From fd1f76a0858f0294ae19e2c3112094bd51a845f8 Mon Sep 17 00:00:00 2001 From: "cmeans-claude-dev[bot]" <272174644+cmeans-claude-dev[bot]@users.noreply.github.com> Date: Wed, 13 May 2026 21:32:06 -0500 Subject: [PATCH 01/15] feat(vdsm): install Download Station into the golden image Adds _install_download_station_via_ui() in tests/vdsm/setup_dsm.py that walks the operator path through Package Center: open Package Center, search "Download Station", click Install, walk any license/confirmation dialogs, poll up to 5 min for the Installed state. Wired into setup_dsm_for_testing() as a new step [3/5] between user creation and SSH enable. golden_image.py metadata now records dsm_packages=["DownloadStation"] so a cached pre-DS golden image is detectable for invalidation. Bake-time only: subsequent test runs restore from the tarball and skip the install. Cold bake adds ~3-5 min to vdsm CI; cached runs are unaffected. Refs: #106 --- tests/vdsm/golden_image.py | 1 + tests/vdsm/setup_dsm.py | 125 +++++++++++++++++++++++++++++++++++-- 2 files changed, 121 insertions(+), 5 deletions(-) diff --git a/tests/vdsm/golden_image.py b/tests/vdsm/golden_image.py index 109248f..bc7ab82 100644 --- a/tests/vdsm/golden_image.py +++ b/tests/vdsm/golden_image.py @@ -60,6 +60,7 @@ def save_golden_image( "admin_user": DEFAULT_ADMIN_USER, "test_user": DEFAULT_TEST_USER, "test_password": DEFAULT_TEST_PASSWORD, + "dsm_packages": ["DownloadStation"], "test_paths": { "existing_share": "/testshare", "search_folder": "/testshare/Documents", diff --git a/tests/vdsm/setup_dsm.py b/tests/vdsm/setup_dsm.py index 6301b15..c01a0db 100644 --- a/tests/vdsm/setup_dsm.py +++ b/tests/vdsm/setup_dsm.py @@ -576,6 +576,117 @@ def _verify_setup_via_api(base_url: str, username: str, password: str) -> bool: return False +# --------------------------------------------------------------------------- +# Download Station install via Package Center UI +# --------------------------------------------------------------------------- + + +def _install_download_station_via_ui( + page: Any, + install_timeout_sec: int = 300, +) -> None: + """Install the Download Station package via DSM Package Center UI. + + Required at golden-image bake time because: + - The vdsm base image doesn't ship Download Station + - Test cases in tests/vdsm/test_vdsm_downloadstation.py need DS installed + to register tools against the live API + + Operator flow (mirrored here): + 1. Click Package Center desktop shortcut (or open via Main Menu) + 2. Search "Download Station" + 3. Click Install on the search result + 4. Accept any license / permission dialog + 5. Wait for "Installed" state (can take 1-3 minutes; download is fetched + from Synology's package server, so this step requires outbound HTTP) + + install_timeout_sec defaults to 300 (5 min) because cold-cache installs + can be slow on CI runners. Bake-time only — subsequent test runs restore + from the golden image. + """ + print(" Opening Package Center...") + _screenshot(page, "ds-01-before-package-center") + + # Open Package Center. DSM 7's Main Menu lives at the top-left; clicking + # the menu icon then the "Package Center" tile is the operator path. + # If a desktop shortcut for Package Center exists, prefer it (fewer steps). + pkg_shortcut = page.query_selector("text='Package Center'") + if pkg_shortcut and pkg_shortcut.is_visible(): + pkg_shortcut.click(force=True) + else: + # Fall back to Main Menu + page.locator(".sds-mainmenu-btn, [data-qa='main-menu']").first.click(force=True) + time.sleep(1) + page.locator("text='Package Center'").first.click(force=True) + time.sleep(5) + _screenshot(page, "ds-02-package-center-open") + + # Search for Download Station + print(" Searching for Download Station...") + search_input = page.locator("input[placeholder*='Search'], input[aria-label*='Search']").first + search_input.click(force=True) + search_input.type("Download Station", delay=50) + time.sleep(3) + _screenshot(page, "ds-03-search-results") + + # Click Install on the Download Station card. The button label is "Install" + # for not-yet-installed packages; if the bake re-runs on an image that + # already has DS, we'll see "Open" or "Run" instead — short-circuit. + install_btn = page.query_selector("button:has-text('Install')") + if not install_btn or not install_btn.is_visible(): + # Already installed — nothing to do. + already = page.query_selector("button:has-text('Open'), button:has-text('Run')") + if already and already.is_visible(): + print(" Download Station already installed — skipping install") + _screenshot(page, "ds-04-already-installed") + return + # Neither Install nor Open visible — something's wrong. + _screenshot(page, "ds-04-install-button-missing") + msg = "Could not find Install button or installed marker for Download Station" + raise RuntimeError(msg) + + install_btn.click(force=True) + time.sleep(3) + _screenshot(page, "ds-05-install-clicked") + + # Accept any license / configuration wizard dialogs. DSM 7's Package Center + # may show: (a) volume selection if multiple volumes exist (vdsm has one), + # (b) license agreement, (c) confirmation dialog. Walk through them by + # clicking the primary action button repeatedly until we see "Apply" / "Done" + # or the progress indicator appears. + for step in range(6): + time.sleep(2) + next_btn = page.query_selector( + "button:has-text('Next'), button:has-text('Agree'), " + "button:has-text('Apply'), button:has-text('Done'), " + "button:has-text('Continue')" + ) + if next_btn and next_btn.is_visible(): + next_btn.click(force=True) + _screenshot(page, f"ds-06-dialog-step-{step}") + else: + break + + # Wait for installation to complete. Poll for the "Installed" status or + # the "Run"/"Open" button (which indicates the package is installed and + # available). + print(f" Waiting for Download Station install to complete (up to {install_timeout_sec}s)...") + deadline = time.time() + install_timeout_sec + while time.time() < deadline: + time.sleep(5) + installed = page.query_selector( + "button:has-text('Open'), button:has-text('Run'), text='Installed'" + ) + if installed and installed.is_visible(): + print(" Download Station installed successfully") + _screenshot(page, "ds-07-install-complete") + return + + _screenshot(page, "ds-07-install-timeout") + msg = f"Download Station install did not complete within {install_timeout_sec}s" + raise TimeoutError(msg) + + # --------------------------------------------------------------------------- # Main setup entry point # --------------------------------------------------------------------------- @@ -611,13 +722,16 @@ def setup_dsm_for_testing( page = browser.new_page(viewport={"width": 1280, "height": 900}) try: - print("\n [1/4] Logging in to DSM...") + print("\n [1/5] Logging in to DSM...") _dsm_login(page, base_url, admin_user, admin_password) - print("\n [2/4] Creating test user...") + print("\n [2/5] Creating test user...") _open_control_panel(page) _create_user_via_ui(page, DEFAULT_TEST_USER, DEFAULT_TEST_PASSWORD) + print("\n [3/5] Installing Download Station via Package Center...") + _install_download_station_via_ui(page) + except Exception: _screenshot(page, "error-state") raise @@ -626,10 +740,10 @@ def setup_dsm_for_testing( # 2. Enable SSH and create shared folders via synoshare in the DSM guest if ssh_port: - print("\n [3/4] Enabling SSH...") + print("\n [4/5] Enabling SSH...") _enable_ssh(base_url, admin_user, admin_password) - print("\n [4/4] Creating shared folders and test data via SSH...") + print("\n [5/5] Creating shared folders and test data via SSH...") _create_shared_folders_via_ssh( ssh_host, ssh_port, @@ -637,7 +751,7 @@ def setup_dsm_for_testing( DEFAULT_TEST_USER, ) else: - print("\n [3/4] No SSH port — skipping share creation") + print("\n [4/5] No SSH port — skipping share creation") # Verify — check both admin and test user can see shares print("\n Verifying setup...") @@ -653,6 +767,7 @@ def setup_dsm_for_testing( "admin_password": admin_password, "test_user": DEFAULT_TEST_USER, "test_password": DEFAULT_TEST_PASSWORD, + "dsm_packages": ["DownloadStation"], "test_paths": { "existing_share": "/testshare", "search_folder": "/testshare/Documents", From a89674fcd512e5aebdaf066f6ae230feaa675869 Mon Sep 17 00:00:00 2001 From: "cmeans-claude-dev[bot]" <272174644+cmeans-claude-dev[bot]@users.noreply.github.com> Date: Wed, 13 May 2026 21:34:27 -0500 Subject: [PATCH 02/15] feat(vdsm): configure Download Station after install MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds _configure_download_station_via_api() that runs after share creation: sets DS default_destination to /writable, grants test_user permission for the DS package. API-only (no Playwright needed post-install). Permission grant uses SYNO.Core.Package.Setting.User with a graceful fallback (warning + continue) if that API is restricted on vdsm — the test failures will surface the gap. Wired into setup_dsm_for_testing() as new step [6/6] after share creation. Step counts in the orchestrator bumped from [N/5] to [N/6]. --- tests/vdsm/setup_dsm.py | 136 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 130 insertions(+), 6 deletions(-) diff --git a/tests/vdsm/setup_dsm.py b/tests/vdsm/setup_dsm.py index c01a0db..b44f304 100644 --- a/tests/vdsm/setup_dsm.py +++ b/tests/vdsm/setup_dsm.py @@ -687,6 +687,116 @@ def _install_download_station_via_ui( raise TimeoutError(msg) +# --------------------------------------------------------------------------- +# Download Station configuration via API +# --------------------------------------------------------------------------- + + +def _configure_download_station_via_api( + base_url: str, + admin_user: str, + admin_password: str, + test_user: str, + default_destination: str = "writable", +) -> None: + """Configure Download Station after install: default destination + permissions. + + Runs once at golden-image bake time. Uses DSM admin session to: + 1. Set DS default destination to an existing share + 2. Grant the test_user permission to use DS (via the DSM + Application Privileges system) + + All calls go through the DSM web API (login -> admin session -> operation). + The permission-grant API name may differ across DSM versions; a failure + on that call logs a warning rather than failing the bake — test failures + from a 105 on DS calls will surface the gap clearly in CI logs. + """ + print(" Configuring Download Station (admin session)...") + + with httpx.Client(base_url=base_url, timeout=30, verify=False) as client: # noqa: S501 + # Admin login + login_resp = client.get( + "/webapi/auth.cgi", + params={ + "api": "SYNO.API.Auth", + "version": "3", + "method": "login", + "account": admin_user, + "passwd": admin_password, + "session": "DownloadStation", + "format": "cookie", + }, + ) + login_data = login_resp.json() + if not login_data.get("success"): + msg = f"Admin login failed: {login_data.get('error')}" + raise RuntimeError(msg) + sid = login_data["data"]["sid"] + + try: + # 1. Set DS default destination + print(f" Setting DS default_destination = {default_destination}") + setconfig_resp = client.get( + "/webapi/entry.cgi", + params={ + "api": "SYNO.DownloadStation.Info", + "version": "1", + "method": "setconfig", + "default_destination": default_destination, + "_sid": sid, + }, + ) + setconfig_data = setconfig_resp.json() + if not setconfig_data.get("success"): + err = setconfig_data.get("error", {}).get("code", "?") + msg = f"DS setconfig failed: code {err}" + raise RuntimeError(msg) + + # 2. Grant test_user permission for DS + # DSM uses SYNO.Core.Package.Setting.User to manage per-user package + # access. The exact API call shape varies by DSM version — use the + # most widely supported form. If this returns 105/403 (no permission + # via API on vdsm — the SYNO.Core.* surface is restricted on + # virtual-dsm), don't fail the bake; log a warning. A test failure + # with code 105 on DS calls will surface the missing permission + # clearly enough that the operator can fall back to a UI grant. + print(f" Granting {test_user} permission for DownloadStation...") + perm_resp = client.get( + "/webapi/entry.cgi", + params={ + "api": "SYNO.Core.Package.Setting.User", + "version": "1", + "method": "set", + "package": "DownloadStation", + "users": '[{"name":"' + test_user + '","allowed":true}]', + "_sid": sid, + }, + ) + perm_data = perm_resp.json() + if not perm_data.get("success"): + code = perm_data.get("error", {}).get("code", "?") + logger.warning( + "DS permission grant for %s returned code %s — test_user may " + "lack DS access. May need manual UI grant.", + test_user, + code, + ) + finally: + # Logout regardless of success + client.get( + "/webapi/auth.cgi", + params={ + "api": "SYNO.API.Auth", + "version": "1", + "method": "logout", + "session": "DownloadStation", + "_sid": sid, + }, + ) + + print(" Download Station configured.") + + # --------------------------------------------------------------------------- # Main setup entry point # --------------------------------------------------------------------------- @@ -722,14 +832,14 @@ def setup_dsm_for_testing( page = browser.new_page(viewport={"width": 1280, "height": 900}) try: - print("\n [1/5] Logging in to DSM...") + print("\n [1/6] Logging in to DSM...") _dsm_login(page, base_url, admin_user, admin_password) - print("\n [2/5] Creating test user...") + print("\n [2/6] Creating test user...") _open_control_panel(page) _create_user_via_ui(page, DEFAULT_TEST_USER, DEFAULT_TEST_PASSWORD) - print("\n [3/5] Installing Download Station via Package Center...") + print("\n [3/6] Installing Download Station via Package Center...") _install_download_station_via_ui(page) except Exception: @@ -740,18 +850,32 @@ def setup_dsm_for_testing( # 2. Enable SSH and create shared folders via synoshare in the DSM guest if ssh_port: - print("\n [4/5] Enabling SSH...") + print("\n [4/6] Enabling SSH...") _enable_ssh(base_url, admin_user, admin_password) - print("\n [5/5] Creating shared folders and test data via SSH...") + print("\n [5/6] Creating shared folders and test data via SSH...") _create_shared_folders_via_ssh( ssh_host, ssh_port, admin_password, DEFAULT_TEST_USER, ) + + print("\n [6/6] Configuring Download Station...") + _configure_download_station_via_api( + base_url=base_url, + admin_user=admin_user, + admin_password=admin_password, + test_user=DEFAULT_TEST_USER, + default_destination="writable", + ) else: - print("\n [4/5] No SSH port — skipping share creation") + print("\n [4/6] No SSH port — skipping share creation") + print( + " [5/6] No SSH port — skipping DS configuration" + " (default destination needs an existing share)" + ) + print(" [6/6] Skipped") # Verify — check both admin and test user can see shares print("\n Verifying setup...") From b27370da59b8f79b98869a5a2944439bc860482d Mon Sep 17 00:00:00 2001 From: "cmeans-claude-dev[bot]" <272174644+cmeans-claude-dev[bot]@users.noreply.github.com> Date: Wed, 13 May 2026 21:37:42 -0500 Subject: [PATCH 03/15] test(vdsm): add Download Station integration tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five READ-tool tests plus a create/read/delete lifecycle test. The lifecycle test uses a fake magnet URI (no seeds, no leech behavior) so the task creates instantly and sits at 'waiting' status — gives get_download_info real data to render without any network dependency or wait-for-download flakiness. Skip-cleanly fixture _ds_available checks SYNO.DownloadStation.Task is in the API cache; tests skip if the package isn't installed (e.g., running this file against a real NAS that doesn't have DS). --- tests/vdsm/test_vdsm_downloadstation.py | 181 ++++++++++++++++++++++++ 1 file changed, 181 insertions(+) create mode 100644 tests/vdsm/test_vdsm_downloadstation.py diff --git a/tests/vdsm/test_vdsm_downloadstation.py b/tests/vdsm/test_vdsm_downloadstation.py new file mode 100644 index 0000000..245de81 --- /dev/null +++ b/tests/vdsm/test_vdsm_downloadstation.py @@ -0,0 +1,181 @@ +"""Integration tests for Download Station tools against virtual-dsm. + +Requires DS installed in the golden image (see tests/vdsm/setup_dsm.py +_install_download_station_via_ui). Tests skip cleanly if the package isn't +available on the connected NAS (e.g., running against a real NAS without DS). + +Run with: uv run pytest -m vdsm -v tests/vdsm/test_vdsm_downloadstation.py +""" + +from __future__ import annotations + +import asyncio +from typing import TYPE_CHECKING, Any + +import pytest + +if TYPE_CHECKING: + from mcp_synology.core.client import DsmClient + +pytestmark = pytest.mark.vdsm + + +# A magnet URI that's parseable but won't actually leech anything in CI +# (random hex hash with no real torrent behind it). DS accepts magnets at +# create time without verifying the swarm — the task will sit at "waiting" +# status indefinitely, which is fine for read-tool exercises. +_FAKE_MAGNET = "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567&dn=test-file" + + +def _unpack(nas_client: Any) -> tuple[DsmClient, Any, Any, dict[str, str]]: + """Unpack the nas_client tuple yielded by conftest.""" + return nas_client # type: ignore[return-value] + + +@pytest.fixture +async def _ds_available(nas_client: Any) -> bool: + """Skip if Download Station isn't installed on the connected NAS.""" + client, _, _, _ = _unpack(nas_client) + info = await client.query_api_info() + if "SYNO.DownloadStation.Task" not in info: + pytest.skip("Download Station package not installed on this NAS") + return True + + +class TestVdsmDownloadStationReads: + """Exercise the 5 READ tools against a real DSM instance. + + Most tests run against the empty queue (default state after fresh install). + """ + + async def test_list_downloads_empty_queue(self, nas_client: Any, _ds_available: bool) -> None: + from mcp_synology.modules.downloadstation.tasks import list_downloads + + client, _, _, _ = _unpack(nas_client) + result = await list_downloads(client) + # Empty queue renders the table header but no rows + assert "Download Station queue" in result + # The total-line should report 0 tasks OR the empty-table sentinel + # depending on which path format_table takes for empty rows. + assert "0 task" in result or "No items to display" in result + + async def test_get_download_stats_returns_speeds( + self, nas_client: Any, _ds_available: bool + ) -> None: + from mcp_synology.modules.downloadstation.stats import get_download_stats + + client, _, _, _ = _unpack(nas_client) + result = await get_download_stats(client) + # Headline rows must be present whether or not transfers are active + assert "Download (total)" in result + assert "Upload (total)" in result + + async def test_get_download_config_returns_known_fields( + self, nas_client: Any, _ds_available: bool + ) -> None: + from mcp_synology.modules.downloadstation.config import get_download_config + + client, _, _, _ = _unpack(nas_client) + result = await get_download_config(client) + # Expected fields rendered by the handler + assert "Default destination" in result + assert "BT max download" in result + assert "eMule enabled" in result + # The setup configured default_destination=writable — confirm it's set + assert "writable" in result + + async def test_get_schedule_returns_grid(self, nas_client: Any, _ds_available: bool) -> None: + from mcp_synology.modules.downloadstation.config import get_schedule + + client, _, _, _ = _unpack(nas_client) + result = await get_schedule(client) + # Header rendered + assert "Enabled" in result + # Grid rendered + assert "Sun" in result + assert "Sat" in result + assert "Legend" in result + + +class TestVdsmDownloadStationLifecycle: + """Create + read + delete lifecycle to exercise get_download_info against + a real task (which the empty-queue tests can't cover). + """ + + async def test_create_read_delete_task(self, nas_client: Any, _ds_available: bool) -> None: + from mcp_synology.modules.downloadstation.tasks import ( + create_download, + delete_download, + get_download_info, + list_downloads, + ) + + client, _, _, _ = _unpack(nas_client) + created_task_id: str | None = None + try: + # Create a magnet-URI task — won't actually transfer (no seeds), + # but DS accepts it and creates a task record. + create_result = await create_download(client, uri=_FAKE_MAGNET, destination="writable") + assert "Created" in create_result + + # Give DS a moment to commit the new task (some DSM versions + # need a tick before list reflects new entries). + await asyncio.sleep(2) + + # Find the new task via list_downloads. The fake magnet uses dn=test-file, + # which DS will surface as the task title. + listed = await list_downloads(client) + assert "test-file" in listed or "0123456789abcdef" in listed.lower(), ( + f"Newly-created task not found in list_downloads output:\n{listed}" + ) + + # Pull the task id out of the list output. The list table renders + # the id in the first column; parse it by finding the row that + # contains our magnet's identifying substring. + created_task_id = _extract_task_id_from_list(listed) + assert created_task_id, f"Could not parse task id from:\n{listed}" + + # Read full task info — exercises the additional=detail,transfer,file,tracker,peer path + info = await get_download_info(client, task_id=created_task_id) + assert created_task_id in info or "test-file" in info + assert "Status" in info # header block + assert "Detail" in info # detail block + + finally: + # Cleanup — always attempt deletion, even on test failure + if created_task_id is not None: + try: + await delete_download( + client, + task_ids=[created_task_id], + delete_data=True, + ) + except Exception as exc: # noqa: BLE001 + # Swallow cleanup errors — the test result has priority. + # Log via pytest's stderr capture so the failure shows up + # in CI logs. + print(f"WARNING: failed to delete test task {created_task_id}: {exc}") + + +def _extract_task_id_from_list(list_output: str) -> str | None: + """Parse a task id from the list_downloads table output. + + Format is: ID | Title | Type | Status | Size | Progress | Speed | ETA + with the columns space-separated and rendered by format_table. The first + non-header, non-empty content line gives us the id in column 0. + """ + lines = list_output.splitlines() + in_table = False + for line in lines: + stripped = line.strip() + if not stripped: + continue + if stripped.startswith("ID "): + in_table = True + continue + if in_table and not stripped.startswith("-") and not stripped.endswith("task(s) total"): + # First column is the task id — split on multiple spaces + parts = stripped.split() + if parts: + return parts[0] + return None From a3f09067de6be64f0cac67f4f40b9cc8d3c033de Mon Sep 17 00:00:00 2001 From: "cmeans-claude-dev[bot]" <272174644+cmeans-claude-dev[bot]@users.noreply.github.com> Date: Wed, 13 May 2026 21:39:09 -0500 Subject: [PATCH 04/15] docs: add vdsm Download Station coverage notes --- CHANGELOG.md | 2 ++ CLAUDE.md | 1 + 2 files changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f047f46..a5012ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ### Added +- **vdsm Download Station coverage** (#PR_PLACEHOLDER) — closes #106. The vdsm golden image now installs Download Station at bake time via Playwright through Package Center (`tests/vdsm/setup_dsm.py:_install_download_station_via_ui`), and configures it post-install via DSM API (`_configure_download_station_via_api`: sets `default_destination=writable`, grants the test_user permission with a graceful warning-and-continue fallback if `SYNO.Core.Package.Setting.User` is restricted on the vdsm DSM build). New `tests/vdsm/test_vdsm_downloadstation.py` exercises the five Phase 1 READ tools against the real DSM instance (`list_downloads`, `get_download_info`, `get_download_stats`, `get_download_config`, `get_schedule`), plus a create/read/delete lifecycle test that uses a fake magnet URI (no seeds, no leech behavior) so the task creates instantly and `get_download_info` has real data to render without network or wait-for-download flakiness. The `_ds_available` fixture skip-cleanly checks `SYNO.DownloadStation.Task` is in the API cache, so the file is safe to run against any DSM-compatible test target. Cold bake adds ~3–5 min for the DS install; cached restores are unaffected. `golden_image.py` metadata now records `dsm_packages=["DownloadStation"]` so cached pre-DS images are detectable for invalidation. + - **Download Station module — Phase 2 (WRITE tools)** (#105). Second of three planned PRs from the DS module spec (`docs/superpowers/specs/2026-05-13-downloadstation-module-design.md`). Adds seven WRITE-tier tools: `create_download` (URI list or local `.torrent`/`.nzb` file via multipart POST), `delete_download` (explicit `delete_data: bool` required — `True` proceeds with the destructive default-DSM-behavior of removing both task and files, `False` is REFUSED with a clear error because DSM v1 `Task.delete` has no documented "keep files" mode), `pause_download` / `resume_download` (siblings sharing a private `_task_state_change` helper that walks the per-task error list), `edit_download` (currently `destination` only — DSM's `Task.edit` supported-field set varies by DSM version and is verified per-NAS), `set_download_config` (partial updates only — only supplied fields are written), `set_schedule` (168-character weekly plan, validated client-side via the same `SCHEDULE_PLAN_LENGTH` invariant `format_schedule_grid` uses on the read side). New `core/downloadstation_errors.py` carries DS-specific 400-series error codes (different semantics from FileStation's reuse of the same numeric range — DS 400 is "file upload failed", FS 400 is "invalid parameter"); `error_from_code()` dispatches to the DS map via a deferred local import when the api begins with `SYNO.DownloadStation`, with explicit regression tests guarding that codes 105 (permission denied) and 106 (session expired) still fall through to the common 100-series handlers for DS APIs — the CLAUDE.md "never re-auth on 105" invariant must hold for DS calls too. New `DsmClient.create_download_task_with_file()` mirrors the existing `upload_file()` multipart helper with the same re-auth-on-session-error retry semantics and `_sid`-masked debug logging. Bonus fix to `DsmClient.request()` debug logging: was calling `.keys()` unconditionally on the response `data` field, which crashed for endpoints (like `Task.delete`) that return a list instead of a dict — now guarded with `isinstance(data, dict)`. Re-auth retry tests added for `create_download_task_with_file` (multipart path) and the URI-path `create_download` documents the standard-GET re-auth surface — closes the operational coverage gap flagged at issue #99 for the DS module specifically. 62 new tests; every new module file at 100% line coverage; 727 repo tests pass at 96.26% overall. - **Download Station module — Phase 1 (READ tools)** (#104). New module `mcp_synology.modules.downloadstation` adds five read-only tools — `list_downloads`, `get_download_info`, `get_download_stats`, `get_download_config`, `get_schedule` — that expose Synology's Download Station package via DSM API v1 (`SYNO.DownloadStation.Task`, `Info`, `Statistic`, `Schedule`). Module is opt-in via `instances.[name].modules.downloadstation.enabled: true`; the API preflight skips registration entirely when the DS package is not installed on the NAS, so users without DS pay zero LLM-context cost (no tools registered, no handler imports, no MODULE_INFO consumed beyond the preflight). Module shape mirrors File Station: domain-split files (`tasks.py`, `stats.py`, `config.py`, `helpers.py`), lazy handler imports inside `register()`, `respx`-mocked unit tests. Reuses the existing shared DSM session — DSM does not gate API access by session name, so no auth-manager changes were needed; ADR-0001's deferral of per-client sessions applies to client-isolation, not per-package isolation. `list_downloads` does client-side status filtering (DSM v1 `Task.list` lacks server-side filter support); `get_download_info` requests all five `additional` groups (`detail,transfer,file,tracker,peer`) and renders them as distinct sections; `get_schedule` parses DSM's 168-character `schedule_plan` into a 7×24 ASCII grid. Tool input validation uses the project's standard `error_response(ErrorCode.INVALID_PARAMETER, ...)` structured envelope. Phase 2 (task CRUD writes + global config writes) and Phase 3 (BT search + RSS) to follow as separate PRs. 60 new unit tests; every new module file (`__init__.py`, `tasks.py`, `stats.py`, `config.py`, `helpers.py`) at 100% line coverage. Full design at `docs/superpowers/specs/2026-05-13-downloadstation-module-design.md`. diff --git a/CLAUDE.md b/CLAUDE.md index 7c79ca0..a282eee 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -122,6 +122,7 @@ uv run pytest --cov=mcp_synology # Tests with coverage - Configure NAS connection + `test_paths` (existing_share, search_folder, search_keyword, writable_folder) - Run: `uv run pytest -m integration -v --log-cli-level=INFO` - Search tests can be flaky if the NAS search service is overloaded — allow recovery time between runs +- **vdsm Download Station coverage**: the vdsm golden image now installs Download Station at bake time via Playwright through Package Center (`tests/vdsm/setup_dsm.py:_install_download_station_via_ui`) and configures it post-install via DSM API (`_configure_download_station_via_api`: sets `default_destination=writable`, grants the test_user permission). Cached images skip re-install; cold bake adds ~3–5 min. If `tests/vdsm/test_vdsm_downloadstation.py` reports `pytest.skip("Download Station package not installed")`, the cached golden image is stale — delete `.vdsm/golden_images/*.tar.gz` to force a re-bake. ## Common Tasks From 8b58270e133eb4f5b5ec5b98fa9e3e2cf65eb76e Mon Sep 17 00:00:00 2001 From: "cmeans-claude-dev[bot]" <272174644+cmeans-claude-dev[bot]@users.noreply.github.com> Date: Wed, 13 May 2026 21:41:02 -0500 Subject: [PATCH 05/15] docs(changelog): substitute real PR# 107 for placeholder --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a5012ee..9ca5bb8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Added -- **vdsm Download Station coverage** (#PR_PLACEHOLDER) — closes #106. The vdsm golden image now installs Download Station at bake time via Playwright through Package Center (`tests/vdsm/setup_dsm.py:_install_download_station_via_ui`), and configures it post-install via DSM API (`_configure_download_station_via_api`: sets `default_destination=writable`, grants the test_user permission with a graceful warning-and-continue fallback if `SYNO.Core.Package.Setting.User` is restricted on the vdsm DSM build). New `tests/vdsm/test_vdsm_downloadstation.py` exercises the five Phase 1 READ tools against the real DSM instance (`list_downloads`, `get_download_info`, `get_download_stats`, `get_download_config`, `get_schedule`), plus a create/read/delete lifecycle test that uses a fake magnet URI (no seeds, no leech behavior) so the task creates instantly and `get_download_info` has real data to render without network or wait-for-download flakiness. The `_ds_available` fixture skip-cleanly checks `SYNO.DownloadStation.Task` is in the API cache, so the file is safe to run against any DSM-compatible test target. Cold bake adds ~3–5 min for the DS install; cached restores are unaffected. `golden_image.py` metadata now records `dsm_packages=["DownloadStation"]` so cached pre-DS images are detectable for invalidation. +- **vdsm Download Station coverage** (#107) — closes #106. The vdsm golden image now installs Download Station at bake time via Playwright through Package Center (`tests/vdsm/setup_dsm.py:_install_download_station_via_ui`), and configures it post-install via DSM API (`_configure_download_station_via_api`: sets `default_destination=writable`, grants the test_user permission with a graceful warning-and-continue fallback if `SYNO.Core.Package.Setting.User` is restricted on the vdsm DSM build). New `tests/vdsm/test_vdsm_downloadstation.py` exercises the five Phase 1 READ tools against the real DSM instance (`list_downloads`, `get_download_info`, `get_download_stats`, `get_download_config`, `get_schedule`), plus a create/read/delete lifecycle test that uses a fake magnet URI (no seeds, no leech behavior) so the task creates instantly and `get_download_info` has real data to render without network or wait-for-download flakiness. The `_ds_available` fixture skip-cleanly checks `SYNO.DownloadStation.Task` is in the API cache, so the file is safe to run against any DSM-compatible test target. Cold bake adds ~3–5 min for the DS install; cached restores are unaffected. `golden_image.py` metadata now records `dsm_packages=["DownloadStation"]` so cached pre-DS images are detectable for invalidation. - **Download Station module — Phase 2 (WRITE tools)** (#105). Second of three planned PRs from the DS module spec (`docs/superpowers/specs/2026-05-13-downloadstation-module-design.md`). Adds seven WRITE-tier tools: `create_download` (URI list or local `.torrent`/`.nzb` file via multipart POST), `delete_download` (explicit `delete_data: bool` required — `True` proceeds with the destructive default-DSM-behavior of removing both task and files, `False` is REFUSED with a clear error because DSM v1 `Task.delete` has no documented "keep files" mode), `pause_download` / `resume_download` (siblings sharing a private `_task_state_change` helper that walks the per-task error list), `edit_download` (currently `destination` only — DSM's `Task.edit` supported-field set varies by DSM version and is verified per-NAS), `set_download_config` (partial updates only — only supplied fields are written), `set_schedule` (168-character weekly plan, validated client-side via the same `SCHEDULE_PLAN_LENGTH` invariant `format_schedule_grid` uses on the read side). New `core/downloadstation_errors.py` carries DS-specific 400-series error codes (different semantics from FileStation's reuse of the same numeric range — DS 400 is "file upload failed", FS 400 is "invalid parameter"); `error_from_code()` dispatches to the DS map via a deferred local import when the api begins with `SYNO.DownloadStation`, with explicit regression tests guarding that codes 105 (permission denied) and 106 (session expired) still fall through to the common 100-series handlers for DS APIs — the CLAUDE.md "never re-auth on 105" invariant must hold for DS calls too. New `DsmClient.create_download_task_with_file()` mirrors the existing `upload_file()` multipart helper with the same re-auth-on-session-error retry semantics and `_sid`-masked debug logging. Bonus fix to `DsmClient.request()` debug logging: was calling `.keys()` unconditionally on the response `data` field, which crashed for endpoints (like `Task.delete`) that return a list instead of a dict — now guarded with `isinstance(data, dict)`. Re-auth retry tests added for `create_download_task_with_file` (multipart path) and the URI-path `create_download` documents the standard-GET re-auth surface — closes the operational coverage gap flagged at issue #99 for the DS module specifically. 62 new tests; every new module file at 100% line coverage; 727 repo tests pass at 96.26% overall. From f4f1aaa79191e7f8391f859f63648a4ed7e3b77d Mon Sep 17 00:00:00 2001 From: "cmeans-claude-dev[bot]" <272174644+cmeans-claude-dev[bot]@users.noreply.github.com> Date: Wed, 13 May 2026 22:04:17 -0500 Subject: [PATCH 06/15] fix(vdsm): launch Package Center via SYNO.SDS.AppLaunch, not UI selectors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI bake at fd1f76a failed in _install_download_station_via_ui — the Main Menu fallback selector (.sds-mainmenu-btn, [data-qa='main-menu']) timed out at 30s. Root cause: after _create_user_via_ui completes, Control Panel is still open in the foreground, occluding the desktop; the initial text='Package Center' query found nothing, so the fallback fired against a selector that doesn't match the actual DSM UI. Replace the UI navigation with a direct JS call to SYNO.SDS.AppLaunch — DSM's internal app-launch mechanism that doesn't care which window is in focus, doesn't need taskbar selectors, and is the same call DSM apps use to launch each other. Keep a desktop-shortcut fallback (double-click, matching _open_control_panel) for the case where AppLaunch isn't on window.SYNO.SDS for some reason. Also dismiss popups first (matching the existing pattern in _open_control_panel) and bump post-launch sleep to 10s — Package Center fetches its catalog over HTTP on first launch. --- tests/vdsm/setup_dsm.py | 53 +++++++++++++++++++++++++++++++---------- 1 file changed, 41 insertions(+), 12 deletions(-) diff --git a/tests/vdsm/setup_dsm.py b/tests/vdsm/setup_dsm.py index b44f304..17a0082 100644 --- a/tests/vdsm/setup_dsm.py +++ b/tests/vdsm/setup_dsm.py @@ -607,18 +607,47 @@ def _install_download_station_via_ui( print(" Opening Package Center...") _screenshot(page, "ds-01-before-package-center") - # Open Package Center. DSM 7's Main Menu lives at the top-left; clicking - # the menu icon then the "Package Center" tile is the operator path. - # If a desktop shortcut for Package Center exists, prefer it (fewer steps). - pkg_shortcut = page.query_selector("text='Package Center'") - if pkg_shortcut and pkg_shortcut.is_visible(): - pkg_shortcut.click(force=True) - else: - # Fall back to Main Menu - page.locator(".sds-mainmenu-btn, [data-qa='main-menu']").first.click(force=True) - time.sleep(1) - page.locator("text='Package Center'").first.click(force=True) - time.sleep(5) + # Clear popups left over from prior steps (mirrors what _open_control_panel + # does). The user-creation step leaves Control Panel open in the foreground, + # which occluded the desktop in the first CI attempt — a desktop-shortcut + # search returned nothing, and the Main Menu fallback selector was wrong. + _dismiss_all_popups(page) + + # Launch Package Center via DSM's internal AppLaunch API. This is the + # canonical mechanism DSM apps use to launch each other and works + # regardless of which window is currently in focus — no UI scraping, no + # taskbar selector. The internal app id for Package Center is + # SYNO.SDS.PkgManApp.Instance. + launched = page.evaluate("""() => { + try { + if (window.SYNO && window.SYNO.SDS && window.SYNO.SDS.AppLaunch) { + window.SYNO.SDS.AppLaunch('SYNO.SDS.PkgManApp.Instance'); + return 'app_launch'; + } + } catch (e) {} + return null; + }""") + + if not launched: + # Fallback: try the desktop shortcut (matches the _open_control_panel + # operator pattern — double-click required for desktop icons). + pkg = page.query_selector("text='Package Center'") + if pkg and pkg.is_visible(): + pkg.dblclick(force=True) + launched = "desktop_shortcut" + + if not launched: + _screenshot(page, "ds-02-launch-failed") + msg = ( + "Could not launch Package Center via SYNO.SDS.AppLaunch or " + "desktop shortcut. DSM UI may have changed." + ) + raise RuntimeError(msg) + + print(f" Launched Package Center via {launched}") + # Package Center needs time to initialize on first launch (catalog + # fetch over HTTP). Wait longer than other windows. + time.sleep(10) _screenshot(page, "ds-02-package-center-open") # Search for Download Station From cba47a5667cd43727a9bf139e812ba3f677ffe3b Mon Sep 17 00:00:00 2001 From: "cmeans-claude-dev[bot]" <272174644+cmeans-claude-dev[bot]@users.noreply.github.com> Date: Wed, 13 May 2026 22:19:32 -0500 Subject: [PATCH 07/15] fix(vdsm): drop text= from install-complete poll selector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI bake at f4f1aaa got further — AppLaunch fired and the Install button was clicked — but failed in the install-complete poll loop with a Playwright CSS parse error: Unexpected token "=" while parsing css selector "button:has-text('Open'), button:has-text('Run'), text='Installed'" Playwright's text= selector engine doesn't compose into a comma-separated CSS list. Drop the text='Installed' token and rely on the button selectors — Install gets replaced with Open/Run once installation completes, so the button check alone is sufficient. Other text='...' usages in the file (lines 265 + 634) are standalone selectors and unaffected; the comma-list mixing was the only bug. --- tests/vdsm/setup_dsm.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/vdsm/setup_dsm.py b/tests/vdsm/setup_dsm.py index 17a0082..2ba1cbd 100644 --- a/tests/vdsm/setup_dsm.py +++ b/tests/vdsm/setup_dsm.py @@ -696,15 +696,16 @@ def _install_download_station_via_ui( else: break - # Wait for installation to complete. Poll for the "Installed" status or - # the "Run"/"Open" button (which indicates the package is installed and - # available). + # Wait for installation to complete. Poll for the "Run" / "Open" button + # which replaces "Install" once the package is available. NOTE: do NOT + # mix Playwright's text= selector engine into a CSS list — that fails at + # parse time. :has-text() is CSS-compatible and sufficient here. print(f" Waiting for Download Station install to complete (up to {install_timeout_sec}s)...") deadline = time.time() + install_timeout_sec while time.time() < deadline: time.sleep(5) installed = page.query_selector( - "button:has-text('Open'), button:has-text('Run'), text='Installed'" + "button:has-text('Open'), button:has-text('Run')" ) if installed and installed.is_visible(): print(" Download Station installed successfully") From 8c4db503ade91397745c4f828f9d861744e6825f Mon Sep 17 00:00:00 2001 From: "cmeans-claude-dev[bot]" <272174644+cmeans-claude-dev[bot]@users.noreply.github.com> Date: Wed, 13 May 2026 22:49:47 -0500 Subject: [PATCH 08/15] fix(vdsm): expand dialog walker labels, add screenshot artifact upload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI bake at cba47a5 got further still — Install button clicked, but the install-complete poll loop timed out at 300s with no Open/Run button ever appearing. Without screenshots from CI it's hard to know whether the install hung partway, the dialog walker missed a button, or Synology's package server was unreachable. Three changes to make the next iteration diagnosable AND more likely to succeed: 1. Expand the post-Install dialog walker label set: add Confirm/OK/Yes/Install. DSM 7's package-install confirmation dialogs vary; the previous walker (Next/Agree/Apply/Done/Continue) may have exited too early. 2. Add periodic install-progress screenshots during the poll loop (every ~60s). The final screenshot at timeout already exists; this adds intermediate ones so post-mortem diagnostics can see whether the install stalled at 0% or at a specific step. 3. Add a vdsm-failure-screenshots artifact upload step to vdsm.yml so .vdsm/screenshots/ is recoverable from CI on failure. The existing vdsm-failure-logs step only uploads /tmp/vdsm-*.log and .vdsm/storage/**/log/ — screenshots were being lost. Also: ruff format check failed at the previous push because I ran `ruff check` locally but not `ruff format --check`. The CI check is `ruff format --check src/ tests/ scripts/`; running that locally now matches CI. --- .github/workflows/vdsm.yml | 9 +++++++++ tests/vdsm/setup_dsm.py | 24 +++++++++++++++++------- 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/.github/workflows/vdsm.yml b/.github/workflows/vdsm.yml index 020d417..d2e0fa4 100644 --- a/.github/workflows/vdsm.yml +++ b/.github/workflows/vdsm.yml @@ -83,3 +83,12 @@ jobs: /tmp/vdsm-*.log if-no-files-found: ignore retention-days: 7 + + - name: Upload Playwright screenshots on failure + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: vdsm-failure-screenshots + path: .vdsm/screenshots/ + if-no-files-found: ignore + retention-days: 7 diff --git a/tests/vdsm/setup_dsm.py b/tests/vdsm/setup_dsm.py index 2ba1cbd..9cd25f9 100644 --- a/tests/vdsm/setup_dsm.py +++ b/tests/vdsm/setup_dsm.py @@ -681,14 +681,20 @@ def _install_download_station_via_ui( # Accept any license / configuration wizard dialogs. DSM 7's Package Center # may show: (a) volume selection if multiple volumes exist (vdsm has one), # (b) license agreement, (c) confirmation dialog. Walk through them by - # clicking the primary action button repeatedly until we see "Apply" / "Done" - # or the progress indicator appears. - for step in range(6): + # clicking the primary action button repeatedly. Expanded button-label + # set after the first CI iteration timed out without seeing the Open/Run + # marker — the previous walker's labels (Next/Agree/Apply/Done/Continue) + # missed Confirm/OK/Yes/Install which DSM 7 uses on package-install + # confirmation dialogs. + time.sleep(3) # Give the first dialog a moment to appear before polling + for step in range(10): time.sleep(2) next_btn = page.query_selector( "button:has-text('Next'), button:has-text('Agree'), " "button:has-text('Apply'), button:has-text('Done'), " - "button:has-text('Continue')" + "button:has-text('Continue'), button:has-text('Confirm'), " + "button:has-text('OK'), button:has-text('Yes'), " + "button:has-text('Install')" ) if next_btn and next_btn.is_visible(): next_btn.click(force=True) @@ -702,15 +708,19 @@ def _install_download_station_via_ui( # parse time. :has-text() is CSS-compatible and sufficient here. print(f" Waiting for Download Station install to complete (up to {install_timeout_sec}s)...") deadline = time.time() + install_timeout_sec + poll_iter = 0 while time.time() < deadline: time.sleep(5) - installed = page.query_selector( - "button:has-text('Open'), button:has-text('Run')" - ) + installed = page.query_selector("button:has-text('Open'), button:has-text('Run')") if installed and installed.is_visible(): print(" Download Station installed successfully") _screenshot(page, "ds-07-install-complete") return + # Periodic screenshots during the wait so post-mortem diagnostics + # show install progress (or stall point) without needing local repro. + poll_iter += 1 + if poll_iter % 12 == 0: # every ~60s + _screenshot(page, f"ds-07-install-progress-{poll_iter * 5}s") _screenshot(page, "ds-07-install-timeout") msg = f"Download Station install did not complete within {install_timeout_sec}s" From 97cbbfa9dd5ab8f96163226b9f53745cf10c4cae Mon Sep 17 00:00:00 2001 From: "cmeans-claude-dev[bot]" <272174644+cmeans-claude-dev[bot]@users.noreply.github.com> Date: Wed, 13 May 2026 23:55:01 -0500 Subject: [PATCH 09/15] fix(vdsm): dismiss Package Center Terms of Service modal on first launch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Screenshots from CI bake at 8c4db50 revealed the root cause of the 300s install timeout: DSM 7 Package Center shows a "Synology Package Center Terms of Services" modal on first launch that blocks everything underneath. The modal has a checkbox ("I have read and agreed to the Package Center Terms of Service") + a primary action button. Earlier iterations clicked through what they THOUGHT were the search box and Install button, but the clicks landed on the modal overlay or were eaten by the disabled controls behind it — the install never actually started. The five-minute install poll then ran out, and the final screenshot showed the same ToS modal still up. Fix: after launching Package Center and waiting for it to initialize, detect the ToS modal by looking for "Terms of Service" text on a visible element. If present: 1. Tick the agreement checkbox via JS click 2. Click the primary action button (label varies — try OK / Apply / Agree / Accept) 3. Wait for the modal to close New screenshots ds-02a-tos-shown, ds-02b-tos-checked, ds-02c-tos-accepted trace the dismissal so future post-mortems can verify the flow. --- tests/vdsm/setup_dsm.py | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/tests/vdsm/setup_dsm.py b/tests/vdsm/setup_dsm.py index 9cd25f9..a89e327 100644 --- a/tests/vdsm/setup_dsm.py +++ b/tests/vdsm/setup_dsm.py @@ -650,6 +650,46 @@ def _install_download_station_via_ui( time.sleep(10) _screenshot(page, "ds-02-package-center-open") + # DSM 7 Package Center shows a Terms of Service modal on first launch + # that blocks everything underneath. Earlier CI iterations clicked + # through to the search results and Install button visually, but the + # clicks were all eaten by the modal's overlay — the install never + # actually started. Detect the ToS, check the agreement box, click + # OK/Apply to close it. + tos_visible = page.evaluate( + """() => { + return [...document.querySelectorAll('div, span, h1, h2')].some( + el => el.textContent && el.textContent.includes('Terms of Service') + && el.offsetParent !== null + ); + }""" + ) + if tos_visible: + print(" Detected Package Center Terms of Service modal; accepting...") + _screenshot(page, "ds-02a-tos-shown") + # Check the agreement checkbox (DSM puts it just above the OK button) + page.evaluate( + """() => { + const cbs = [...document.querySelectorAll('input[type=checkbox]')]; + const visible = cbs.find(cb => cb.offsetParent !== null && !cb.checked); + if (visible) { visible.click(); return true; } + return false; + }""" + ) + time.sleep(1) + _screenshot(page, "ds-02b-tos-checked") + # Click the primary action button (label varies: OK / Apply / Agree / Accept) + ok_btn = page.query_selector( + "button:has-text('OK'), button:has-text('Apply'), " + "button:has-text('Agree'), button:has-text('Accept')" + ) + if ok_btn and ok_btn.is_visible(): + ok_btn.click(force=True) + time.sleep(3) + _screenshot(page, "ds-02c-tos-accepted") + else: + print(" No Terms of Service modal — proceeding directly") + # Search for Download Station print(" Searching for Download Station...") search_input = page.locator("input[placeholder*='Search'], input[aria-label*='Search']").first From 9ac0844ee3d2be0242f8932ba3eb1d5b7bd02d2d Mon Sep 17 00:00:00 2001 From: "cmeans-claude-dev[bot]" <272174644+cmeans-claude-dev[bot]@users.noreply.github.com> Date: Thu, 14 May 2026 00:13:27 -0500 Subject: [PATCH 10/15] fix(vdsm): ExtJS-aware ToS modal dismissal (checkbox label + _click_text) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Screenshots from CI bake at 97cbbfa showed ds-02b/02c identical to ds-02a — the ToS modal wasn't budging. Two root causes: 1. ExtJS doesn't use real . The checkbox is a styled