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/CHANGELOG.md b/CHANGELOG.md index f047f46..eb0a19a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ### Added +- **vdsm Download Station coverage** (#107) — closes #106. The vdsm golden image now installs Download Station at bake time via `synopkg install_from_server DownloadStation` over SSH (`tests/vdsm/setup_dsm.py:_install_download_station_via_ssh`), 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). The SSH approach replaces an earlier Playwright/Package-Center UI path that proved unautomatable against Synology's heavily customised DSM 7 Package Center: the Terms of Service modal uses a `syno-ux` checkbox rendered as `` whose state is driven entirely by Ext 3.4.1's component model, and eight local iterations covering JS `.click()`, CDP `page.mouse.click(x, y)`, scoped Playwright locators, and direct Ext component access all failed to dismiss it. `synopkg install_from_server` completes in ~5s against a live container and is idempotent (skips the install if `synopkg list --name DownloadStation` already shows the package), so cached-golden-image re-bakes are safe. 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 ~10s 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 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..2bbceca 100644 --- a/tests/vdsm/setup_dsm.py +++ b/tests/vdsm/setup_dsm.py @@ -576,6 +576,255 @@ def _verify_setup_via_api(base_url: str, username: str, password: str) -> bool: return False +# --------------------------------------------------------------------------- +# Download Station install via synopkg (SSH) +# --------------------------------------------------------------------------- + + +def _install_download_station_via_ssh( + host: str, + ssh_port: int, + admin_password: str, + base_url: str, + *, + install_timeout_sec: int = 300, +) -> None: + """Install Download Station via synopkg over SSH. + + Replaces the original Playwright/Package-Center UI flow, which proved + unautomatable against Synology's heavily customized DSM 7 Package + Center (Ext 3.4.1 + syno-ux checkbox = no synthetic-click path + dismisses the ToS modal). SSH bypasses the UI entirely and reuses + the `_ssh` helper proven for share creation. + + Three-step start: + 1. `synopkg install_from_server` to install (~3s) + 2. `synopkg start` is a no-op on vDSM — systemd-unit lookup fails + with status_code 263 even though install succeeded + 3. `/var/packages/DownloadStation/scripts/start-stop-status start` + runs the package's own SysV-style start script which actually + starts the DS services (synopkg/synosystemd is the misleading + layer; the package internals work fine) + + Gate is the DSM API cache (SYNO.API.Info query for + SYNO.DownloadStation.Task), NOT `synopkg status` — the latter keeps + reporting stopped on vDSM regardless of actual service state. + + Idempotent on cached golden images: returns early if the API is + already registered. + """ + + def ssh(cmd: str, *, sudo: bool = True) -> tuple[int, str]: + return _ssh(host, ssh_port, admin_password, cmd, sudo=sudo) + + # All DS APIs the downstream bake steps + the vdsm tests touch. + # Different DS APIs register at different times after start — + # Task tends to appear first, Info (used by setconfig at [6/6]) lags. + # Gating on the full set avoids the "Task registered but Info isn't" + # race that bit CI on 43a2526. + required_apis = ( + "SYNO.DownloadStation.Task", + "SYNO.DownloadStation.Info", + "SYNO.DownloadStation.Statistic", + "SYNO.DownloadStation.Schedule", + ) + + def missing_ds_apis() -> list[str]: + """Return the subset of required DS APIs NOT yet in the API cache.""" + try: + resp = httpx.get( + f"{base_url}/webapi/query.cgi", + params={ + "api": "SYNO.API.Info", + "version": "1", + "method": "query", + "query": ",".join(required_apis), + }, + timeout=10, + verify=False, # noqa: S501 + ) + data = resp.json().get("data", {}) + return [api for api in required_apis if not data.get(api)] + except (httpx.HTTPError, ValueError): + return list(required_apis) + + # Idempotency: a cached golden image rebuilt against an already-DS + # image will have all APIs registered already. + if not missing_ds_apis(): + print(" Download Station already running (all DS APIs in cache)") + return + # Install (~3s on a warm Synology package server, 30-60s on cold cache). + print(" Running synopkg install_from_server DownloadStation...") + start = time.monotonic() + rc, out = ssh( + "/usr/syno/bin/synopkg install_from_server DownloadStation", + sudo=True, + ) + elapsed = int(time.monotonic() - start) + if rc != 0: + msg = f"synopkg install_from_server failed after {elapsed}s (rc={rc}): {out.strip()}" + raise RuntimeError(msg) + print(f" synopkg install completed in {elapsed}s") + # Verify install via the package list (cheap sanity check). + rc, out = ssh("/usr/syno/bin/synopkg list --name DownloadStation", sudo=False) + if "DownloadStation" not in out: + msg = f"DownloadStation not in package list after install: {out.strip()[:200]}" + raise RuntimeError(msg) + # Run synopkg start (typically a no-op on vDSM but harmless on real DSM) + # then the package's own start script which actually starts the DS + # services on vDSM. start-stop-status emits benign warnings about + # python2 (eMule plugin only) and a missing amule statistics file — + # both are unrelated to the core download/list/edit task surface. + ssh("/usr/syno/bin/synopkg start DownloadStation", sudo=True) + rc, out = ssh( + "/var/packages/DownloadStation/scripts/start-stop-status start 2>&1", + sudo=True, + ) + print(f" start-stop-status start: rc={rc}") + if "active" not in out.lower() and "started" not in out.lower(): + # No "active" marker — likely failed to start. Surface the output + # so we can debug; don't silently swallow. + print(f" start-stop-status output:\n{out.strip()[:600]}") + # Poll the API cache for ALL required DS APIs — gating on Task alone + # is insufficient because Info (used by setconfig at [6/6]) registers + # on a different timeline. synopkg status keeps reporting stopped on + # vDSM regardless of actual service state, so it's not a useful gate. + deadline = time.monotonic() + 120 + missing: list[str] = list(required_apis) + while time.monotonic() < deadline: + missing = missing_ds_apis() + if not missing: + print(f" All {len(required_apis)} DS APIs registered in DSM API cache.") + break + time.sleep(3) + else: + msg = ( + f"DS APIs still missing from cache after 120s: {missing}. " + "Package services may not have started — check " + "/var/log/synopkg.log on the NAS." + ) + raise RuntimeError(msg) + print(" Download Station installed, started, and API-verified.") + + +# --------------------------------------------------------------------------- +# 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 + # DSM 7.2.2 DS Info API only dispatches via /webapi/DownloadStation/info.cgi, + # NOT the generic /webapi/entry.cgi (which returns error 102 for ALL + # DS API calls). The method name is `setserverconfig`, NOT the + # `setconfig` published in some older DSM API docs — `setconfig` + # returns error 103 (method does not exist). Discovered by probing + # local vdsm 7.2.2 with both endpoints x [setconfig/setserverconfig/ + # set/config/update] x [v1/v2]; only setserverconfig at + # info.cgi succeeded. + print(f" Setting DS default_destination = {default_destination}") + setconfig_resp = client.get( + "/webapi/DownloadStation/info.cgi", + params={ + "api": "SYNO.DownloadStation.Info", + "version": "1", + "method": "setserverconfig", + "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 setserverconfig 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 # --------------------------------------------------------------------------- @@ -611,10 +860,10 @@ 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/6] Logging in to DSM...") _dsm_login(page, base_url, admin_user, admin_password) - print("\n [2/4] 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) @@ -624,20 +873,39 @@ def setup_dsm_for_testing( finally: browser.close() - # 2. Enable SSH and create shared folders via synoshare in the DSM guest + # 2. Enable SSH, install Download Station, create shared folders, + # configure DS — all via the SSH/CLI surface so we never touch + # Synology's customized Package Center UI (which proved unautomatable + # over Ext 3.4.1's component model in 8 local iterations — see + # _install_download_station_via_ssh docstring). if ssh_port: - print("\n [3/4] Enabling SSH...") + print("\n [3/6] Enabling SSH...") _enable_ssh(base_url, admin_user, admin_password) - print("\n [4/4] Creating shared folders and test data via SSH...") + print("\n [4/6] Installing Download Station via synopkg (SSH)...") + _install_download_station_via_ssh(ssh_host, ssh_port, admin_password, base_url) + + 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 [3/4] No SSH port — skipping share creation") + print("\n [3/6] No SSH port — skipping SSH setup") + print(" [4/6] No SSH port — skipping DS install") + print(" [5/6] No SSH port — skipping shares") + print(" [6/6] No SSH port — skipping DS config") # Verify — check both admin and test user can see shares print("\n Verifying setup...") @@ -653,6 +921,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", 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