diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7fc49c37..6f4e512a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -292,16 +292,16 @@ jobs: curl --retry 10 --retry-delay 2 --retry-connrefused http://localhost:4173 - name: Run Cypress E2E tests + id: cypress working-directory: apps/frontend run: npm run test:e2e - continue-on-error: true env: CYPRESS_BASE_URL: http://localhost:4173 CYPRESS_API_URL: http://localhost:7778/api - name: Upload Cypress screenshots on failure uses: actions/upload-artifact@v7 - if: failure() + if: failure() && steps.cypress.outcome == 'failure' with: name: cypress-screenshots path: apps/frontend/tests/e2e/screenshots @@ -336,7 +336,7 @@ jobs: path: .out/coverage/frontend - name: SonarCloud Scan - uses: SonarSource/sonarqube-scan-action@v5 + uses: SonarSource/sonarqube-scan-action@v6 with: args: > -Dsonar.projectKey=scheilch_opencloudtouch diff --git a/.github/workflows/license-check.yml b/.github/workflows/license-check.yml new file mode 100644 index 00000000..d37760b3 --- /dev/null +++ b/.github/workflows/license-check.yml @@ -0,0 +1,82 @@ +# ============================================================================== +# LICENSE CHECK - Verify OSS dependency licenses are compatible +# +# Fails on: GPL, AGPL, LGPL, CPAL, EUPL (copyleft incompatible with MIT) +# Allowed: MIT, Apache-2.0, BSD-*, ISC, Python-2.0, CC0-1.0, 0BSD, Unlicense +# ============================================================================== + +name: License Check + +on: + push: + branches: + - main + paths: + - 'apps/frontend/package.json' + - 'package-lock.json' + - 'apps/backend/requirements.txt' + - 'apps/backend/requirements-dev.txt' + pull_request: + paths: + - 'apps/frontend/package.json' + - 'package-lock.json' + - 'apps/backend/requirements.txt' + - 'apps/backend/requirements-dev.txt' + workflow_dispatch: + +jobs: + license-check: + name: License Check + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v6 + + # ── Frontend (npm) ────────────────────────────────────────────────────── + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '22' + cache: 'npm' + cache-dependency-path: package-lock.json + + - name: Install frontend dependencies + run: npm ci + + - name: Check frontend licenses + run: | + npx license-checker-rseidelsohn \ + --start apps/frontend \ + --production \ + --excludePrivatePackages \ + --onlyAllow "MIT;Apache-2.0;BSD-2-Clause;BSD-3-Clause;ISC;0BSD;Unlicense;CC0-1.0;CC-BY-3.0;CC-BY-4.0;Python-2.0;BlueOak-1.0.0;Artistic-2.0" \ + --summary + + # ── Backend (pip) ─────────────────────────────────────────────────────── + - name: Setup Python + uses: actions/setup-python@v6 + with: + python-version: '3.13' + + - name: Install backend dependencies + run: | + pip install pip-licenses + pip install -r apps/backend/requirements.txt + + - name: Check backend licenses + # asyncssh is an approved dependency: EPL-2.0 (Eclipse Public License 2.0) + # is weak-copyleft — only applies to modifications of asyncssh itself, not + # to embedding projects. Excluded via --ignore-packages because its SPDX + # composite expression "EPL-2.0 OR GPL-2.0-or-later" contains "GPL-2.0-or-later" + # as a substring, which would otherwise trigger the --fail-on check. + # + # LGPL is intentionally NOT in the deny-list: LGPL-2.1 only requires open- + # sourcing modifications to the LGPL library itself, not embedding code. + # zeroconf (mDNS discovery) is LGPL-2.1 and safe to use here. + run: | + pip-licenses \ + --format=markdown \ + --order=license \ + --ignore-packages asyncssh \ + --fail-on="GNU General Public License;GNU Affero General Public License;European Union Public Licence;GPL-2.0-only;GPL-2.0-or-later;GPL-3.0-only;GPL-3.0-or-later;AGPL-3.0-only;AGPL-3.0-or-later;EUPL-1.1;EUPL-1.2" diff --git a/.gitignore b/.gitignore index b266fc69..646513c0 100644 --- a/.gitignore +++ b/.gitignore @@ -65,6 +65,11 @@ apps/backend/openapi.yaml *_TEMP.md CONTEXT_TEMP.md *.log +result.txt +test-output.txt +test*.txt +test_full.txt +apps/backend/result.txt AGENTS.md .github/copilot-instructions.md .github/prompts/ diff --git a/apps/backend/src/opencloudtouch/bmx/radiobrowser_routes.py b/apps/backend/src/opencloudtouch/bmx/radiobrowser_routes.py index 736f0e8b..d1ad422a 100644 --- a/apps/backend/src/opencloudtouch/bmx/radiobrowser_routes.py +++ b/apps/backend/src/opencloudtouch/bmx/radiobrowser_routes.py @@ -14,7 +14,7 @@ from fastapi.responses import JSONResponse from opencloudtouch.bmx.models import BmxAudio, BmxPlaybackResponse, BmxStream -from opencloudtouch.bmx.routes import convert_https_to_http +from opencloudtouch.bmx.stream_utils import convert_https_to_http from opencloudtouch.bmx.tunein import get_oct_base_url from opencloudtouch.radio.adapter import get_radio_adapter from opencloudtouch.radio.providers.radiobrowser import ( diff --git a/apps/backend/src/opencloudtouch/bmx/routes.py b/apps/backend/src/opencloudtouch/bmx/routes.py index 6a6e2b12..dac48d4b 100644 --- a/apps/backend/src/opencloudtouch/bmx/routes.py +++ b/apps/backend/src/opencloudtouch/bmx/routes.py @@ -25,6 +25,7 @@ BmxServicesResponse, BmxStream, ) +from opencloudtouch.bmx.stream_utils import convert_https_to_http from opencloudtouch.bmx.tunein import get_oct_base_url, resolve_tunein_station logger = logging.getLogger(__name__) @@ -32,27 +33,6 @@ router = APIRouter(tags=["bmx"]) -def convert_https_to_http(url: str) -> str: - """Convert HTTPS URLs to HTTP for Bose device compatibility. - - Bose SoundTouch devices cannot play HTTPS streams directly. - Most radio stations support both HTTP and HTTPS, so we try HTTP first. - - Args: - url: Stream URL (may be HTTPS or HTTP) - - Returns: - HTTP version of the URL (https:// → http://) - """ - if url.startswith("https://"): - http_url = "http://" + url[8:] - logger.info( - f"[BMX] Converting HTTPS to HTTP: {url[:50]}... → {http_url[:50]}..." - ) - return http_url - return url - - # ============================================================================= # BMX Registry Endpoint # ============================================================================= @@ -244,9 +224,15 @@ async def custom_stream_playback(request: Request) -> JSONResponse: json_obj = json.loads(json_str) stream_url = json_obj.get("streamUrl", "") + tunein_id = json_obj.get("tuneinId", "") image_url = json_obj.get("imageUrl", "") name = json_obj.get("name", "Custom Station") + # TuneIn stations: resolve stream URL dynamically via TuneIn API + if tunein_id and not stream_url: + logger.info(f"[BMX ORION] TuneIn station detected: {tunein_id} ({name})") + return await _resolve_tunein_for_orion(tunein_id) + # Convert HTTPS to HTTP - Bose devices can't play HTTPS streams stream_url = convert_https_to_http(stream_url) @@ -284,3 +270,24 @@ async def custom_stream_playback(request: Request) -> JSONResponse: status_code=500, headers={"Access-Control-Allow-Origin": "*"}, ) + + +async def _resolve_tunein_for_orion(tunein_id: str) -> JSONResponse: + """Resolve TuneIn station dynamically for Orion playback. + + Called when a preset contains a tuneinId but no streamUrl. + Fetches fresh stream URL from TuneIn API at playback time. + """ + try: + response = await resolve_tunein_station(tunein_id) + return JSONResponse( + content=response.model_dump(), + headers={"Access-Control-Allow-Origin": "*"}, + ) + except Exception as e: + logger.error(f"[BMX ORION] TuneIn resolution failed for {tunein_id}: {e}") + return JSONResponse( + content={"error": f"TuneIn resolution failed: {e}"}, + status_code=500, + headers={"Access-Control-Allow-Origin": "*"}, + ) diff --git a/apps/backend/src/opencloudtouch/bmx/stream_utils.py b/apps/backend/src/opencloudtouch/bmx/stream_utils.py new file mode 100644 index 00000000..ddd82192 --- /dev/null +++ b/apps/backend/src/opencloudtouch/bmx/stream_utils.py @@ -0,0 +1,26 @@ +"""Shared stream URL utilities for BMX modules.""" + +import logging + +logger = logging.getLogger(__name__) + + +def convert_https_to_http(url: str) -> str: + """Convert HTTPS URLs to HTTP for Bose device compatibility. + + Bose SoundTouch devices cannot play HTTPS streams directly. + Most radio stations support both HTTP and HTTPS, so we try HTTP first. + + Args: + url: Stream URL (may be HTTPS or HTTP) + + Returns: + HTTP version of the URL (https:// → http://) + """ + if url.startswith("https://"): + http_url = "http://" + url[8:] # NOSONAR - intentional HTTP for Bose SoundTouch + logger.info( + f"[BMX] Converting HTTPS to HTTP: {url[:50]}... → {http_url[:50]}..." + ) + return http_url + return url diff --git a/apps/backend/src/opencloudtouch/bmx/tunein.py b/apps/backend/src/opencloudtouch/bmx/tunein.py index 9d0fceb2..f906ea43 100644 --- a/apps/backend/src/opencloudtouch/bmx/tunein.py +++ b/apps/backend/src/opencloudtouch/bmx/tunein.py @@ -12,6 +12,7 @@ import httpx from opencloudtouch.bmx.models import BmxAudio, BmxPlaybackResponse, BmxStream +from opencloudtouch.bmx.stream_utils import convert_https_to_http logger = logging.getLogger(__name__) @@ -96,7 +97,9 @@ async def resolve_tunein_station(station_id: str) -> BmxPlaybackResponse: stream_resp = await client.get(TUNEIN_STREAM_URL % station_id) stream_urls = [ - u.strip() for u in stream_resp.text.splitlines() if u.strip() + convert_https_to_http(u.strip()) + for u in stream_resp.text.splitlines() + if u.strip() ] if not stream_urls: diff --git a/apps/backend/src/opencloudtouch/devices/api/preset_stream_routes.py b/apps/backend/src/opencloudtouch/devices/api/preset_stream_routes.py index 91ce0698..3d1a3110 100644 --- a/apps/backend/src/opencloudtouch/devices/api/preset_stream_routes.py +++ b/apps/backend/src/opencloudtouch/devices/api/preset_stream_routes.py @@ -106,85 +106,87 @@ async def stream_device_preset( }, ) - # Stream generator that manages the upstream connection + # Open upstream connection and check status BEFORE sending response headers. + # This ensures we can return proper HTTP error codes (502) instead of + # failing inside a StreamingResponse generator (where headers are already sent). + http_client = httpx.AsyncClient(timeout=30.0, follow_redirects=True) + try: + upstream_response = await http_client.send( + httpx.Request( + "GET", + preset.station_url, + headers={ + "User-Agent": "OpenCloudTouch/0.2.0 (Bose SoundTouch Proxy)", + "Icy-MetaData": "1", + }, + ), + stream=True, + ) + except httpx.RequestError as e: + await http_client.aclose() + logger.error( + f"[502] Failed to fetch RadioBrowser stream: {e}", + extra={ + "device_id": device_id, + "preset_id": preset_id, + "upstream_url": preset.station_url, + "error": str(e), + }, + exc_info=True, + ) + raise HTTPException( + status_code=502, + detail=f"Failed to connect to RadioBrowser: {e}", + ) + + # Check upstream status before committing to StreamingResponse + if upstream_response.status_code != 200: + await upstream_response.aclose() + await http_client.aclose() + logger.error( + f"[502] RadioBrowser stream unavailable: HTTP {upstream_response.status_code}", + extra={ + "device_id": device_id, + "preset_id": preset_id, + "upstream_status": upstream_response.status_code, + "upstream_url": preset.station_url, + }, + ) + raise HTTPException( + status_code=502, + detail=f"RadioBrowser stream unavailable: HTTP {upstream_response.status_code}", + ) + + content_type = upstream_response.headers.get("content-type", "audio/mpeg") + logger.info( + f"[STREAMING] {preset.station_name} → Bose device (HTTP proxy active)", + extra={ + "device_id": device_id, + "preset_id": preset_id, + "content_type": content_type, + "upstream_headers": dict(upstream_response.headers), + }, + ) + async def stream_generator(): - """Generator that fetches and yields audio chunks from RadioBrowser.""" + """Generator that yields audio chunks from the already-opened upstream.""" try: - async with httpx.AsyncClient( - timeout=30.0, follow_redirects=True - ) as client: - async with client.stream( - "GET", - preset.station_url, - headers={ - "User-Agent": "OpenCloudTouch/0.2.0 (Bose SoundTouch Proxy)", - "Icy-MetaData": "1", - }, - ) as upstream_response: - # Check if stream is available - if upstream_response.status_code != 200: - logger.error( - f"[502] RadioBrowser stream unavailable: HTTP {upstream_response.status_code}", - extra={ - "device_id": device_id, - "preset_id": preset_id, - "upstream_status": upstream_response.status_code, - "upstream_url": preset.station_url, - }, - ) - raise HTTPException( - status_code=502, - detail=f"RadioBrowser stream unavailable: HTTP {upstream_response.status_code}", - ) - - # Detect content type - content_type = upstream_response.headers.get( - "content-type", "audio/mpeg" - ) - - logger.info( - f"[STREAMING] {preset.station_name} → Bose device (HTTP proxy active)", - extra={ - "device_id": device_id, - "preset_id": preset_id, - "content_type": content_type, - "upstream_headers": dict(upstream_response.headers), - }, - ) - - # Stream audio chunks - async for chunk in upstream_response.aiter_bytes( - chunk_size=8192 - ): - yield chunk - - except httpx.RequestError as e: - logger.error( - f"[502] Failed to fetch RadioBrowser stream: {e}", - extra={ - "device_id": device_id, - "preset_id": preset_id, - "upstream_url": preset.station_url, - "error": str(e), - }, - exc_info=True, - ) - raise HTTPException( - status_code=502, - detail=f"Failed to connect to RadioBrowser: {e}", - ) + async for chunk in upstream_response.aiter_bytes(chunk_size=8192): + yield chunk except Exception as e: logger.error( f"[STREAM ERROR] Proxy interrupted: {e}", extra={"device_id": device_id, "preset_id": preset_id}, exc_info=True, ) - raise + finally: + await upstream_response.aclose() + await http_client.aclose() # Return streaming response to Bose device return StreamingResponse( stream_generator(), - media_type="audio/mpeg", # Will be updated by generator + media_type=content_type, headers={ "icy-name": preset.station_name, "Cache-Control": "no-cache, no-store, must-revalidate", diff --git a/apps/backend/src/opencloudtouch/devices/client.py b/apps/backend/src/opencloudtouch/devices/client.py index e2c2260f..8103b405 100644 --- a/apps/backend/src/opencloudtouch/devices/client.py +++ b/apps/backend/src/opencloudtouch/devices/client.py @@ -125,6 +125,7 @@ async def store_preset( station_name: str, oct_backend_url: str, station_image_url: str = "", + station_uuid: str = "", ) -> None: """ Store a preset on the Bose device. @@ -136,6 +137,7 @@ async def store_preset( station_name: Station display name oct_backend_url: OCT backend base URL station_image_url: Optional station logo URL + station_uuid: Optional station ID (used for TuneIn dynamic resolution) """ pass diff --git a/apps/backend/src/opencloudtouch/devices/client_adapter.py b/apps/backend/src/opencloudtouch/devices/client_adapter.py index 7925b9db..7144f38e 100644 --- a/apps/backend/src/opencloudtouch/devices/client_adapter.py +++ b/apps/backend/src/opencloudtouch/devices/client_adapter.py @@ -212,6 +212,7 @@ async def store_preset( station_name: str, oct_backend_url: str, station_image_url: str = "", + station_uuid: str = "", ) -> None: """ Store a preset on the Bose device using LOCAL_INTERNET_RADIO + Orion adapter. @@ -264,6 +265,9 @@ async def store_preset( "name": station_name, "imageUrl": station_image_url, } + # TuneIn stations have empty URL - store station ID for dynamic resolution + if not station_url and station_uuid: + stream_data["tuneinId"] = station_uuid json_str = json.dumps(stream_data) base64_data = base64.urlsafe_b64encode(json_str.encode()).decode() @@ -274,14 +278,13 @@ async def store_preset( ) logger.info( - f"Storing preset {preset_number} on {self.ip}: {station_name}", + "Storing preset %d on %s", + preset_number, + self.ip, extra={ "device_ip": self.ip, "device_id": device_id, "preset_number": preset_number, - "station_name": station_name, - "orion_url": orion_url[:100] + "...", - "upstream_url": station_url, }, ) @@ -306,7 +309,8 @@ async def store_preset( response.raise_for_status() logger.info( - f"✅ Bose device programmed with LOCAL_INTERNET_RADIO + Orion: {station_name}" + "Bose device programmed with LOCAL_INTERNET_RADIO + Orion (preset %d)", + preset_number, ) except httpx.HTTPStatusError as e: diff --git a/apps/backend/src/opencloudtouch/presets/service.py b/apps/backend/src/opencloudtouch/presets/service.py index ef6e8cff..736b4f04 100644 --- a/apps/backend/src/opencloudtouch/presets/service.py +++ b/apps/backend/src/opencloudtouch/presets/service.py @@ -110,6 +110,7 @@ async def set_preset( station_name=station_name, oct_backend_url=oct_backend_url, station_image_url=station_favicon or "", + station_uuid=station_uuid, ) logger.info( f"✅ Bose device programmed: Preset {preset_number} = {station_name}" diff --git a/apps/backend/src/opencloudtouch/radio/api/routes.py b/apps/backend/src/opencloudtouch/radio/api/routes.py index fb0c9844..76ced925 100644 --- a/apps/backend/src/opencloudtouch/radio/api/routes.py +++ b/apps/backend/src/opencloudtouch/radio/api/routes.py @@ -4,6 +4,7 @@ Provides REST API for searching and retrieving radio stations. """ +import os from enum import Enum from typing import List @@ -100,6 +101,16 @@ async def search_stations( - **limit**: Maximum results (1-100, default: 10) - **provider**: Radio provider - radiobrowser or tunein (default: radiobrowser) """ + # Guard: reject tunein when extended resolver is disabled + if ( + provider == ProviderType.TUNEIN + and os.getenv("OCT_EXTENDED_RESOLVER", "true").lower() != "true" + ): + raise HTTPException( + status_code=400, + detail="Provider 'tunein' is not available", + ) + adapter = get_radio_adapter(provider.value) try: diff --git a/apps/backend/tests/integration/devices/test_preset_stream_endpoint.py b/apps/backend/tests/integration/devices/test_preset_stream_endpoint.py index d13da04b..680baccf 100644 --- a/apps/backend/tests/integration/devices/test_preset_stream_endpoint.py +++ b/apps/backend/tests/integration/devices/test_preset_stream_endpoint.py @@ -298,9 +298,6 @@ async def test_stream_preset_all_slots(real_api_client: AsyncClient): @pytest.mark.asyncio -@pytest.mark.skip( - reason="StreamingResponse cannot change HTTP status after headers sent - error handling limitation" -) async def test_stream_upstream_unavailable_returns_502(real_api_client: AsyncClient): """Test that upstream stream unavailable returns HTTP 502. diff --git a/apps/backend/tests/regression/test_device_removal_cascade.py b/apps/backend/tests/regression/test_device_removal_cascade.py new file mode 100644 index 00000000..081a2feb --- /dev/null +++ b/apps/backend/tests/regression/test_device_removal_cascade.py @@ -0,0 +1,229 @@ +""" +Regression test for device removal cascade failure. + +BUGFIX: Removing a device from the network causes ALL other devices to fail. + +Date: 2026-04-06 +Symptom: After removing one device from the network, navigating to the app + on mobile shows "blurred" skeleton tiles instead of presets for ALL + devices. Adding new devices also fails. Database wipe required to + recover. + +Root Cause: Multiple aiosqlite connections to the same database file without + WAL mode and without busy_timeout. When concurrent write + operations happen (e.g. health-check upsert + preset sync), + SQLITE_BUSY errors occur. Without rollback-on-error, failed + transactions leave dangling locks that block ALL subsequent + database operations across ALL repositories. + +Fix: Enable WAL journal mode and set busy_timeout in BaseRepository to + handle concurrent access from multiple connections gracefully. +""" + +import asyncio +import tempfile +from pathlib import Path + +import pytest + +from opencloudtouch.db import Device, DeviceRepository +from opencloudtouch.presets.models import Preset +from opencloudtouch.presets.repository import PresetRepository + + +@pytest.fixture +async def shared_db_path(): + """Provide a single DB file path shared by multiple repos.""" + with tempfile.TemporaryDirectory() as tmpdir: + yield Path(tmpdir) / "shared.db" + + +@pytest.fixture +async def device_repo(shared_db_path): + repo = DeviceRepository(str(shared_db_path)) + await repo.initialize() + yield repo + await repo.close() + + +@pytest.fixture +async def preset_repo(shared_db_path): + repo = PresetRepository(str(shared_db_path)) + await repo.initialize() + yield repo + await repo.close() + + +def _make_device(device_id: str, ip: str, name: str) -> Device: + return Device( + device_id=device_id, + ip=ip, + name=name, + model="SoundTouch 10", + mac_address=f"AA:BB:CC:DD:EE:{device_id[-2:]}", + firmware_version="28.0.3.46454", + ) + + +def _make_preset(device_id: str, number: int, station_name: str) -> Preset: + return Preset( + device_id=device_id, + preset_number=number, + station_uuid=f"uuid-{station_name.lower().replace(' ', '-')}", + station_name=station_name, + station_url=f"http://stream.example.com/{station_name.lower()}.mp3", + ) + + +class TestDeviceRemovalCascadeFailure: + """Regression: one offline device must NOT break preset loading for others.""" + + @pytest.mark.asyncio + async def test_concurrent_repo_writes_no_sqlite_busy( + self, device_repo, preset_repo + ): + """Concurrent writes from two connections must not cause SQLITE_BUSY. + + Simulates: health-check upserts device while preset sync writes presets. + Before fix (no WAL, no busy_timeout): SQLITE_BUSY → cascade failure. + After fix: both operations succeed. + """ + device = _make_device("DEV001", "192.168.1.10", "Living Room") + await device_repo.upsert(device) + + # Run concurrent writes from two different connections + errors = [] + + async def health_check_writes(): + """Simulate health-check updating last_seen for many devices.""" + for i in range(20): + try: + d = _make_device(f"HC{i:03d}", f"10.0.0.{i}", f"HC Device {i}") + await device_repo.upsert(d) + except Exception as e: + errors.append(("health_check", i, e)) + + async def preset_writes(): + """Simulate preset sync writing presets.""" + for i in range(20): + try: + p = _make_preset("DEV001", (i % 6) + 1, f"Station {i}") + await preset_repo.set_preset(p) + except Exception as e: + errors.append(("preset_sync", i, e)) + + # Run both concurrently — this triggers the SQLITE_BUSY scenario + await asyncio.gather(health_check_writes(), preset_writes()) + + assert ( + errors == [] + ), f"Concurrent writes caused {len(errors)} errors: " + "; ".join( + f"{src}[{idx}]: {err}" for src, idx, err in errors[:5] + ) + + @pytest.mark.asyncio + async def test_preset_read_during_device_writes(self, device_repo, preset_repo): + """Reading presets must work while device_repo is writing. + + This is the exact user scenario: loading presets page while + background health-check updates device last_seen timestamps. + """ + # Setup: device with presets + device = _make_device("DEV_A", "192.168.1.10", "Kitchen Speaker") + await device_repo.upsert(device) + for i in range(1, 4): + await preset_repo.set_preset(_make_preset("DEV_A", i, f"Radio {i}")) + + preset_read_results = [] + errors = [] + + async def continuous_device_upserts(): + """Simulate health-check doing rapid upserts.""" + for i in range(30): + try: + d = _make_device(f"BG{i:03d}", f"10.0.0.{i}", f"BG {i}") + await device_repo.upsert(d) + except Exception as e: + errors.append(("upsert", i, e)) + + async def read_presets_repeatedly(): + """Simulate frontend loading presets for DEV_A.""" + for _ in range(30): + try: + presets = await preset_repo.get_all_presets("DEV_A") + preset_read_results.append(len(presets)) + except Exception as e: + errors.append(("read_presets", _, e)) + + await asyncio.gather(continuous_device_upserts(), read_presets_repeatedly()) + + assert ( + errors == [] + ), f"Concurrent read+write caused {len(errors)} errors: " + "; ".join( + f"{src}[{idx}]: {err}" for src, idx, err in errors[:5] + ) + # All reads should return 3 presets consistently + assert all( + count == 3 for count in preset_read_results + ), f"Inconsistent preset reads: {set(preset_read_results)}" + + @pytest.mark.asyncio + async def test_failed_write_does_not_poison_connection( + self, device_repo, preset_repo + ): + """A failed write on one connection must not block the other. + + After a SQLITE_BUSY error (if it ever happens), subsequent + operations on both connections must still work. + """ + # Pre-populate + d1 = _make_device("GOOD_DEV", "192.168.1.50", "Good Device") + await device_repo.upsert(d1) + await preset_repo.set_preset(_make_preset("GOOD_DEV", 1, "My Radio")) + + # Force heavy concurrent writes to increase chance of contention + async def hammer_devices(): + for i in range(50): + try: + d = _make_device(f"H{i:03d}", f"10.0.0.{i}", f"H{i}") + await device_repo.upsert(d) + except Exception: + pass # Some might fail — that's fine + + async def hammer_presets(): + for i in range(50): + try: + p = _make_preset("GOOD_DEV", (i % 6) + 1, f"S{i}") + await preset_repo.set_preset(p) + except Exception: + pass + + await asyncio.gather(hammer_devices(), hammer_presets()) + + # CRITICAL: After the storm, BOTH connections MUST still work + # This is what failed before the fix — the connection was "poisoned" + devices = await device_repo.get_all() + assert len(devices) > 0, "device_repo must be usable after concurrent writes" + + presets = await preset_repo.get_all_presets("GOOD_DEV") + assert len(presets) > 0, "preset_repo must be usable after concurrent writes" + + @pytest.mark.asyncio + async def test_wal_mode_enabled(self, device_repo): + """WAL journal mode must be enabled for concurrent access support.""" + cursor = await device_repo._db.execute("PRAGMA journal_mode") + row = await cursor.fetchone() + assert row[0].lower() == "wal", ( + f"Expected WAL journal mode, got '{row[0]}'. " + "Without WAL, concurrent connections will cause SQLITE_BUSY errors." + ) + + @pytest.mark.asyncio + async def test_busy_timeout_set(self, device_repo): + """busy_timeout must be set to avoid immediate SQLITE_BUSY failures.""" + cursor = await device_repo._db.execute("PRAGMA busy_timeout") + row = await cursor.fetchone() + assert row[0] >= 3000, ( + f"Expected busy_timeout >= 3000ms, got {row[0]}ms. " + "Without busy_timeout, concurrent access fails immediately." + ) diff --git a/apps/backend/tests/regression/test_offline_device_blocks_server.py b/apps/backend/tests/regression/test_offline_device_blocks_server.py new file mode 100644 index 00000000..cb4fe2bd --- /dev/null +++ b/apps/backend/tests/regression/test_offline_device_blocks_server.py @@ -0,0 +1,243 @@ +""" +Regression test: Offline device blocks entire server via sync BoseClient calls. + +Date: 2026-04-06 +Symptom: Removing one device from the network causes ALL other devices to show + blurred skeleton tiles instead of presets. Database wipe required. + +Root Cause: BoseDeviceClientAdapter calls synchronous bosesoundtouchapi methods + (GetNowPlayingStatus, GetVolume, etc.) directly in async methods, + blocking the asyncio event loop for the full connectTimeout (5s). + During that time, the server cannot serve ANY other request. + +Fix: All synchronous BoseClient calls wrapped in asyncio.to_thread(). +""" + +import asyncio +import inspect +import re +import tempfile +import time +from pathlib import Path +from unittest.mock import MagicMock + +import pytest + +from opencloudtouch.db import Device, DeviceRepository +from opencloudtouch.presets.models import Preset +from opencloudtouch.presets.repository import PresetRepository +from opencloudtouch.presets.service import PresetService + + +@pytest.fixture +async def shared_db(): + with tempfile.TemporaryDirectory() as tmpdir: + db_path = Path(tmpdir) / "test.db" + device_repo = DeviceRepository(str(db_path)) + await device_repo.initialize() + preset_repo = PresetRepository(str(db_path)) + await preset_repo.initialize() + yield device_repo, preset_repo + await device_repo.close() + await preset_repo.close() + + +def _make_device(device_id: str, ip: str, name: str) -> Device: + return Device( + device_id=device_id, + ip=ip, + name=name, + model="SoundTouch 10", + mac_address=f"AA:BB:CC:DD:EE:{device_id[-2:]}", + firmware_version="28.0.3", + ) + + +class TestOfflineDeviceBlocksEventLoop: + """Regression: synchronous BoseClient calls must not block the event loop.""" + + @pytest.mark.asyncio + async def test_sync_bose_call_does_not_block_event_loop(self, shared_db): + """A slow device must NOT prevent fast DB reads for other devices. + + Uses the REAL BoseDeviceClientAdapter (with mocked BoseClient) to verify + that asyncio.to_thread() is correctly applied. + """ + device_repo, preset_repo = shared_db + + await device_repo.upsert(_make_device("OFFLINE01", "192.168.1.99", "Offline")) + await device_repo.upsert(_make_device("WORKING01", "192.168.1.10", "Working")) + for i in range(1, 4): + await preset_repo.set_preset( + Preset( + device_id="WORKING01", + preset_number=i, + station_uuid=f"uuid-{i}", + station_name=f"Radio {i}", + station_url=f"http://stream{i}.example.com/live.mp3", + ) + ) + + BLOCK_SECONDS = 2.0 + preset_service = PresetService(preset_repo, device_repo) + + def slow_get_now_playing(): + """Simulates BoseClient.GetNowPlayingStatus() on an unreachable + device — blocks the calling thread for connectTimeout seconds.""" + time.sleep(BLOCK_SECONDS) + raise ConnectionError("Device offline") + + mock_bose_client = MagicMock() + mock_bose_client.GetNowPlayingStatus = slow_get_now_playing + + preset_timing = [] + np_error = [] + + async def poll_offline_now_playing(): + """Call get_now_playing via the REAL adapter with mocked BoseClient.""" + try: + from opencloudtouch.devices.client_adapter import ( + BoseDeviceClientAdapter, + ) + + adapter = BoseDeviceClientAdapter.__new__(BoseDeviceClientAdapter) + adapter.base_url = "http://192.168.1.99:8090" + adapter.ip = "192.168.1.99" + adapter.timeout = 5.0 + adapter._client = mock_bose_client + + await adapter.get_now_playing() + except Exception as e: + np_error.append(str(e)) + + async def read_working_presets(): + start = time.monotonic() + presets = await preset_service.get_all_presets("WORKING01") + elapsed = time.monotonic() - start + preset_timing.append(elapsed) + return presets + + # Blocking call FIRST — it gets scheduled first in the event loop. + # Before the fix, this would block the entire event loop for BLOCK_SECONDS, + # delaying all other coroutines. After the fix (asyncio.to_thread()), + # it runs in a thread pool and preset reads proceed immediately. + await asyncio.gather(poll_offline_now_playing(), read_working_presets()) + + assert preset_timing[0] < 0.5, ( + f"Preset read took {preset_timing[0]:.2f}s — expected < 0.5s. " + f"The offline device's synchronous I/O is blocking the event loop. " + f"BoseClient calls must be wrapped in asyncio.to_thread()." + ) + assert len(np_error) == 1 + + @pytest.mark.asyncio + async def test_all_bose_methods_use_to_thread(self): + """Static analysis: every sync BoseClient call must use asyncio.to_thread. + + Reads the source of BoseDeviceClientAdapter and verifies that every + self._client.Method(...) call is wrapped in asyncio.to_thread(). + Catches regressions where someone adds a new BoseClient call without + the thread wrapper. + """ + from opencloudtouch.devices.client_adapter import BoseDeviceClientAdapter + + source = inspect.getsource(BoseDeviceClientAdapter) + + # Find all self._client.MethodName references that are method calls + # (either direct calls `self._client.Foo(...)` or passed to to_thread) + # Exclude property accesses like `self._client.Device.DeviceId` + all_method_refs = set( + re.findall(r"self\._client\.([A-Z]\w+?)(?:\(|,|\)|\s)", source) + ) + + # Property accesses (no parentheses, used as attribute lookups) + property_accesses = set(re.findall(r"self\._client\.([A-Z]\w+)\.\w+", source)) + + # Methods that are actually called (not just property access) + called_methods = all_method_refs - property_accesses + + assert called_methods, "Expected at least one self._client.Method() call" + + # Every called method must appear in an asyncio.to_thread() wrapper + for method in called_methods: + pattern = rf"asyncio\.to_thread\(self\._client\.{method}" + assert re.search(pattern, source), ( + f"self._client.{method}() is NOT wrapped in asyncio.to_thread(). " + f"This will block the event loop when the device is offline." + ) + + # Negative check: no direct calls without to_thread + # Match self._client.Method( that is NOT preceded by asyncio.to_thread( + for method in called_methods: + direct_call = rf"(? str: + """Helper to encode TuneIn station data as base64 (empty streamUrl + tuneinId).""" + data = { + "streamUrl": "", + "name": name, + "imageUrl": image_url, + "tuneinId": tunein_id, + } + return base64.urlsafe_b64encode(json.dumps(data).encode()).decode() + + +class TestOrionTuneInResolution: + """Tests for dynamic TuneIn stream resolution via Orion adapter.""" + + def test_tunein_station_resolves_dynamically(self, client): + """TuneIn preset with empty streamUrl + tuneinId resolves at playback time.""" + data = encode_tunein_data("s158432", "Absolut Relax") + mock_response = BmxPlaybackResponse( + audio=BmxAudio( + streamUrl="http://stream.absolut.at/relax.mp3", + streams=[BmxStream(streamUrl="http://stream.absolut.at/relax.mp3")], + ), + imageUrl="https://cdn.tunein.com/s158432q.png", + name="Absolut Relax", + ) + + with patch( + "opencloudtouch.bmx.routes.resolve_tunein_station", + new_callable=AsyncMock, + return_value=mock_response, + ) as mock_resolve: + response = client.get( + f"/core02/svc-bmx-adapter-orion/prod/orion/station?data={data}" + ) + + assert response.status_code == 200 + body = response.json() + assert body["audio"]["streamUrl"] == "http://stream.absolut.at/relax.mp3" + assert body["name"] == "Absolut Relax" + mock_resolve.assert_called_once_with("s158432") + + def test_tunein_resolution_failure_returns_500(self, client): + """TuneIn resolution failure returns 500 with error details.""" + data = encode_tunein_data("s999999", "Unknown Station") + + with patch( + "opencloudtouch.bmx.routes.resolve_tunein_station", + new_callable=AsyncMock, + side_effect=ValueError("No stream URLs found for station s999999"), + ): + response = client.get( + f"/core02/svc-bmx-adapter-orion/prod/orion/station?data={data}" + ) + + assert response.status_code == 500 + body = response.json() + assert "error" in body + assert "TuneIn resolution failed" in body["error"] + + def test_regular_stream_still_works_with_tunein_id_absent(self, client): + """Regular stream (no tuneinId) continues to work as before.""" + stream_url = "http://stream.example.com/radio.mp3" + data = encode_stream_data(stream_url, "Normal Radio") + + response = client.get( + f"/core02/svc-bmx-adapter-orion/prod/orion/station?data={data}" + ) + + assert response.status_code == 200 + body = response.json() + assert body["audio"]["streamUrl"] == stream_url + + def test_tunein_id_with_streamurl_uses_streamurl(self, client): + """When both tuneinId and streamUrl are present, use streamUrl directly.""" + data_dict = { + "streamUrl": "http://direct.example.com/stream.mp3", + "name": "Station", + "imageUrl": "", + "tuneinId": "s158432", + } + data = base64.urlsafe_b64encode(json.dumps(data_dict).encode()).decode() + + response = client.get( + f"/core02/svc-bmx-adapter-orion/prod/orion/station?data={data}" + ) + + assert response.status_code == 200 + body = response.json() + assert body["audio"]["streamUrl"] == "http://direct.example.com/stream.mp3" diff --git a/apps/backend/tests/unit/bmx/test_stream_utils.py b/apps/backend/tests/unit/bmx/test_stream_utils.py new file mode 100644 index 00000000..d693d47c --- /dev/null +++ b/apps/backend/tests/unit/bmx/test_stream_utils.py @@ -0,0 +1,27 @@ +"""Unit tests for BMX stream URL utilities.""" + +from opencloudtouch.bmx.stream_utils import convert_https_to_http + + +class TestConvertHttpsToHttp: + """Tests for HTTPS → HTTP conversion (Bose device compatibility).""" + + def test_converts_https_to_http(self): + assert ( + convert_https_to_http("https://stream.example.com/radio.mp3") + == "http://stream.example.com/radio.mp3" + ) + + def test_leaves_http_unchanged(self): + assert ( + convert_https_to_http("http://stream.example.com/radio.mp3") + == "http://stream.example.com/radio.mp3" + ) + + def test_empty_string(self): + assert convert_https_to_http("") == "" + + def test_preserves_path_and_query(self): + url = "https://cdn.example.com/live/stream.mp3?token=abc123&format=mp3" + expected = "http://cdn.example.com/live/stream.mp3?token=abc123&format=mp3" + assert convert_https_to_http(url) == expected diff --git a/apps/backend/tests/unit/bmx/test_tunein.py b/apps/backend/tests/unit/bmx/test_tunein.py index 0e216594..7b5e77f2 100644 --- a/apps/backend/tests/unit/bmx/test_tunein.py +++ b/apps/backend/tests/unit/bmx/test_tunein.py @@ -72,6 +72,48 @@ async def test_resolve_station_success(self): ) assert result.streamType == "liveRadio" + @pytest.mark.asyncio + async def test_resolve_station_converts_https_to_http(self): + """Test that HTTPS stream URLs are converted to HTTP for Bose compatibility.""" + station_id = "s158432" + describe_xml = """ + + + + + HTTPS Station + + + +""" + # TuneIn returns HTTPS URLs + stream_urls = "https://secure.stream.example.com/live.mp3\nhttps://backup.stream.example.com/live.aac" + + mock_response_describe = MagicMock() + mock_response_describe.text = describe_xml + mock_response_stream = MagicMock() + mock_response_stream.text = stream_urls + + with patch("opencloudtouch.bmx.tunein.httpx.AsyncClient") as mock_client: + mock_context = AsyncMock() + mock_context.__aenter__.return_value.get = AsyncMock( + side_effect=[mock_response_describe, mock_response_stream] + ) + mock_client.return_value = mock_context + + result = await resolve_tunein_station(station_id) + + # HTTPS should be converted to HTTP + assert result.audio.streamUrl == "http://secure.stream.example.com/live.mp3" + assert ( + result.audio.streams[0].streamUrl + == "http://secure.stream.example.com/live.mp3" + ) + assert ( + result.audio.streams[1].streamUrl + == "http://backup.stream.example.com/live.aac" + ) + @pytest.mark.asyncio async def test_resolve_station_minimal_xml(self): """Test resolution with minimal XML (missing logo).""" diff --git a/apps/backend/tests/unit/devices/api/test_preset_stream_routes.py b/apps/backend/tests/unit/devices/api/test_preset_stream_routes.py index b5062db1..ff9cb697 100644 --- a/apps/backend/tests/unit/devices/api/test_preset_stream_routes.py +++ b/apps/backend/tests/unit/devices/api/test_preset_stream_routes.py @@ -61,8 +61,7 @@ def test_stream_returns_streaming_response( mock_response = MagicMock() mock_response.status_code = 200 mock_response.headers = {"content-type": "audio/mpeg"} - mock_response.__aenter__ = AsyncMock(return_value=mock_response) - mock_response.__aexit__ = AsyncMock(return_value=False) + mock_response.aclose = AsyncMock() async def mock_aiter_bytes(chunk_size=8192): yield b"audio_data_chunk_1" @@ -70,12 +69,14 @@ async def mock_aiter_bytes(chunk_size=8192): mock_response.aiter_bytes = mock_aiter_bytes - mock_client_ctx = MagicMock() - mock_client_ctx.__aenter__ = AsyncMock(return_value=mock_client_ctx) - mock_client_ctx.__aexit__ = AsyncMock(return_value=False) - mock_client_ctx.stream = MagicMock(return_value=mock_response) + mock_http_client = MagicMock() + mock_http_client.send = AsyncMock(return_value=mock_response) + mock_http_client.aclose = AsyncMock() - with patch("httpx.AsyncClient", return_value=mock_client_ctx): + with patch( + "opencloudtouch.devices.api.preset_stream_routes.httpx.AsyncClient", + return_value=mock_http_client, + ): with client.stream("GET", "/device/689E194F7D2F/preset/1") as response: assert response.status_code == 200 assert "audio" in response.headers.get("content-type", "") @@ -89,25 +90,18 @@ def test_upstream_non_200_raises_502( mock_response = MagicMock() mock_response.status_code = 503 mock_response.headers = {} - mock_response.__aenter__ = AsyncMock(return_value=mock_response) - mock_response.__aexit__ = AsyncMock(return_value=False) + mock_response.aclose = AsyncMock() - async def mock_aiter_bytes(chunk_size=8192): - return - yield # make it async generator - - mock_response.aiter_bytes = mock_aiter_bytes + mock_http_client = MagicMock() + mock_http_client.send = AsyncMock(return_value=mock_response) + mock_http_client.aclose = AsyncMock() - mock_client_ctx = MagicMock() - mock_client_ctx.__aenter__ = AsyncMock(return_value=mock_client_ctx) - mock_client_ctx.__aexit__ = AsyncMock(return_value=False) - mock_client_ctx.stream = MagicMock(return_value=mock_response) - - with patch("httpx.AsyncClient", return_value=mock_client_ctx): - # The 502 is raised inside the generator; TestClient will propagate it - with pytest.raises(Exception): - with client.stream("GET", "/device/689E194F7D2F/preset/1") as resp: - resp.read() + with patch( + "opencloudtouch.devices.api.preset_stream_routes.httpx.AsyncClient", + return_value=mock_http_client, + ): + response = client.get("/device/689E194F7D2F/preset/1") + assert response.status_code == 502 def test_httpx_request_error_raises_502( self, client, mock_preset_service, sample_preset @@ -117,22 +111,18 @@ def test_httpx_request_error_raises_502( mock_preset_service.get_preset = AsyncMock(return_value=sample_preset) - mock_client_ctx = MagicMock() - mock_client_ctx.__aenter__ = AsyncMock(return_value=mock_client_ctx) - mock_client_ctx.__aexit__ = AsyncMock(return_value=False) - - # stream() returns context manager that raises on __aenter__ - mock_stream_ctx = MagicMock() - mock_stream_ctx.__aenter__ = AsyncMock( + mock_http_client = MagicMock() + mock_http_client.send = AsyncMock( side_effect=httpx.ConnectError("Connection refused") ) - mock_stream_ctx.__aexit__ = AsyncMock(return_value=False) - mock_client_ctx.stream = MagicMock(return_value=mock_stream_ctx) - - with patch("httpx.AsyncClient", return_value=mock_client_ctx): - with pytest.raises(Exception): - with client.stream("GET", "/device/689E194F7D2F/preset/1") as resp: - resp.read() + mock_http_client.aclose = AsyncMock() + + with patch( + "opencloudtouch.devices.api.preset_stream_routes.httpx.AsyncClient", + return_value=mock_http_client, + ): + response = client.get("/device/689E194F7D2F/preset/1") + assert response.status_code == 502 class TestGetPresetDescriptor: diff --git a/apps/backend/tests/unit/radio/api/test_radio_routes.py b/apps/backend/tests/unit/radio/api/test_radio_routes.py index 1f8cc9a3..21fbfc9e 100644 --- a/apps/backend/tests/unit/radio/api/test_radio_routes.py +++ b/apps/backend/tests/unit/radio/api/test_radio_routes.py @@ -618,3 +618,59 @@ def test_station_detail_unexpected_exception_returns_500( response = client.get("/api/radio/station/test-uuid") assert response.status_code == 500 assert "Unexpected" in response.json()["detail"] + + +class TestExtendedResolverGuard: + """Tests for OCT_EXTENDED_RESOLVER env var guard on tunein provider.""" + + def test_tunein_rejected_when_flag_disabled(self, client, mock_adapter): + """provider=tunein returns 400 when OCT_EXTENDED_RESOLVER=false.""" + with patch.dict("os.environ", {"OCT_EXTENDED_RESOLVER": "false"}): + response = client.get( + "/api/radio/search", + params={"q": "test", "provider": "tunein"}, + ) + assert response.status_code == 400 + assert "not available" in response.json()["detail"] + mock_adapter.search_by_name.assert_not_called() + + def test_radiobrowser_works_when_flag_disabled( + self, client, mock_adapter, mock_radio_stations + ): + """provider=radiobrowser works normally when OCT_EXTENDED_RESOLVER=false.""" + mock_adapter.search_by_name.return_value = mock_radio_stations + with patch.dict("os.environ", {"OCT_EXTENDED_RESOLVER": "false"}): + response = client.get( + "/api/radio/search", + params={"q": "test", "provider": "radiobrowser"}, + ) + assert response.status_code == 200 + assert len(response.json()["stations"]) == 2 + + def test_tunein_works_when_flag_enabled( + self, client, mock_adapter, mock_radio_stations + ): + """provider=tunein works normally when OCT_EXTENDED_RESOLVER=true (default).""" + mock_adapter.search_by_name.return_value = mock_radio_stations + with patch.dict("os.environ", {"OCT_EXTENDED_RESOLVER": "true"}): + response = client.get( + "/api/radio/search", + params={"q": "test", "provider": "tunein"}, + ) + assert response.status_code == 200 + + def test_tunein_works_when_flag_not_set( + self, client, mock_adapter, mock_radio_stations + ): + """provider=tunein works when OCT_EXTENDED_RESOLVER is not set (defaults to true).""" + mock_adapter.search_by_name.return_value = mock_radio_stations + with patch.dict("os.environ", {}, clear=False): + # Ensure env var is not set + import os + + os.environ.pop("OCT_EXTENDED_RESOLVER", None) + response = client.get( + "/api/radio/search", + params={"q": "test", "provider": "tunein"}, + ) + assert response.status_code == 200 diff --git a/apps/frontend/cypress.config.ts b/apps/frontend/cypress.config.ts index e9138b91..be3c0435 100644 --- a/apps/frontend/cypress.config.ts +++ b/apps/frontend/cypress.config.ts @@ -1,4 +1,6 @@ import { defineConfig } from 'cypress' +import webpackPreprocessor from '@cypress/webpack-preprocessor' +import path from 'path' export default defineConfig({ allowCypressEnv: false, @@ -19,6 +21,32 @@ export default defineConfig({ video: false, // Disable video recording (speeds up tests) setupNodeEvents(on, config) { + // Use custom webpack preprocessor with transpileOnly to avoid TS5101: + // Cypress 15's built-in webpack sets downlevelIteration:true which is + // deprecated in TypeScript 6.0 and causes a compilation error. + // transpileOnly skips type-checking during test bundling (tsc --noEmit + // in the lint step still provides full type safety). + on( + 'file:preprocessor', + webpackPreprocessor({ + webpackOptions: { + resolve: { extensions: ['.ts', '.tsx', '.js', '.jsx'] }, + module: { + rules: [ + { + test: /\.tsx?$/, + loader: 'ts-loader', + options: { + transpileOnly: true, + configFile: path.resolve(__dirname, 'tests/tsconfig.json'), + }, + }, + ], + }, + }, + }), + ); + // a11y violation report store (in-process, resets per test run) let a11yViolations: unknown[] = []; diff --git a/apps/frontend/package.json b/apps/frontend/package.json index f662372d..143b8850 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -31,6 +31,7 @@ "@tanstack/react-query": "^5.95.2", "flag-icons": "^7.5.0", "framer-motion": "^12.38.0", + "html2canvas": "^1.4.1", "i18next": "^26.0.8", "react": "^19.2.4", "react-dom": "^19.2.4", @@ -38,6 +39,7 @@ "react-router-dom": "^7.13.0" }, "devDependencies": { + "@cypress/webpack-preprocessor": "^7.1.0", "@eslint-react/eslint-plugin": "^4.2.3", "@eslint/css": "^1.0.0", "@eslint/js": "^10.0.1", @@ -46,6 +48,7 @@ "@testing-library/jest-dom": "^6.1.5", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", + "@types/html2canvas": "^0.5.35", "@types/react": "^19.2.10", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.1", @@ -62,9 +65,11 @@ "prettier": "^3.8.1", "rimraf": "^6.0.1", "start-server-and-test": "^3.0.2", + "ts-loader": "^9.5.7", "typescript": "^6.0.3", "typescript-eslint": "^8.57.1", "vite": "^8.0.5", - "vitest": "^4.1.5" + "vitest": "^4.1.5", + "webpack": "^5.106.2" } } diff --git a/apps/frontend/src/__mocks__/html2canvas.ts b/apps/frontend/src/__mocks__/html2canvas.ts new file mode 100644 index 00000000..9106e1fe --- /dev/null +++ b/apps/frontend/src/__mocks__/html2canvas.ts @@ -0,0 +1,11 @@ +/** + * Mock for html2canvas — used in test environment only. + */ +export default function html2canvas( + _element: HTMLElement, + _options?: Record +): Promise { + const canvas = document.createElement("canvas"); + canvas.toDataURL = () => "data:image/png;base64,mock"; + return Promise.resolve(canvas); +} diff --git a/apps/frontend/src/api/bugReport.ts b/apps/frontend/src/api/bugReport.ts new file mode 100644 index 00000000..a6584e43 --- /dev/null +++ b/apps/frontend/src/api/bugReport.ts @@ -0,0 +1,43 @@ +/** + * Bug Report API Client + */ + +const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || ""; + +export interface BugReportPayload { + description: string; + steps_to_reproduce: string; + expected_behavior: string; + installation_type: string; + hardware: string; + soundtouch_devices: string[]; + network_config: string; + additional_info: string; + other_installation: string; + other_hardware: string; + other_device: string; + screenshot_data_url: string; + frontend_logs: Array<{ timestamp: string; level: string; message: string }>; + browser_info: string; + current_route: string; + click_timestamp: number; +} + +export interface BugReportResponse { + issue_url: string; +} + +export async function submitBugReport(payload: BugReportPayload): Promise { + const response = await fetch(`${API_BASE_URL}/api/bug-report`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Bug report failed (${response.status}): ${text}`); + } + + return response.json(); +} diff --git a/apps/frontend/src/components/BugReportModal.css b/apps/frontend/src/components/BugReportModal.css new file mode 100644 index 00000000..fb531d1f --- /dev/null +++ b/apps/frontend/src/components/BugReportModal.css @@ -0,0 +1,205 @@ +/* BugReportModal — One-click bug reporting */ +.bug-modal-overlay { + position: fixed; + inset: 0; + z-index: 9999; + display: flex; + align-items: flex-start; + justify-content: center; + padding-top: 5vh; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(2px); + overflow-y: auto; +} + +.bug-modal { + background: var(--color-bg-card, #242424); + border: 1px solid var(--color-border, #333); + border-radius: 12px; + width: calc(100% - 32px); + max-width: 520px; + max-height: calc(90vh - 5vh); + overflow-y: auto; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); + animation: bugModalFadeIn 0.15s ease-out; +} + +@keyframes bugModalFadeIn { + from { + opacity: 0; + transform: translateY(-8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.bug-modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 20px 24px 12px; + position: sticky; + top: 0; + background: var(--color-bg-card, #242424); + z-index: 1; +} + +.bug-modal-header h2 { + margin: 0; + font-size: 1.2rem; + font-weight: 600; + color: var(--color-text-primary, #fff); +} + +.bug-modal-close { + background: none; + border: none; + color: var(--color-text-secondary, #b3b3b3); + font-size: 1.2rem; + cursor: pointer; + padding: 4px 8px; + border-radius: 6px; + min-width: 44px; + min-height: 44px; + display: flex; + align-items: center; + justify-content: center; +} + +.bug-modal-close:hover { + color: var(--color-text-primary, #fff); + background: rgba(255, 255, 255, 0.08); +} + +.bug-modal-form { + padding: 8px 24px 24px; + display: flex; + flex-direction: column; + gap: 16px; +} + +.bug-field { + display: flex; + flex-direction: column; + gap: 6px; + border: none; + padding: 0; + margin: 0; +} + +.bug-field--other { + margin-top: -8px; + padding-left: 16px; + border-left: 2px solid var(--color-accent, #0066cc); +} + +.bug-label { + font-size: 0.85rem; + font-weight: 500; + color: var(--color-text-secondary, #b3b3b3); +} + +.bug-required { + color: var(--color-danger, #ef5350); +} + +.bug-modal-form textarea, +.bug-modal-form input[type="text"], +.bug-modal-form select { + width: 100%; + padding: 10px 12px; + background: var(--color-bg-dark, #1a1a1a); + border: 1px solid var(--color-border, #333); + border-radius: 8px; + color: var(--color-text-primary, #fff); + font-size: 0.9rem; + font-family: inherit; + resize: vertical; + box-sizing: border-box; +} + +.bug-modal-form textarea:focus, +.bug-modal-form input[type="text"]:focus, +.bug-modal-form select:focus { + outline: 2px solid var(--color-accent, #0066cc); + outline-offset: -1px; + border-color: transparent; +} + +.bug-modal-form select { + cursor: pointer; + appearance: auto; +} + +.bug-checkbox-group { + display: flex; + flex-wrap: wrap; + gap: 8px 16px; +} + +.bug-checkbox { + display: flex; + align-items: center; + gap: 6px; + font-size: 0.85rem; + color: var(--color-text-primary, #fff); + cursor: pointer; + min-height: 32px; +} + +.bug-checkbox input[type="checkbox"] { + width: 18px; + height: 18px; + accent-color: var(--color-accent, #0066cc); +} + +.bug-modal-actions { + display: flex; + gap: 12px; + justify-content: flex-end; + padding-top: 8px; +} + +.bug-btn { + min-width: 100px; + min-height: 44px; + padding: 10px 20px; + border: none; + border-radius: 8px; + font-size: 0.9rem; + font-weight: 500; + cursor: pointer; + transition: + background 0.15s, + opacity 0.15s; +} + +.bug-btn:focus-visible { + outline: 2px solid var(--color-accent, #0066cc); + outline-offset: 2px; +} + +.bug-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.bug-btn--cancel { + background: rgba(255, 255, 255, 0.08); + color: var(--color-text-primary, #fff); +} + +.bug-btn--cancel:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.12); +} + +.bug-btn--submit { + background: var(--color-accent, #0066cc); + color: #fff; +} + +.bug-btn--submit:hover:not(:disabled) { + background: var(--color-accent-hover, #0052a3); +} diff --git a/apps/frontend/src/components/BugReportModal.tsx b/apps/frontend/src/components/BugReportModal.tsx new file mode 100644 index 00000000..586a8c9d --- /dev/null +++ b/apps/frontend/src/components/BugReportModal.tsx @@ -0,0 +1,372 @@ +import { useState, useEffect, useRef, useCallback } from "react"; +import { useLocation } from "react-router-dom"; +import { submitBugReport } from "../api/bugReport"; +import { getLogEntries } from "../utils/logBuffer"; +import { useToast } from "../contexts/ToastContext"; +import "./BugReportModal.css"; + +interface BugReportModalProps { + readonly open: boolean; + readonly onClose: () => void; +} + +const INSTALLATION_OPTIONS = [ + { value: "docker", label: "Docker (Raspberry Pi)" }, + { value: "docker-other", label: "Docker (other hardware)" }, + { value: "source", label: "From Source" }, + { value: "other", label: "Other" }, +]; + +const HARDWARE_OPTIONS = [ + { value: "raspberry-pi-4", label: "Raspberry Pi 4" }, + { value: "raspberry-pi-5", label: "Raspberry Pi 5" }, + { value: "linux-x64", label: "Linux x86_64" }, + { value: "other", label: "Other" }, +]; + +const DEVICE_OPTIONS = [ + "SoundTouch 10", + "SoundTouch 20", + "SoundTouch 30", + "SoundTouch 300", + "SoundTouch Portable", + "Lifestyle 535", + "Lifestyle 600", + "Lifestyle 650", + "Wave SoundTouch", + "Other", +]; + +const NETWORK_OPTIONS = [ + { value: "wifi", label: "Wi-Fi" }, + { value: "lan", label: "LAN / Ethernet" }, + { value: "mixed", label: "Mixed" }, +]; + +export default function BugReportModal({ open, onClose }: BugReportModalProps) { + const { show: showToast } = useToast(); + const location = useLocation(); + const closeRef = useRef(null); + const clickTimestampRef = useRef(0); + + const [submitting, setSubmitting] = useState(false); + const [description, setDescription] = useState(""); + const [steps, setSteps] = useState(""); + const [expected, setExpected] = useState(""); + const [installationType, setInstallationType] = useState(""); + const [hardware, setHardware] = useState(""); + const [selectedDevices, setSelectedDevices] = useState([]); + const [networkConfig, setNetworkConfig] = useState(""); + const [additionalInfo, setAdditionalInfo] = useState(""); + const [otherInstallation, setOtherInstallation] = useState(""); + const [otherHardware, setOtherHardware] = useState(""); + const [otherDevice, setOtherDevice] = useState(""); + + useEffect(() => { + if (open) { + clickTimestampRef.current = Date.now() / 1000; + closeRef.current?.focus(); + } + }, [open]); + + useEffect(() => { + if (!open) return; + const handleKey = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + document.addEventListener("keydown", handleKey); + return () => document.removeEventListener("keydown", handleKey); + }, [open, onClose]); + + const resetForm = useCallback(() => { + setDescription(""); + setSteps(""); + setExpected(""); + setInstallationType(""); + setHardware(""); + setSelectedDevices([]); + setNetworkConfig(""); + setAdditionalInfo(""); + setOtherInstallation(""); + setOtherHardware(""); + setOtherDevice(""); + }, []); + + const captureScreenshot = async (): Promise => { + try { + const { default: html2canvas } = await import("html2canvas"); + const canvas = await html2canvas(document.body, { + scale: 0.5, + logging: false, + useCORS: true, + width: Math.min(document.body.scrollWidth, 1280), + }); + return canvas.toDataURL("image/jpeg", 0.6); + } catch { + return ""; + } + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setSubmitting(true); + + try { + const screenshot = await captureScreenshot(); + const browserInfo = `${navigator.userAgent} | ${window.innerWidth}x${window.innerHeight}`; + + const result = await submitBugReport({ + description, + steps_to_reproduce: steps, + expected_behavior: expected, + installation_type: installationType, + hardware, + soundtouch_devices: selectedDevices, + network_config: networkConfig, + additional_info: additionalInfo, + other_installation: otherInstallation, + other_hardware: otherHardware, + other_device: otherDevice, + screenshot_data_url: screenshot, + frontend_logs: getLogEntries(), + browser_info: browserInfo, + current_route: location.pathname, + click_timestamp: clickTimestampRef.current, + }); + + showToast(`Bug report created! ${result.issue_url}`, "success", 10000); + resetForm(); + onClose(); + } catch (err) { + const msg = err instanceof Error ? err.message : "Unknown error"; + showToast(`Failed to submit bug report: ${msg}`, "error", 8000); + } finally { + setSubmitting(false); + } + }; + + const toggleDevice = (device: string) => { + setSelectedDevices((prev) => + prev.includes(device) ? prev.filter((d) => d !== device) : [...prev, device] + ); + }; + + const needsOtherInstallation = installationType === "other"; + const needsOtherHardware = hardware === "other"; + const needsOtherDevice = selectedDevices.includes("Other"); + + const isValid = + description.length >= 10 && + steps.length >= 10 && + expected.length >= 5 && + installationType !== "" && + hardware !== "" && + (!needsOtherInstallation || otherInstallation.length >= 2) && + (!needsOtherHardware || otherHardware.length >= 2) && + (!needsOtherDevice || otherDevice.length >= 2); + + if (!open) return null; + + return ( +
+ +
+

🐛 Report a Bug

+ +
+ +
+ {/* 1. Bug Description */} +