Show actual collector search activity in request pipeline
Magent CI/CD / verify (push) Successful in 10m55s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 2m4s

This commit is contained in:
2026-09-06 21:14:42 +12:00
parent dec1dd902c
commit 1851fa9753
6 changed files with 386 additions and 28 deletions
+2
View File
@@ -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"
+61
View File
@@ -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)
+50 -24
View File
@@ -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))
+158
View File
@@ -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)
+14 -4
View File
@@ -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() {
<span className={`request-stage-state state-${stage.state}`}>{stage.stateLabel ?? stage.state}</span>
</div>
<h3>{stage.label}</h3>
<p>{stage.summary}</p>
<p aria-live={stage.id === 'library' ? 'polite' : undefined} aria-atomic={stage.id === 'library' ? true : undefined}>{stage.summary}</p>
{stage.id === 'library' && Boolean(stage.total) && (
<div className="request-availability-meter">
+101
View File
@@ -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 })