diff --git a/CHANGELOG.md b/CHANGELOG.md index be29ebc..5676d12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- **vdsm test infrastructure fixes** (#21) — fixes conftest `instance_id` validation (dots → hyphens), adds admin credentials from golden image metadata, rewrites `setup_dsm.py` with Playwright-based user creation (ExtJS-compatible `type()` input, DOM-based popup removal, wizard step navigation), adds `container_id` property, switches to stronger test password for DSM password policy. Podman KVM passthrough works; 21/47 vdsm tests pass on bare DSM without storage volume. Storage volume automation is a follow-up. - **GitHub Sponsors funding configuration** (#20) — adds `.github/FUNDING.yml` to enable the Sponsor button on the repository - **Test coverage Phase 3 + Phase 4 of #14** (#19) — closes #14. Total coverage 93% → 96%, with Phase 4's `--cov-fail-under=95` guardrail enforced in `pyproject.toml` so future regressions fail CI. Three more files at 100% (`server.py` 57% → 99% — one defensive `if self._client is None` branch unreachable; `core/auth.py` 90% → 100%; `modules/__init__.py` 96% → 100%; `core/formatting.py` 97% → 99%). Test count 457 → 487 (+30 cases). New `TestSharedClientManagerLifecycle` (15 cases) directly tests the lazy `get_client` init, `with_update_notice` clearing logic, signal handler installation including SIGTERM closure invocation, `_cleanup_session` with both running-loop and no-loop paths, and `_bg_update_check` with newer-version, no-update, and error-swallowing scenarios. New `TestPlatformLabel`, `TestCreateServerInstructionPaths` cover the `_platform_label` Darwin/Linux/Windows branches and the `instructions_file` / `custom_instructions` template paths. New `TestDbusSocketMissing`, `TestLoginErrorPaths`, `TestLogout` close the remaining gaps in `core/auth.py` (D-Bus socket-not-found branch, non-2FA SynologyError propagation, "no sid" AuthenticationError, and the three logout paths). No production code touched. - **Test coverage Phase 2 of #14** (#17) — total coverage 85% → 93%. `cli/version.py` 27% → 100% and `cli/setup.py` 63% → 100%, the two largest gaps remaining after Phase 1. Test count 392 → 457 (+65 cases) across two new test files: `tests/core/test_cli_version.py` (40 cases covering `_get_current_version`/`_get_latest_pypi_version`/`_version_tuple`/`_detect_installer`/`_load_global_state`/`_save_global_state`/`_check_for_update`/`_do_auto_upgrade`/`_do_revert`, with `urlopen` and `subprocess.run` mocked at the boundary), and `tests/core/test_cli_setup.py` (25 cases covering the async helpers `_attempt_login`/`_connect_and_login`/`_setup_login` including the 2FA bootstrap path with device-token storage, plus `_setup_credential_flow` error paths, the `setup` command's discovered-config valid-and-invalid branches, the `_setup_interactive` validation-failure exit, and the `_emit_claude_desktop_snippet` Linux DBUS fallback). No production code touched. diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 1d26e90..26b3339 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -68,38 +68,72 @@ Virtual-DSM tests run the same integration test suite against a Docker container running full Synology DSM via QEMU/KVM. This enables testing across multiple DSM versions without a physical NAS. +### Current Status + +**21 of 47 vdsm tests pass** on a bare DSM 7.2.2 instance (wizard completed, admin + test user created). The remaining 26 tests require shared folders on a properly configured storage volume. Virtual-dsm does not auto-create a storage volume during initial setup — this requires either manual Storage Manager configuration or additional automation (tracked as a follow-up). + +Tests that pass without a volume: connection (3), system info (1), resource usage (2), error handling (3), empty search cases (2), and several listing/metadata/transfer tests that validate error responses. + ### Requirements -- Linux host with KVM support (`/dev/kvm` must exist) -- Docker installed and running +- **Linux host with KVM** — `/dev/kvm` must exist. macOS is not supported (KVM is Linux-only); use the real NAS integration tests on macOS. +- **Podman (recommended)** or native Docker Engine — Docker Desktop runs containers inside a VM that lacks `/dev/kvm` passthrough. Podman runs natively on the host with direct KVM access. - ~2 GB disk per DSM version (golden images) -macOS is not currently supported — KVM is Linux-only. Use the real NAS integration -tests on macOS. +#### Podman Setup + +```bash +# Enable the Podman API socket (one-time) +systemctl --user enable --now podman.socket +``` + +The test infrastructure auto-detects the Podman socket at `/run/user/$UID/podman/podman.sock` and prefers it over Docker Desktop. No `DOCKER_HOST` configuration needed. ### First-Time Setup -Each DSM version needs a one-time golden image creation (~15 min): +Each DSM version needs a one-time golden image creation (~5 min): ```bash uv sync --extra dev --extra vdsm -python scripts/vdsm_setup.py --version 7.2.2 +uv run playwright install chromium +echo y | uv run python scripts/vdsm_setup.py --version 7.2.2 \ + --admin-user mcpadmin --admin-password 'McpTest123!' ``` -The script boots a fresh virtual-dsm container. Complete the DSM setup -wizard in your browser (set admin password, basic storage). The script then -automatically creates test users, shared folders, and seed data via the DSM API. +The setup script: +1. Boots a fresh virtual-dsm container via Podman/Docker +2. Automates the DSM first-boot wizard via headless Playwright (account, updates, analytics) +3. Dismisses post-login popups (2FA, MFA promotions) by force-removing them from the DOM +4. Creates the `mcptest` test user via the Control Panel User Creation Wizard (Playwright) +5. Creates test directories and seed data via `docker exec` +6. Saves a compressed golden image to `.vdsm/golden/` -Golden images are stored in `.vdsm/golden/` (~2 GB each, gitignored). +Golden images are stored in `.vdsm/golden/` (~865 MB each, gitignored). + +#### Manual Storage Volume Setup (for full 47-test suite) + +After the automated setup, the golden image has no DSM storage volume. To enable shared folder tests: + +1. Boot the golden image: run `vdsm_setup.py` but stop it before the "Stopping container" step (or boot manually) +2. Open the DSM web UI at the container's URL +3. Open **Storage Manager** → create a **Storage Pool** (Basic/SHR) on the virtual disk → create a **Volume** +4. Open **Control Panel** → **Shared Folder** → create `testshare` and `writable` +5. Set read/write permissions for `mcptest` on both shares +6. Upload test files to `/testshare/Documents/` (report.txt, search_target.txt) and `/testshare/Media/` (sample.mkv) +7. Stop the container and re-save the golden image + +This manual step is a one-time investment per DSM version. Automating Storage Manager via Playwright is a planned follow-up. ### Running ```bash -uv run pytest -m vdsm -v --log-cli-level=INFO # Default (7.2.2) -uv run pytest -m vdsm -v --dsm-version 7.1 # Specific version -uv run pytest -m vdsm -v -k "TestSearch" # Single test class +uv run pytest -m vdsm -v --log-cli-level=INFO --no-cov # Default (7.2.2) +uv run pytest -m vdsm -v --dsm-version 7.1 --no-cov # Specific version +uv run pytest -m vdsm -v -k "TestConnection" --no-cov # Single test class ``` +Note: `--no-cov` is recommended for vdsm runs since the coverage floor (95%) applies globally and vdsm tests alone won't meet it. + ### Supported DSM Versions | Version | Build | PAT | @@ -113,10 +147,17 @@ uv run pytest -m vdsm -v -k "TestSearch" # Single test class ### How It Works 1. Restores a golden image (pre-configured DSM) to a temp storage directory -2. Boots the virtual-dsm Docker container (~2 min) +2. Boots the virtual-dsm container via Podman/Docker (~30s from golden image) 3. Runs the same test functions as the real NAS integration tests 4. Container is stopped and cleaned up automatically +### Known Limitations + +- **No auto-volume** — virtual-dsm's QEMU disk is visible to DSM but requires manual Storage Manager configuration to create a storage pool and volume. The `DISK_SIZE` env var controls disk size but does not trigger DSM-level volume creation. +- **Undocumented APIs fail** — `SYNO.Core.User`, `SYNO.Core.Share`, and `SYNO.Core.Share.Permission` return error 105/403 on virtual-dsm even with valid admin sessions. User creation is done via Playwright web UI automation instead. +- **DSM password policy** — DSM 7.2.2 requires "Strong" passwords (blocks "Moderate"). The test user password is `Mcp#Test9!xK27zQ`. +- **Boot time** — first boot from PAT download takes ~2 min; subsequent boots from golden image take ~30s. + ## Design Docs Detailed specs live in `docs/specs/`. These were the original design documents — the code is authoritative where they diverge (e.g., DSM API version pinning, GET-only requests, and search behavior were discovered during live testing and are documented in `CLAUDE.md`). diff --git a/scripts/vdsm_setup.py b/scripts/vdsm_setup.py index 797d3be..1d984f4 100755 --- a/scripts/vdsm_setup.py +++ b/scripts/vdsm_setup.py @@ -92,10 +92,15 @@ def setup(dsm_version: str, admin_user: str, admin_password: str) -> None: click.echo("Aborted.") sys.exit(0) - # 3. Create storage directory + # 3. Create clean storage directory (must be empty for fresh DSM wizard) + import shutil as _shutil + store = storage_path(dsm_version) + if store.exists(): + _shutil.rmtree(store) + click.echo(f"\nCleared existing storage: {store}") store.mkdir(parents=True, exist_ok=True) - click.echo(f"\nStorage directory: {store}") + click.echo(f"Storage directory: {store}") # 4. Start virtual-dsm container click.echo(f"\nStarting virtual-dsm container (DSM {dsm_version})...") @@ -115,9 +120,20 @@ def setup(dsm_version: str, admin_user: str, admin_password: str) -> None: click.echo("\nRunning DSM setup wizard (automated)...") complete_wizard(base_url, admin_user, admin_password) - # 7. Run post-wizard API configuration + # 7. Wait for DSM services to initialize after wizard + click.echo("\nWaiting 30s for DSM services to initialize after wizard...") + import time + + time.sleep(30) + + # 8. Run post-wizard API configuration click.echo("\nConfiguring DSM for integration testing...") - metadata = setup_dsm_for_testing(base_url, admin_password, admin_user=admin_user) + metadata = setup_dsm_for_testing( + base_url, + admin_password, + admin_user=admin_user, + container_id=container.container_id, + ) # 8. Stop container gracefully click.echo("\nStopping container...") diff --git a/tests/vdsm/config.py b/tests/vdsm/config.py index 3e27e6f..f4be0b6 100644 --- a/tests/vdsm/config.py +++ b/tests/vdsm/config.py @@ -83,7 +83,7 @@ def storage_path(version: str) -> Path: # Default credentials for test DSM instances DEFAULT_ADMIN_USER: str = "mcpadmin" DEFAULT_TEST_USER: str = "mcptest" -DEFAULT_TEST_PASSWORD: str = "McpTest123!" +DEFAULT_TEST_PASSWORD: str = "Mcp#Test9!xK27zQ" # Container resource limits CONTAINER_DISK_SIZE: str = "16G" diff --git a/tests/vdsm/conftest.py b/tests/vdsm/conftest.py index 41e8830..186bd16 100644 --- a/tests/vdsm/conftest.py +++ b/tests/vdsm/conftest.py @@ -83,13 +83,17 @@ def vdsm_config( config = AppConfig( schema_version=1, - instance_id=f"vdsm-{dsm_version}", + instance_id=f"vdsm-{dsm_version.replace('.', '-')}", connection={ "host": host, "port": port, "https": False, "verify_ssl": False, }, + auth={ + "username": meta.get("admin_user", "admin"), + "password": meta.get("admin_password", ""), + }, modules={ "filestation": { "enabled": True, @@ -170,7 +174,7 @@ async def admin_client( # Build admin config admin_config = AppConfig( schema_version=1, - instance_id=f"vdsm-admin-{dsm_version}", + instance_id=f"vdsm-admin-{dsm_version.replace('.', '-')}", connection={ "host": conn.host, "port": conn.port, @@ -179,6 +183,7 @@ async def admin_client( }, auth={ "username": meta.get("admin_user", "admin"), + "password": meta.get("admin_password", ""), }, modules={ "filestation": {"enabled": True, "permission": "write"}, diff --git a/tests/vdsm/container.py b/tests/vdsm/container.py index 671c06d..ec2fffc 100644 --- a/tests/vdsm/container.py +++ b/tests/vdsm/container.py @@ -191,6 +191,15 @@ def _wait_for_dsm(self) -> None: msg = f"DSM {self.version} did not become ready within {elapsed}s" raise TimeoutError(msg) + @property + def container_id(self) -> str: + """Docker container ID (short hash).""" + if self._container is None: + msg = "Container is not started" + raise RuntimeError(msg) + cid: str = self._container._container.id # type: ignore[union-attr] + return cid[:12] + @property def base_url(self) -> str: """HTTP base URL for the running DSM instance.""" diff --git a/tests/vdsm/setup_dsm.py b/tests/vdsm/setup_dsm.py index c40869f..9db5d45 100644 --- a/tests/vdsm/setup_dsm.py +++ b/tests/vdsm/setup_dsm.py @@ -2,18 +2,27 @@ Includes: - Playwright-based wizard automation (first-boot setup, fully headless) -- Post-wizard API configuration (users, shares, permissions, test data) +- Playwright-based post-wizard configuration (user creation) +- Docker exec-based shared folder and test data creation -Note: SYNO.Core.User, SYNO.Core.Share, and SYNO.Core.Share.Permission are -UNDOCUMENTED admin APIs. Parameters are based on community reverse engineering -and may need adjustment across DSM versions. +The undocumented SYNO.Core.* admin APIs do not work on virtual-dsm +(error 105/403), so user creation is automated through the DSM web UI. + +Shared folder creation requires a configured storage volume. Virtual-dsm +does not auto-create one during initial setup, so folders are created +directly on the filesystem via docker exec as a temporary workaround +until Storage Manager automation is added. + +DSM 7 uses a windowed desktop UI with frequent overlay masks. Clicks use +JavaScript dispatch or Playwright's force=True to bypass overlay interception. """ from __future__ import annotations -import io import logging +import subprocess import time +from pathlib import Path from typing import Any import httpx @@ -28,12 +37,47 @@ logger = logging.getLogger(__name__) +# Directory for debug screenshots on failure +_SCREENSHOT_DIR = Path(__file__).parent.parent.parent / ".vdsm" / "screenshots" -def wait_for_api(base_url: str, timeout: int = DSM_BOOT_TIMEOUT) -> None: - """Poll DSM API info endpoint until it responds. - Retries every DSM_API_POLL_INTERVAL seconds until success or timeout. +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _screenshot(page: Any, name: str) -> None: + """Save a debug screenshot.""" + _SCREENSHOT_DIR.mkdir(parents=True, exist_ok=True) + path = _SCREENSHOT_DIR / f"{name}.png" + page.screenshot(path=str(path)) + print(f" Screenshot: {path.name}") + + +def _click_text(page: Any, text: str, *, timeout: int = 3) -> bool: + """Click a visible element containing exact text via JavaScript. + + Returns True if an element was found and clicked. """ + clicked = page.evaluate(f"""() => {{ + const els = [...document.querySelectorAll('a, button, span, div, p, label')]; + const el = els.find(e => e.textContent.trim() === {text!r} + && e.offsetParent !== null); + if (el) {{ el.click(); return true; }} + return false; + }}""") + if clicked: + time.sleep(timeout) + return clicked + + +# --------------------------------------------------------------------------- +# Boot wait +# --------------------------------------------------------------------------- + + +def wait_for_api(base_url: str, timeout: int = DSM_BOOT_TIMEOUT) -> None: + """Poll DSM API info endpoint until it responds.""" url = f"{base_url}/webapi/query.cgi?api=SYNO.API.Info&version=1&method=query&query=ALL" start = time.monotonic() deadline = start + timeout @@ -59,15 +103,13 @@ def wait_for_api(base_url: str, timeout: int = DSM_BOOT_TIMEOUT) -> None: raise TimeoutError(msg) -def complete_wizard(base_url: str, admin_user: str, admin_password: str) -> None: - """Automate the DSM first-boot setup wizard using Playwright. +# --------------------------------------------------------------------------- +# First-boot wizard +# --------------------------------------------------------------------------- - Fills in the admin account form and clicks through all wizard pages - (update settings, Synology account, analytics, package offers). - Requires: playwright with chromium installed - (uv sync --extra vdsm && uv run playwright install chromium) - """ +def complete_wizard(base_url: str, admin_user: str, admin_password: str) -> None: + """Automate the DSM first-boot setup wizard using Playwright.""" try: from playwright.sync_api import sync_playwright except ImportError as e: @@ -84,13 +126,11 @@ def complete_wizard(base_url: str, admin_user: str, admin_password: str) -> None page.goto(base_url, wait_until="networkidle", timeout=60000) time.sleep(3) - # Step 1: Welcome — wait for wizard to fully render, then click Start print(" [1/6] Welcome page (waiting for wizard to load)...") page.wait_for_selector(".welcome-page-btn", timeout=120000) page.click(".welcome-page-btn") time.sleep(2) - # Step 2: Account setup — fill form and click Next print(" [2/6] Account setup...") page.fill("input[name=device_name]", "VirtualDSM") page.fill("input[name=nas_account]", admin_user) @@ -105,36 +145,30 @@ def complete_wizard(base_url: str, admin_user: str, admin_password: str) -> None page.click("button:has-text('Next')") time.sleep(3) - # Verify we advanced (check for error banner) error = page.query_selector(".v-tooltip-error, .error-msg") if error and error.is_visible(): error_text = error.inner_text() msg = f"Wizard account setup failed: {error_text}" raise RuntimeError(msg) - # Step 3: Update options — accept default, click Next print(" [3/6] Update options...") page.click("button:has-text('Next')") time.sleep(3) - # Step 4: Synology Account — click Skip print(" [4/6] Synology Account (skipping)...") page.click("button:has-text('Skip')") time.sleep(3) - # Step 5: Device Analytics — click Submit (unchecked = decline) print(" [5/6] Device Analytics (declining)...") page.click("button:has-text('Submit')") time.sleep(3) - # Step 6: Synology Drive/Office install — click "No, thanks" print(" [6/6] Package install offer (declining)...") no_btn = page.query_selector("button:has-text('No, thanks')") if no_btn and no_btn.is_visible(): no_btn.click() time.sleep(3) else: - # Some DSM versions may not show this step logger.info("No package install prompt found — skipping") print(" Wizard complete!") @@ -143,326 +177,247 @@ def complete_wizard(base_url: str, admin_user: str, admin_password: str) -> None browser.close() -def login(base_url: str, username: str, password: str) -> tuple[str, str]: - """Login to DSM and return (session_id, syno_token). +# --------------------------------------------------------------------------- +# Post-wizard popups +# --------------------------------------------------------------------------- - The SynoToken is a CSRF token required by DSM 7 for admin write operations - (SYNO.Core.User, SYNO.Core.Share, etc.). Requested via enable_syno_token=yes. - """ - params = { - "api": "SYNO.API.Auth", - "version": "6", - "method": "login", - "account": username, - "passwd": password, - "format": "sid", - "enable_syno_token": "yes", - } - resp = httpx.get( - f"{base_url}/webapi/entry.cgi", - params=params, - timeout=30, - verify=False, # noqa: S501 - ) - resp.raise_for_status() - body = resp.json() - - if not body.get("success"): - code = body.get("error", {}).get("code", 0) - msg = f"Login failed with error code {code}" - raise RuntimeError(msg) - - data = body["data"] - sid: str = data["sid"] - syno_token: str = data.get("synotoken", "") - logger.info("Logged in as %s (sid=%s..., synotoken=%s)", username, sid[:8], bool(syno_token)) - return sid, syno_token +def _dismiss_all_popups(page: Any) -> None: + """Dismiss all overlay popups: 2FA, MFA, tour, notifications. -def logout(base_url: str, sid: str) -> None: - """Logout from DSM, invalidating the session.""" - params = { - "api": "SYNO.API.Auth", - "version": "6", - "method": "logout", - "_sid": sid, - } - try: - resp = httpx.get( - f"{base_url}/webapi/entry.cgi", - params=params, - timeout=10, - verify=False, # noqa: S501 - ) - resp.raise_for_status() - logger.info("Logged out successfully") - except Exception: - logger.warning("Logout failed (non-critical)", exc_info=True) - - -def _admin_post( - base_url: str, - sid: str, - syno_token: str, - data: dict[str, str], -) -> dict[str, Any]: - """Make an admin POST request with SynoToken CSRF header. - - DSM 7 requires the SynoToken for all admin write operations - (SYNO.Core.User, SYNO.Core.Share, etc.). - """ - data["_sid"] = sid - headers: dict[str, str] = {} - if syno_token: - headers["X-SYNO-TOKEN"] = syno_token - resp = httpx.post( - f"{base_url}/webapi/entry.cgi", - data=data, - headers=headers, - timeout=30, - verify=False, # noqa: S501 - ) - resp.raise_for_status() - return resp.json() # type: ignore[no-any-return] - - -def create_user( - base_url: str, sid: str, username: str, password: str, *, syno_token: str = "" -) -> None: - """Create a local DSM user. - - Uses the undocumented SYNO.Core.User API. If it fails, prints manual - instructions and continues. + DSM 7 shows persistent promotion dialogs after first login. Clicking + through the MFA confirmation flow is unreliable — the dialog can + reappear. Instead, we click the initial 2FA "No, thanks" then + force-remove all promotion windows and overlay masks from the DOM. """ - try: - body = _admin_post( - base_url, - sid, - syno_token, - { - "api": "SYNO.Core.User", - "method": "create", - "version": "1", - "name": username, - "password": password, - "description": "MCP integration test user", - }, - ) - - if body.get("success"): - logger.info("Created user: %s", username) - print(f" Created user: {username}") - else: - code = body.get("error", {}).get("code", 0) - logger.warning("Create user API returned error code %d", code) - print(f" Warning: Create user returned error code {code}") - _print_manual_user_instructions(username, password) - except Exception: - logger.warning("Create user API call failed", exc_info=True) - _print_manual_user_instructions(username, password) - - -def _print_manual_user_instructions(username: str, password: str) -> None: - """Print instructions for manual user creation via web UI.""" - print("\n Manual step needed — create user via DSM web UI:") - print(" Control Panel > User & Group > Create") - print(f" Username: {username}") - print(f" Password: {password}") - print() - - -def create_shared_folder( - base_url: str, sid: str, name: str, vol_path: str = "/volume1", *, syno_token: str = "" -) -> None: - """Create a shared folder on the NAS. - - Uses the undocumented SYNO.Core.Share API. If it fails, prints manual - instructions and continues. + # Step 1: Dismiss initial popups via button clicks + for _round in range(3): + time.sleep(1) + if _click_text(page, "No, thanks", timeout=2): + print(" Dismissed: 2FA promotion") + continue + if _click_text(page, "No, Thanks", timeout=2): + print(" Dismissed: 2FA promotion (alt)") + continue + break + + # Step 2: Force-remove ALL promotion/overlay windows from the DOM. + removed = page.evaluate("""() => { + let count = 0; + document.querySelectorAll( + '.syno-promotion-app, [syno-id="promotion-app-window"]' + ).forEach(el => { el.remove(); count++; }); + document.querySelectorAll( + '.v-window-container-mask, .v-window-mask' + ).forEach(el => { el.remove(); count++; }); + return count; + }""") + if removed: + print(f" Force-removed {removed} promotion/overlay elements") + time.sleep(2) + + # Step 3: Close notification toasts + page.evaluate("""() => { + document.querySelectorAll('.x-tool-close, .v-close-btn') + .forEach(btn => { if (btn.offsetParent !== null) btn.click(); }); + }""") + time.sleep(1) + + +# --------------------------------------------------------------------------- +# Login +# --------------------------------------------------------------------------- + + +def _dsm_login(page: Any, base_url: str, username: str, password: str) -> None: + """Login to DSM desktop via the two-step login UI.""" + page.goto(base_url, wait_until="networkidle", timeout=60000) + time.sleep(3) + + # Step 1: Enter username and press Enter + user_field = page.wait_for_selector("input:visible", timeout=30000) + if user_field: + user_field.fill(username) + time.sleep(1) + page.keyboard.press("Enter") + time.sleep(3) + + # Step 2: Enter password and press Enter + pass_field = page.wait_for_selector("input[type='password']:visible", timeout=10000) + if pass_field: + pass_field.fill(password) + time.sleep(1) + page.keyboard.press("Enter") + + # Wait for desktop to load + time.sleep(10) + _dismiss_all_popups(page) + print(" Logged in to DSM desktop") + _screenshot(page, "02-desktop") + + +# --------------------------------------------------------------------------- +# Control Panel navigation +# --------------------------------------------------------------------------- + + +def _open_control_panel(page: Any) -> None: + """Open Control Panel from the DSM desktop.""" + _dismiss_all_popups(page) + + cp = page.query_selector("text='Control Panel'") + if cp and cp.is_visible(): + cp.dblclick(force=True) + time.sleep(5) + _dismiss_all_popups(page) + print(" Opened Control Panel") + _screenshot(page, "03-control-panel") + return + + print(" Warning: Could not find Control Panel shortcut") + _screenshot(page, "03-control-panel-missing") + + +# --------------------------------------------------------------------------- +# User creation via Playwright +# --------------------------------------------------------------------------- + + +def _create_user_via_ui(page: Any, username: str, password: str) -> None: + """Create a local user via Control Panel > User & Group wizard. + + Uses Playwright's type() for form fields (not fill()) because DSM's + ExtJS framework requires keystroke events for internal validation. + The wizard buttons are