diff --git a/backend/app/clients/base.py b/backend/app/clients/base.py index beb0eea..da99095 100644 --- a/backend/app/clients/base.py +++ b/backend/app/clients/base.py @@ -241,6 +241,8 @@ def _operation_messages(service: str, method: str, path: str) -> tuple[str, str] if service in {"Radarr", "Sonarr"} and "/release" in normalized_path: return f"Checking releases through {service}…", f"{service} returned release information" if service in {"Radarr", "Sonarr"} and "/command" in normalized_path: + if normalized_method == "GET": + return f"Checking {service}'s search activity…", f"{service} returned its current activity" return f"Sending a command to {service}…", f"{service} accepted the command" if service == "Bazarr" and "/subtitles" in normalized_path and normalized_method == "PATCH": return "Asking Bazarr for fresh subtitles…", "Bazarr started the subtitle search" diff --git a/backend/app/services/collector_search.py b/backend/app/services/collector_search.py new file mode 100644 index 0000000..87fbe79 --- /dev/null +++ b/backend/app/services/collector_search.py @@ -0,0 +1,61 @@ +"""Read title-specific search activity without starting a search or changing monitoring.""" + +from typing import Any + +from ..clients.base import ApiClient +from ..models import RequestType + + +def _ids(values: Any) -> set[int]: + if not isinstance(values, list): + return set() + return {value for value in values if type(value) is int and value > 0} + + +def search_status(commands: Any, request_type: RequestType, item_id: int, episodes: Any = None) -> str: + """Only a matching queued/started search is evidence of current activity. + + Completed commands, RSS syncs and library-wide jobs do not establish that this + title is being searched. Episode searches are matched using Sonarr episode IDs. + """ + if not isinstance(commands, list): + return "unavailable" + episode_ids = _ids([ + episode.get("id") for episode in (episodes if isinstance(episodes, list) else []) + if isinstance(episode, dict) and episode.get("seriesId", item_id) == item_id + ]) + queued = False + for command in commands: + if not isinstance(command, dict): + continue + body = command.get("body") + if not isinstance(body, dict): + continue + name = str(command.get("name") or body.get("name") or "").lower() + if request_type == RequestType.movie: + matches = name == "moviessearch" and item_id in _ids(body.get("movieIds")) + else: + matches = ( + name in {"seriessearch", "seasonsearch"} and body.get("seriesId") == item_id + ) or ( + name == "episodesearch" and bool(episode_ids & _ids(body.get("episodeIds"))) + ) + if not matches or command.get("ended"): + continue + status = str(command.get("status", "")).lower() + if status in {"started", "1"}: + return "searching" + if status in {"queued", "0"}: + queued = True + return "queued" if queued else "idle" + + +async def read_search_status( + client: ApiClient, request_type: RequestType, item_id: int, episodes: Any = None, +) -> str: + try: + commands = await client.get("/api/v3/command", timeout_seconds=3.0) + except Exception: + # Search telemetry must not turn a healthy library record into an error. + return "unavailable" + return search_status(commands, request_type, item_id, episodes) diff --git a/backend/app/services/snapshot.py b/backend/app/services/snapshot.py index 6ca02de..ccf5a86 100644 --- a/backend/app/services/snapshot.py +++ b/backend/app/services/snapshot.py @@ -27,6 +27,7 @@ from ..db import ( clear_seerr_media_failure, ) from ..models import ActionOption, NormalizedState, RequestType, Snapshot, TimelineHop +from .collector_search import read_search_status logger = logging.getLogger(__name__) @@ -666,6 +667,19 @@ def _build_presentation( fully_available = bool(jellyfin_found and not jellyfin_partial) download_visible = bool(download.get("visible")) download_state = str(download.get("state") or "not_started") + search_status = str((arr_details.get("search") or {}).get("state") or "unavailable") + search_in_progress = search_status in {"searching", "queued"} + search_label = { + "searching": "Searching", + "queued": "Search queued", + "idle": "Not searching", + }.get(search_status, "Search unknown") + search_target = "episode releases" if snapshot.request_type == RequestType.tv else "a matching release" + search_detail = { + "searching": f"{collector} is searching for {search_target}.", + "queued": f"Search queued — waiting for {collector} to start.", + "idle": f"Not currently searching for this {'series' if snapshot.request_type == RequestType.tv else 'movie'}.", + }.get(search_status, f"Search status unavailable — unable to check {collector}.") if snapshot.state == NormalizedState.requested: status_label = "Waiting for approval" @@ -679,8 +693,8 @@ def _build_presentation( elif jellyfin_partial: status_label = f"Partially available — {available} of {total} episodes collected" meaning = ( - f"Some of this request is ready to watch. {collector} is still looking for " - f"{missing} missing episode{'s' if missing != 1 else ''}." + f"Some of this request is ready to watch; {missing} episode{'s' if missing != 1 else ''} " + f"{'is' if missing == 1 else 'are'} still missing. {search_detail}" ) elif snapshot.state in {NormalizedState.completed, NormalizedState.available}: status_label = "Available to watch" @@ -760,6 +774,10 @@ def _build_presentation( next_title = "Add this request to the library queue" next_description = f"Send the approved request to {collector} so collection can begin." recommended = ["readd_to_arr"] + elif search_in_progress and arr_state != "available": + next_title = "Wait for the search results" if search_status == "searching" else "Wait for the queued search" + next_description = f"{search_detail} This page will update automatically." + recommended = [] elif "search_auto" in action_ids or "search_releases" in action_ids: if snapshot.request_type == RequestType.tv and missing: target = f"the {missing} missing episode{'s' if missing != 1 else ''}" @@ -806,38 +824,38 @@ def _build_presentation( "state": "complete" if approved else "active", "summary": "Approved for collection" if approved else "Waiting for approval", } + library_state_label = None if arr_state == "missing": library_state, library_summary = "attention", "Not yet added to the collector" elif arr_state == "error": library_state, library_summary = "attention", f"Unable to read {collector}" - elif partial: - library_state, library_summary = "partial", f"{available} of {total} episodes collected" - elif arr_state == "available": - library_state, library_summary = "complete", "Collection complete" - elif arr_state in {"added", "searching"}: - library_state = "active" if missing or not available else "complete" - library_summary = ( - f"{missing} episode{'s' if missing != 1 else ''} still missing" - if snapshot.request_type == RequestType.tv and missing - else "In the library queue" - ) + elif fully_available or (arr_state == "available" and not partial): + library_state, library_summary = "complete", "Collection complete — no search needed" + elif arr_state in {"added", "searching", "available"}: + library_state = "active" if search_in_progress else "attention" if search_status == "unavailable" else "waiting" + library_state_label = search_label + library_summary = search_detail + if download_visible and download_state == "downloading" and not search_in_progress: + library_state, library_state_label = "active", "Downloading" + library_summary = f"Download in progress. {search_detail}" + if partial: + library_state = "partial" + library_summary = f"{available} of {total} episodes collected. {library_summary}" else: library_state, library_summary = "waiting", "Waiting for collector information" if fully_available: search_state, search_summary = "complete", "No further search needed" - elif arr_state == "available": + elif arr_state == "available" and not partial: search_state, search_summary = "complete", "A release was collected" - elif download_visible: + elif search_in_progress: + search_state, search_summary = "active", search_detail + elif download_visible and download_state in {"downloading", "paused", "completed"} and not partial: search_state = "complete" search_summary = "A release was found" elif arr_state in {"added", "searching"} and (missing or snapshot.request_type == RequestType.movie): - search_state = "active" if prowlarr_state == "ok" else "attention" - search_summary = ( - f"Ready to search for {missing} missing episode{'s' if missing != 1 else ''}" - if snapshot.request_type == RequestType.tv and missing - else "Ready to search for a release" - ) + search_state = "waiting" if search_status == "idle" and prowlarr_state == "ok" else "attention" + search_summary = search_detail else: search_state, search_summary = "waiting", "Search has not started" @@ -917,6 +935,8 @@ def _build_presentation( "id": "library", "label": "Library collection", "state": library_state, + "stateLabel": library_state_label or library_state, + "searchStatus": search_status, "summary": library_summary, "available": available, "missing": missing, @@ -929,7 +949,7 @@ def _build_presentation( "label": "Release search", "state": search_state, "summary": search_summary, - "actionIds": [] if fully_available else [ + "actionIds": [] if fully_available or search_in_progress else [ action_id for action_id in ("search_auto", "search_releases") if action_id in action_ids @@ -1144,6 +1164,9 @@ async def build_snapshot(request_id: str) -> Snapshot: arr_queue = _filter_queue(arr_queue, series_id, RequestType.tv) arr_details["queue"] = arr_queue episodes = await sonarr.get_episodes(series_id) + arr_details["search"] = { + "state": await read_search_status(sonarr, RequestType.tv, series_id, episodes) + } arr_details["availability"] = _episode_availability(episodes) missing_by_season = _missing_episode_numbers_by_season(episodes) if missing_by_season: @@ -1187,8 +1210,6 @@ async def build_snapshot(request_id: str) -> Snapshot: if arr_item: if arr_item.get("hasFile"): arr_state = "available" - elif arr_item.get("isAvailable"): - arr_state = "searching" else: arr_state = "added" else: @@ -1203,12 +1224,17 @@ async def build_snapshot(request_id: str) -> Snapshot: arr_queue = await radarr.get_queue(int(arr_item["id"])) arr_queue = _filter_queue(arr_queue, int(arr_item["id"]), RequestType.movie) arr_details["queue"] = arr_queue + arr_details["search"] = { + "state": await read_search_status(radarr, RequestType.movie, int(arr_item["id"])) + } except Exception as exc: arr_state = "error" arr_details["error"] = str(exc) if arr_state is None: arr_state = "unknown" + if arr_state == "added" and (arr_details.get("search") or {}).get("state") == "searching": + arr_state = "searching" _apply_arr_identity(snapshot, arr_item) timeline.append(TimelineHop(service="Sonarr/Radarr", status=arr_state, details=arr_details)) diff --git a/backend/tests/test_collector_search.py b/backend/tests/test_collector_search.py new file mode 100644 index 0000000..11406bf --- /dev/null +++ b/backend/tests/test_collector_search.py @@ -0,0 +1,158 @@ +from contextlib import ExitStack +from types import SimpleNamespace +import unittest +from unittest.mock import AsyncMock, patch + +from backend.app.config import settings +from backend.app.models import NormalizedState, RequestType, Snapshot +from backend.app.services import snapshot as snapshot_service +from backend.app.services.collector_search import read_search_status, search_status + + +def command(name="MoviesSearch", status="started", **body): + return {"name": name, "status": status, "body": body} + + +class CollectorSearchTests(unittest.IsolatedAsyncioTestCase): + def test_movie_search_is_scoped_to_the_movie(self): + self.assertEqual(search_status([command(movieIds=[12])], RequestType.movie, 12), "searching") + self.assertEqual(search_status([command(movieIds=[13])], RequestType.movie, 12), "idle") + + def test_queued_search_and_running_search_priority(self): + queued = command(status="queued", movieIds=[12]) + self.assertEqual(search_status([queued], RequestType.movie, 12), "queued") + self.assertEqual(search_status([queued, command(movieIds=[12])], RequestType.movie, 12), "searching") + + def test_terminal_commands_are_not_searching(self): + for state in ["completed", "failed", "aborted", "cancelled", "orphaned", 2, 3, 4, 5, 6]: + with self.subTest(state=state): + self.assertEqual(search_status([command(status=state, movieIds=[12])], RequestType.movie, 12), "idle") + ended = {**command(movieIds=[12]), "ended": "2026-09-06T00:00:00Z"} + self.assertEqual(search_status([ended], RequestType.movie, 12), "idle") + + def test_numeric_statuses(self): + for state, expected in [(0, "queued"), (1, "searching")]: + self.assertEqual(search_status([command(status=state, movieIds=[12])], RequestType.movie, 12), expected) + + def test_series_and_season_searches(self): + for name in ["SeriesSearch", "SeasonSearch"]: + with self.subTest(name=name): + self.assertEqual(search_status([command(name, seriesId=12, seasonNumber=5)], RequestType.tv, 12), "searching") + self.assertEqual(search_status([command(name, seriesId=13, seasonNumber=5)], RequestType.tv, 12), "idle") + + def test_episode_search_uses_episode_ids_not_numbers(self): + episodes = [{"id": 109, "seriesId": 12, "seasonNumber": 5, "episodeNumber": 9}] + for ids, expected in [([109], "searching"), ([9], "idle"), ([110], "idle")]: + self.assertEqual(search_status([command("EpisodeSearch", episodeIds=ids)], RequestType.tv, 12, episodes), expected) + self.assertEqual(search_status([command("EpisodeSearch", episodeIds=[109])], RequestType.tv, 13, episodes), "idle") + + def test_background_tasks_and_unscoped_searches_are_not_title_searches(self): + for name in ["RssSync", "RefreshMovie", "RefreshSeries", "MissingEpisodeSearch", "MoviesSearch"]: + with self.subTest(name=name): + self.assertEqual(search_status([command(name)], RequestType.movie, 12), "idle") + + def test_empty_commands_are_idle_but_missing_response_is_unknown(self): + self.assertEqual(search_status([], RequestType.movie, 12), "idle") + for payload in [None, {}, {"error": "unavailable"}]: + self.assertEqual(search_status(payload, RequestType.movie, 12), "unavailable") + + async def test_check_is_read_only_with_a_short_timeout(self): + client = SimpleNamespace(get=AsyncMock(return_value=[command(movieIds=[12])])) + self.assertEqual(await read_search_status(client, RequestType.movie, 12), "searching") + client.get.assert_awaited_once_with("/api/v3/command", timeout_seconds=3.0) + + async def test_service_failure_is_unknown_not_idle(self): + client = SimpleNamespace(get=AsyncMock(side_effect=TimeoutError())) + self.assertEqual(await read_search_status(client, RequestType.movie, 12), "unavailable") + + +class LibrarySearchPresentationTests(unittest.TestCase): + def presentation(self, search="idle", *, media_type=RequestType.movie, available=0, missing=1, + arr_state="added", download_state="not_started", jellyfin=False): + snapshot = Snapshot(request_id="12", title="Example", request_type=media_type, + state=NormalizedState.added_to_arr) + return snapshot_service._build_presentation( + snapshot, approved=True, arr_state=arr_state, + arr_details={"search": {"state": search}, "availability": { + "available": available, "missing": missing, "total": available + missing, + }}, prowlarr_state="ok", + download={"visible": download_state != "not_started", "state": download_state, "torrents": []}, + jellyfin_found=jellyfin, jellyfin_link=None, + ) + + def stage(self, presentation, stage_id="library"): + return next(stage for stage in presentation["pipeline"] if stage["id"] == stage_id) + + def test_card_uses_actual_search_state(self): + for state, badge, style in [("idle", "Not searching", "waiting"), ("searching", "Searching", "active"), + ("queued", "Search queued", "active"), ("unavailable", "Search unknown", "attention")]: + with self.subTest(state=state): + presentation = self.presentation(state) + library = self.stage(presentation) + self.assertEqual(library["stateLabel"], badge) + self.assertEqual(library["state"], style) + self.assertEqual(library["searchStatus"], state) + self.assertEqual(self.stage(presentation, "search")["summary"], library["summary"]) + self.assertEqual(library["available"], 0) + self.assertEqual(library["missing"], 1) + + def test_partial_tv_retains_counts_and_search_activity(self): + for state in ["idle", "searching", "queued", "unavailable"]: + with self.subTest(state=state): + presentation = self.presentation(state, media_type=RequestType.tv, available=22, missing=2, jellyfin=True) + library = self.stage(presentation) + self.assertEqual(library["state"], "partial") + self.assertEqual(library["searchStatus"], state) + self.assertIn("22 of 24 episodes collected", library["summary"]) + self.assertNotIn("is still looking", presentation["status"]["meaning"]) + + def test_collected_titles_dont_look_stuck_searching(self): + for jellyfin in [True, False]: + library = self.stage(self.presentation("idle", arr_state="available", available=1, missing=0, jellyfin=jellyfin)) + self.assertEqual(library["state"], "complete") + self.assertIn("no search needed", library["summary"]) + + def test_download_has_its_own_state_without_claiming_searching(self): + library = self.stage(self.presentation("idle", download_state="downloading")) + self.assertEqual(library["stateLabel"], "Downloading") + self.assertIn("Not currently searching", library["summary"]) + + def test_an_old_missing_download_does_not_mark_search_complete(self): + search = self.stage(self.presentation("idle", download_state="missing"), "search") + self.assertEqual(search["state"], "waiting") + + +class SearchSnapshotIntegrationTests(unittest.IsolatedAsyncioTestCase): + async def test_movie_eligibility_is_not_search_activity_and_tv_commands_are_checked(self): + for media_type in [RequestType.movie, RequestType.tv]: + for commands, expected in [([], "idle"), ([command("MoviesSearch", movieIds=[12]), command("EpisodeSearch", episodeIds=[109])], "searching")]: + with self.subTest(media_type=media_type, search=expected), ExitStack() as stack: + runtime = settings.model_copy(update={"requests_data_source": "prefer_cache"}) + item = {"id": 12, "title": "Example", "hasFile": False, "isAvailable": True, "monitored": True} + collector = SimpleNamespace( + get_movie_by_tmdb_id=AsyncMock(return_value=[item]), + get_series_by_tvdb_id=AsyncMock(return_value=[item]), + get_episodes=AsyncMock(return_value=[{"id": 109, "seriesId": 12, "seasonNumber": 5, "episodeNumber": 9, "monitored": True, "hasFile": False}]), + get_queue=AsyncMock(return_value={"records": []}), + get=AsyncMock(return_value=commands), + ) + mocks = { + "get_runtime_settings": runtime, + "get_request_cache_payload": {"id": 12, "type": media_type.value, "status": 2, + "media": {"title": "Example", "tmdbId": 123, "tvdbId": 456}}, + "get_request_cache_by_id": None, + "JellyseerrClient": SimpleNamespace(configured=lambda: False), + "JellyfinClient": SimpleNamespace(configured=lambda: False), + "QBittorrentClient": SimpleNamespace(configured=lambda: False), + "SonarrClient": collector, "RadarrClient": collector, + "ProwlarrClient": SimpleNamespace(get_health=AsyncMock(return_value=[])), + "get_request_download_evidence": {}, "_latest_repair_action": None, "save_snapshot": None, + } + for name, value in mocks.items(): + stack.enter_context(patch.object(snapshot_service, name, return_value=value)) + stack.enter_context(patch.object(snapshot_service, "_maybe_refresh_jellyfin", new=AsyncMock())) + snapshot = await snapshot_service.build_snapshot("12") + collector.get.assert_awaited_once_with("/api/v3/command", timeout_seconds=3.0) + self.assertEqual(snapshot.state, NormalizedState.searching if expected == "searching" else NormalizedState.added_to_arr) + library = next(stage for stage in snapshot.presentation["pipeline"] if stage["id"] == "library") + self.assertEqual(library["searchStatus"], expected) diff --git a/frontend/app/requests/[id]/page.tsx b/frontend/app/requests/[id]/page.tsx index 21c7437..e064093 100644 --- a/frontend/app/requests/[id]/page.tsx +++ b/frontend/app/requests/[id]/page.tsx @@ -26,6 +26,7 @@ type PipelineStage = { label: string state: 'complete' | 'active' | 'partial' | 'attention' | 'waiting' | string stateLabel?: string + searchStatus?: 'searching' | 'queued' | 'idle' | 'unavailable' summary: string available?: number missing?: number @@ -320,6 +321,11 @@ export default function RequestTimelinePage() { snapshot?.presentation?.repairActivity?.visible && !['complete', 'attention'].includes(snapshot.presentation.repairActivity.state ?? '') ) + const searchIsActive = Boolean( + snapshot?.presentation?.pipeline?.some( + (stage) => stage.id === 'library' && ['searching', 'queued'].includes(stage.searchStatus ?? '') + ) + ) const closeReleasePicker = () => { if (busyAction?.startsWith('grab:')) return @@ -404,8 +410,10 @@ export default function RequestTimelinePage() { useEffect(() => { if (!getToken() || !requestId) return let stopped = false + let refreshing = false const refresh = async () => { - if (document.visibilityState === 'hidden') return + if (document.visibilityState === 'hidden' || refreshing) return + refreshing = true try { const response = await authFetch(`${getApiBase()}/requests/${requestId}/snapshot`) if (response.status === 401) { @@ -418,17 +426,19 @@ export default function RequestTimelinePage() { if (!stopped && isSnapshotPayload(payload)) setSnapshot(payload) } catch (error) { if (!stopped) console.error(error) + } finally { + refreshing = false } } const timer = window.setInterval( () => void refresh(), - awaitingMediaIndex || repairIsActive ? 5_000 : 15_000, + awaitingMediaIndex || repairIsActive || searchIsActive ? 5_000 : 15_000, ) return () => { stopped = true window.clearInterval(timer) } - }, [awaitingMediaIndex, repairIsActive, requestId, router]) + }, [awaitingMediaIndex, repairIsActive, searchIsActive, requestId, router]) const liveDownloadKey = useMemo(() => { const downloadStage = snapshot?.presentation?.pipeline?.find((stage) => stage.id === 'download') @@ -864,7 +874,7 @@ export default function RequestTimelinePage() { {stage.stateLabel ?? stage.state}

{stage.label}

-

{stage.summary}

+

{stage.summary}

{stage.id === 'library' && Boolean(stage.total) && (
diff --git a/scripts/review_search_ui.cjs b/scripts/review_search_ui.cjs new file mode 100644 index 0000000..ed3077d --- /dev/null +++ b/scripts/review_search_ui.cjs @@ -0,0 +1,101 @@ +// Isolated UI checks: all API responses are fixtures. No searches or downloads run. +const assert = require('node:assert/strict') +const { chromium } = require(process.env.REVIEW_PLAYWRIGHT || 'playwright') +const base = process.env.REVIEW_BASE || 'http://127.0.0.1:3101' +const output = process.env.REVIEW_DIR + +const states = { + queued: ['active', 'Search queued', 'Search queued — waiting for Radarr to start.'], + searching: ['active', 'Searching', 'Radarr is searching for a matching release.'], + idle: ['waiting', 'Not searching', 'Not currently searching for this movie.'], + unavailable: ['attention', 'Search unknown', 'Search status unavailable — unable to check Radarr.'], + complete: ['complete', 'complete', 'Collection complete — no search needed'], +} +const fixture = (mode, partial = false) => { + const [state, stateLabel, summary] = states[mode] + const library = { + id: 'library', label: 'Library collection', state: partial ? 'partial' : state, stateLabel, + searchStatus: mode === 'complete' ? 'idle' : mode, + summary: partial ? '22 of 24 episodes collected. ' + summary : summary, + total: partial ? 24 : 1, available: partial ? 22 : mode === 'complete' ? 1 : 0, missing: partial ? 2 : mode === 'complete' ? 0 : 1, + } + return { + request_id: '12', title: 'Search status review', request_type: partial ? 'tv' : 'movie', + state: mode === 'complete' ? 'COMPLETED' : 'ADDED_TO_ARR', timeline: [], actions: [], + presentation: { + status: { label: 'In the library queue', meaning: 'Tracking collection.' }, + download: { visible: false }, + nextStep: { title: 'Tracking your request', description: 'The status updates automatically.', actionIds: [] }, + pipeline: [ + { id: 'requested', label: 'Requested', state: 'complete', summary: 'Request received' }, + { id: 'approved', label: 'Approved', state: 'complete', summary: 'Approved for collection' }, + library, + { id: 'search', label: 'Release search', state, summary }, + { id: 'download', label: 'Download', state: 'waiting', summary: 'No download attempt yet' }, + { id: 'available', label: 'Media server', state: 'waiting', summary: 'Not yet available' }, + ], + }, + } +} + +;(async () => { + const browser = await chromium.launch({ headless: true }) + try { + const context = await browser.newContext() + await context.addCookies([{ name: 'magent_logged_in', value: '1', url: base }]) + let mode = 'queued' + let partial = false + let polls = 0 + const mutations = [] + const errors = [] + await context.route('**/api/**', (route) => { + const request = route.request() + const path = new URL(request.url()).pathname + if (request.method() !== 'GET') mutations.push(path) + const reply = (json) => route.fulfill({ json }) + if (path === '/api/auth/me') return reply({ username: 'Review member', role: 'user' }) + if (path.endsWith('/snapshot')) { polls++; return reply(fixture(mode, partial)) } + if (path.includes('/events/stream')) return route.fulfill({ contentType: 'text/event-stream', body: ': fixture\n\n' }) + if (path.includes('/branding/')) return route.fulfill({ status: 404 }) + return reply({ navigation: { showRequests: true }, requests: [], services: [] }) + }) + const page = await context.newPage() + page.on('pageerror', (error) => errors.push(error.message)) + const card = page.locator('.request-stage').filter({ has: page.getByRole('heading', { name: 'Library collection', exact: true }) }) + const verify = async () => { + await card.getByText(states[mode][1], { exact: true }).waitFor() + assert.equal(await card.getByRole('progressbar').getAttribute('aria-valuenow'), partial ? '22' : mode === 'complete' ? '1' : '0') + assert.equal(await page.evaluate(() => document.documentElement.scrollWidth - innerWidth), 0) + await card.evaluate((element) => element.scrollIntoView({ block: 'center', behavior: 'instant' })) + if (output) await card.screenshot({ path: output + `/search-${page.viewportSize().width}-${partial ? 'partial-' : ''}${mode}.png` }) + } + for (const width of [1440, 390]) { + await page.setViewportSize({ width, height: 1000 }) + for (mode of Object.keys(states)) { + await page.goto(base + '/requests/12') + await verify() + } + mode = 'idle'; partial = true + await page.goto(base + '/requests/12') + await verify() + partial = false + } + // Confirm queued -> searching -> idle updates without a click or page reload. + mode = 'queued' + await page.goto(base + '/requests/12') + await verify() + const initialPolls = polls + mode = 'searching' + await card.getByText('Searching', { exact: true }).waitFor({ timeout: 8000 }) + await verify() + mode = 'idle' + await card.getByText('Not searching', { exact: true }).waitFor({ timeout: 8000 }) + await verify() + assert.ok(polls >= initialPolls + 2, 'Active search must refresh automatically') + assert.deepEqual(errors, []) + assert.deepEqual(mutations, []) + console.log('PASS: desktop/mobile search cards, partial collection, and queued → searching → idle automatic updates; no API mutations.') + } finally { + await browser.close() + } +})().catch((error) => { console.error(error); process.exitCode = 1 })