Compact request activity into a live summary and modal history

This commit is contained in:
2026-09-06 22:20:33 +12:00
parent 009bb35032
commit 74c49fad5b
10 changed files with 223 additions and 71 deletions
+9 -9
View File
@@ -117,7 +117,7 @@ def _operation_result_message(
if "/queue" in normalized_path and normalized_method == "GET":
return _queue_result_message(service, result)
if "/command" in normalized_path and normalized_method == "POST":
return f"{service} accepted the {_command_name(payload)} and queued it for processing."
return f"{service} accepted the {_command_name(payload)} and put it in line to run. This does not mean a download has started."
if "/release" in normalized_path:
if normalized_method == "GET":
count = len(_result_items(result, "records", "items"))
@@ -129,10 +129,10 @@ def _operation_result_message(
return f"{service} accepted the selected release and sent it to the download client."
if "/qualityprofile" in normalized_path and normalized_method == "GET":
count = len(_result_items(result))
return f"{service} returned {_count_message(count, 'available quality profile')}."
return f"{service} returned {_count_message(count, 'download quality setting')}."
if "/rootfolder" in normalized_path and normalized_method == "GET":
count = len(_result_items(result))
return f"{service} returned {_count_message(count, 'configured library location')}."
return f"{service} returned {_count_message(count, 'library folder')}."
if "/indexer" in normalized_path and normalized_method == "GET":
count = len(_result_items(result))
return f"{service} reports {_count_message(count, 'configured search source')}."
@@ -174,7 +174,7 @@ def _operation_result_message(
if "/health" in normalized_path:
issues = _result_items(result)
if not issues:
return "Prowlarr reports that all configured indexers are healthy."
return "The download search sources are working normally."
first = next((item for item in issues if isinstance(item, dict)), {})
detail = str(first.get("message") or first.get("source") or "").strip()
suffix = f" First issue: {detail}" if detail else ""
@@ -182,9 +182,9 @@ def _operation_result_message(
if "/search" in normalized_path:
results = _result_items(result, "results", "records")
return (
f"Prowlarr found {_count_message(len(results), 'possible release')}."
f"Prowlarr found {_count_message(len(results), 'possible download')}."
if results
else "Prowlarr did not find any possible releases."
else "Prowlarr did not find any possible downloads."
)
if service == "Bazarr" and normalized_method == "PATCH" and "/subtitles" in normalized_path:
@@ -193,9 +193,9 @@ def _operation_result_message(
return f"Bazarr accepted a fresh {language} subtitle search for the {target}."
if normalized_method == "GET":
return f"{service} completed the check successfully."
return f"{service} finished this check without reporting a problem."
if normalized_method == "POST":
return f"{service} accepted the request and started processing it."
return f"{service} received the request. Its result will be checked separately."
if normalized_method == "PUT":
return f"{service} saved the requested changes."
if normalized_method == "DELETE":
@@ -247,7 +247,7 @@ def _operation_messages(service: str, method: str, path: str) -> tuple[str, str]
if service == "Bazarr" and "/subtitles" in normalized_path and normalized_method == "PATCH":
return "Asking Bazarr for fresh subtitles…", "Bazarr started the subtitle search"
if service == "Prowlarr" and "/health" in normalized_path:
return "Checking Prowlarr indexer health", "Prowlarr returned its indexer health"
return "Checking whether the download search sources are working", "Prowlarr returned its indexer health"
return f"Contacting {service}", f"{service} responded"
+2 -2
View File
@@ -15,9 +15,9 @@ def _availability_message(result: Any) -> str:
or (isinstance(items, list) and len(items) > 0)
)
return (
"The title is available to watch in Jellyfin."
"Grizzlyflix returned possible matches. Magent still needs to check the exact title and file."
if available
else "The title is not currently available in Jellyfin."
else "Grizzlyflix did not find this title in its library search."
)
+15 -9
View File
@@ -8,20 +8,26 @@ from ..services.operation_progress import finish_remote_call, start_remote_call
def _torrent_state_text(state: Any) -> str:
normalized = str(state or "").strip().lower()
if "pause" in normalized:
if normalized in {"uploading", "stalledup", "forcedup", "queuedup", "pausedup", "stoppedup", "completed"}:
return "finished"
if "pause" in normalized or normalized == "stoppeddl":
return "paused"
if "stall" in normalized:
return "stalled"
return "waiting for data"
if normalized.startswith("queued"):
return "waiting in the queue"
if "downloading" in normalized or normalized in {"forcedl", "metadl", "checkingdl"}:
if normalized == "metadl":
return "getting the download details"
if normalized in {"checkingdl", "checkingup", "checkingresumedata"}:
return "checking the downloaded files"
if "downloading" in normalized or normalized in {"forcedl", "forceddl"}:
return "downloading"
if "upload" in normalized or normalized in {"stalledup", "forcedup"}:
return "finished and seeding"
if "upload" in normalized:
return "downloaded and sharing with others"
if normalized in {"completed", "missingfiles"}:
return "finished" if normalized == "completed" else "missing files"
if "error" in normalized:
return "in an error state"
return "unable to continue"
return "present"
@@ -31,14 +37,14 @@ def _torrent_result_message(result: Any) -> str:
return "qBittorrent found no matching downloads."
first = next((item for item in torrents if isinstance(item, dict)), {})
if len(torrents) == 1:
name = str(first.get("name") or "the matching download").strip()
progress = first.get("progress")
progress_text = (
f" and {max(0, min(100, round(progress * 100)))}% complete"
f" {max(0, min(100, round(progress * 100)))}% complete"
if isinstance(progress, (int, float))
else ""
)
return f'qBittorrent found "{name}"; it is {_torrent_state_text(first.get("state"))}{progress_text}.'
state_text = _torrent_state_text(first.get("state"))
return f'{"Downloading" if state_text == "downloading" else "The download is " + state_text}{progress_text}.'
active = sum(
1
for item in torrents
+3 -3
View File
@@ -70,7 +70,7 @@ def begin_operation(operation_id: str, *, label: Optional[str], path: str) -> To
"id": uuid.uuid4().hex,
"service": "Magent",
"state": "complete",
"message": "Magent received the action.",
"message": "Your action has been received. Magent is starting the checks.",
"started_at": now_iso,
"finished_at": now_iso,
"duration_ms": 0,
@@ -178,9 +178,9 @@ def finish_operation(operation_id: str, *, success: bool, status_code: Optional[
"service": "Magent",
"state": "complete" if success else "error",
"message": (
"Magent finished processing the action."
"This action has finished. Check the request status for what happens next."
if success
else "Magent could not complete the action."
else "This action could not be completed. Open the activity details to see which step needs attention."
),
"started_at": now_iso,
"finished_at": now_iso,
+13 -3
View File
@@ -240,7 +240,7 @@ class OperationMessageTests(unittest.TestCase):
)
self.assertEqual(queue_message, "Sonarr has no matching downloads in its queue.")
self.assertEqual(health_message, "Prowlarr reports that all configured indexers are healthy.")
self.assertEqual(health_message, "The download search sources are working normally.")
def test_download_and_jellyfin_results_include_actual_state(self) -> None:
torrent_message = _torrent_result_message(
@@ -249,11 +249,11 @@ class OperationMessageTests(unittest.TestCase):
self.assertEqual(
torrent_message,
'qBittorrent found "Arrival.2016"; it is downloading and 42% complete.',
'Downloading 42% complete.',
)
self.assertEqual(
_availability_message({"TotalRecordCount": 0, "Items": []}),
"The title is not currently available in Jellyfin.",
"Grizzlyflix did not find this title in its library search.",
)
def test_bazarr_subtitle_search_is_explained_in_plain_english(self) -> None:
@@ -270,6 +270,16 @@ class OperationMessageTests(unittest.TestCase):
"Bazarr accepted a fresh EN subtitle search for the selected episode.",
)
def test_library_search_does_not_claim_playable_media(self) -> None:
message = _availability_message({"TotalRecordCount": 1, "Items": [{"Name": "Example"}]})
self.assertIn("still needs to check the exact title and file", message)
self.assertNotIn("available to watch", message)
def test_finished_or_paused_download_is_not_described_as_stuck(self) -> None:
for state in ["stalledUP", "stoppedUP", "pausedUP"]:
self.assertIn("finished", _torrent_result_message([{"state": state, "progress": 1}]))
self.assertIn("paused", _torrent_result_message([{"state": "stoppedDL", "progress": .3}]))
def test_http_failures_are_translated_without_exposing_stack_traces(self) -> None:
self.assertEqual(
_operation_error_message("Radarr", 500),
+2
View File
@@ -8,6 +8,7 @@
- Keep technical IDs and pipeline labels monospace. Use sentence case for ordinary labels and descriptions.
- Preserve the six-stage request pipeline: three columns on desktop, two on tablet, one on narrow screens. Issue reports remain a right-hand column on desktop and stack on smaller screens.
- A media repair starts a new collection cycle: preserve Requested/Approved and unaffected TV episodes, but do not reuse an old torrent or Jellyfin entry to mark the replacement ready. Show Pending → Downloading → Indexing → Available from collector/file evidence. Subtitle-only repairs must not reset video availability.
- Request action feedback uses a compact Latest activity card beside download status; full event history opens in a native modal dialog without expanding the page. Keep messages outcome-based and do not equate a successful service response with playable media.
- Desktop navigation stays at the top; mobile navigation stays at the bottom. Dialogs must remain clear of both.
- The guided issue form uses `portal/IssueFlowStep.tsx`: show one expanded step, collapse completed answers into Change rows, and keep repairs behind the final submit action. Movies use the selected title's managed file directly; only TV needs a season/episode picker. Multi-select controls must expose `aria-pressed` and a visible selected state.
@@ -19,6 +20,7 @@ Build the frontend before reviewing. The scripts in `scripts/` run using Node an
- `review_account_ui.cjs`: fixture-only login and profile interaction checks.
- `review_issue_flow_ui.cjs`: fixture-only movie/TV issue flow, multi-device report payloads, subtitle routing, permissions, and collapsed-step navigation.
- `review_repair_pipeline_ui.cjs`: fixture-only movie/TV replacement stages, old-cycle progress rejection, desktop/mobile layout and automatic availability transitions.
- `review_activity_ui.cjs`: fixture-only latest activity, desktop/tablet/mobile placement, modal history, keyboard dismissal and focus restoration.
- `review_settings_ui.cjs`: settings state, region-only saves, secret preservation, responsive controls and issue dialog placement.
The layout/settings reviews accept `REVIEW_BASE`, `REVIEW_LIVE_BASE`, `REVIEW_PLAYWRIGHT`, and `REVIEW_DIR`. Provide an authorised short-lived session through `REVIEW_SESSION` as `{ "name": "cookie-name", "token": "..." }` in the process environment, never in a committed file. Live writes are blocked; submission checks use fixtures. Screenshots may contain account information and must stay outside the repository.
@@ -0,0 +1,57 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import './latest-activity.css'
type Event = { id: string; service: string; state: string; message: string; started_at?: string; finished_at?: string }
type Operation = { id: string; label: string; status: string; events: Event[] }
export default function LatestActivity({ operation, besideDownload, onDismiss }: {
operation: Operation; besideDownload: boolean; onDismiss: () => void
}) {
const dialog = useRef<HTMLDialogElement>(null)
const trigger = useRef<HTMLButtonElement>(null)
const [open, setOpen] = useState(false)
const latest = [...operation.events].sort((a, b) =>
(a.finished_at ?? a.started_at ?? '').localeCompare(b.finished_at ?? b.started_at ?? '')
).at(-1)
const status = operation.status === 'running' ? 'Working' : operation.status === 'complete' ? 'Finished' : 'Needs attention'
useEffect(() => {
if (!open) return
const element = dialog.current
element?.showModal()
const previous = document.body.style.overflow
document.body.style.overflow = 'hidden'
return () => {
element?.close()
document.body.style.overflow = previous
trigger.current?.focus()
}
}, [open])
return (
<div className={`request-overview-block latest-activity ${besideDownload ? 'beside-download' : 'full-row'}`}>
<button ref={trigger} type="button" className="latest-activity-trigger" onClick={() => setOpen(true)} aria-haspopup="dialog" aria-expanded={open}>
<span className="latest-activity-heading"><span className="request-overview-label">Latest activity</span><span className={`latest-activity-badge is-${operation.status}`}>{status}</span></span>
<span className="latest-activity-message" role="status">{latest?.message || 'Getting ready to check your request…'}</span>
<span className="latest-activity-more">View all activity ({operation.events.length}) <span aria-hidden="true"></span></span>
</button>
<dialog ref={dialog} className="activity-dialog" aria-labelledby="activity-dialog-title" onCancel={() => setOpen(false)} onClose={() => setOpen(false)} onClick={(event) => { if (event.target === event.currentTarget) setOpen(false) }}>
<div className="activity-dialog-content">
<header>
<div><span className="request-overview-label">Activity details</span><h2 id="activity-dialog-title">{operation.label}</h2><small>{status} · {operation.events.length} updates</small></div>
<button type="button" onClick={() => setOpen(false)} autoFocus>Close</button>
</header>
<ol className="activity-dialog-events" aria-label="All activity, oldest first">
{operation.events.map((event) => <li key={event.id} className={`is-${event.state}`}>
<span className="activity-event-state">{event.state === 'active' ? 'Working' : event.state === 'error' ? 'Needs attention' : 'Done'}</span>
<div><strong>{event.service}</strong><p>{event.message}</p></div>
</li>)}
</ol>
{operation.status !== 'running' && <footer><button type="button" onClick={() => { setOpen(false); onDismiss() }}>Dismiss activity</button></footer>}
</div>
</dialog>
</div>
)
}
@@ -0,0 +1,28 @@
.latest-activity.beside-download { grid-column: 7 / -1; grid-row: 2; }
.latest-activity.full-row { grid-column: 1 / -1; }
.latest-activity .latest-activity-trigger { display: grid; gap: .6rem; width: 100%; padding: 0; border: 0; background: transparent; color: inherit; text-align: left; box-shadow: none; text-transform: none; }
.latest-activity-trigger:focus-visible { outline: 2px solid var(--ops-accent, #83d7f7); outline-offset: 6px; }
.latest-activity-heading { display: flex; align-items: center; justify-content: space-between; gap: .75rem; }
.latest-activity-message { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; overflow-wrap: anywhere; font-size: .875rem; line-height: 1.5; font-weight: 400; }
.latest-activity-more { color: var(--ops-accent, #83d7f7); font-size: .75rem; }
.latest-activity-badge { font-size: .7rem; font-weight: 500; color: var(--ops-muted, #bbb); }
.latest-activity-badge.is-error { color: #ff9b9b; }
.latest-activity-badge.is-complete { color: #55dec0; }
.activity-dialog { position: fixed; inset: 0; margin: auto; width: min(720px, calc(100vw - 32px)); max-width: none; max-height: min(760px, calc(100dvh - 40px)); padding: 0; border: 1px solid var(--ops-border, #444); border-radius: 16px; color: var(--ops-text, #eee); background: var(--ops-surface, #1c1c1e); overflow: auto; box-shadow: 0 24px 80px #0008; }
.activity-dialog::backdrop { background: #000a; backdrop-filter: blur(5px); }
.activity-dialog-content { padding: 1.25rem; }
.activity-dialog header { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; }
.activity-dialog h2 { font-size: 1.2rem; margin: .4rem 0; }
.activity-dialog small { color: var(--ops-muted, #bbb); }
.activity-dialog-events { list-style: none; padding: 0; margin: 1.25rem 0 0; display: grid; gap: .65rem; }
.activity-dialog-events li { display: grid; grid-template-columns: 85px minmax(0, 1fr); gap: .8rem; padding: .9rem; border: 1px solid var(--ops-border, #444); border-radius: 10px; }
.activity-dialog-events strong { font-size: .8rem; }
.activity-dialog-events p { margin: .3rem 0 0; font-size: .875rem; line-height: 1.5; overflow-wrap: anywhere; }
.activity-event-state { font-size: .7rem; color: #55dec0; }
.is-error > .activity-event-state { color: #ff9b9b; }
.is-active > .activity-event-state { color: #83d7f7; }
.activity-dialog footer { display: flex; justify-content: flex-end; margin-top: 1rem; }
@media (max-width: 720px) {
.latest-activity.beside-download { grid-column: 1 / -1; grid-row: auto; }
.activity-dialog-events li { grid-template-columns: 1fr; gap: .4rem; }
}
+20 -45
View File
@@ -1,6 +1,7 @@
'use client'
import PageHeading from '../../ui/PageHeading'
import LatestActivity from './LatestActivity'
import Image from 'next/image'
import { useParams, useRouter } from 'next/navigation'
@@ -200,12 +201,6 @@ const torrentProgress = (torrent: Record<string, any>) => {
const formatProgress = (progress: number) => `${progress.toFixed(1).replace(/\.0$/, '')}% complete`
const formatDuration = (duration?: number | null) => {
if (typeof duration !== 'number' || Number.isNaN(duration)) return null
if (duration < 1000) return `${Math.max(0, Math.round(duration))}ms`
return `${(duration / 1000).toFixed(1)}s`
}
const mergeLiveDownload = (current: Snapshot, live: LiveDownloadProgress): Snapshot => {
if (String(current.request_id) !== String(live.request_id)) return current
if ((current.presentation?.repairCycle ?? null) !== (live.repairCycle ?? null)) return current
@@ -583,7 +578,10 @@ export default function RequestTimelinePage() {
})
if (!stopped && progressResponse.ok) {
const progress = await progressResponse.json()
if (Array.isArray(progress?.events)) setOperationProgress(progress)
if (Array.isArray(progress?.events)) {
setOperationProgress(progress)
return progress as OperationProgress
}
}
} catch (error) {
if (!stopped) console.error(error)
@@ -594,8 +592,21 @@ export default function RequestTimelinePage() {
const timer = window.setInterval(() => void refreshProgress(), 650)
try {
const response = await request
await refreshProgress()
const finalProgress = await refreshProgress()
if (!finalProgress || finalProgress.status === 'running') {
setOperationProgress((current) => current?.id === operationId ? {
...current, status: response.ok ? 'complete' : 'error',
events: [...current.events, { id: 'result', service: 'Magent', state: response.ok ? 'complete' : 'error',
message: response.ok ? 'This action has finished. Check the request status for what happens next.' : 'This action could not be completed. Check the message beside the request controls.' }],
} : current)
}
return response
} catch (error) {
setOperationProgress((current) => current?.id === operationId ? {
...current, status: 'error', events: [...current.events, { id: 'connection-error', service: 'Magent', state: 'error',
message: 'The connection was interrupted. Recheck the request before trying the action again—it may already have started.' }],
} : current)
throw error
} finally {
stopped = true
window.clearInterval(timer)
@@ -753,6 +764,7 @@ export default function RequestTimelinePage() {
{download?.lastSeenAt && !download?.torrents?.length && <small>Last observed {formatWhen(download.lastSeenAt)}</small>}
</div>
)}
{operationProgress && <LatestActivity operation={operationProgress} besideDownload={downloadVisible} onDismiss={() => setOperationProgress(null)} />}
<div className="request-overview-block request-next-step">
<div className="request-next-step-main">
<div className="request-next-step-copy">
@@ -793,43 +805,6 @@ export default function RequestTimelinePage() {
{actionError ?? actionMessage}
</div>
)}
{operationProgress && (
<div className={`request-operation-progress is-${operationProgress.status}`} aria-live="polite">
<div className="request-operation-heading">
<div>
<span className="request-overview-label">Remote activity</span>
<strong>{operationProgress.label}</strong>
</div>
<div className="request-operation-heading-actions">
{formatDuration(operationProgress.duration_ms) && (
<span>{formatDuration(operationProgress.duration_ms)}</span>
)}
<span className={`request-operation-status is-${operationProgress.status}`}>
{operationProgress.status === 'running' ? 'In progress' : operationProgress.status}
</span>
{operationProgress.status !== 'running' && (
<button type="button" onClick={() => setOperationProgress(null)}>Dismiss</button>
)}
</div>
</div>
<div className="request-operation-events">
{operationProgress.events.map((event) => (
<div className={`request-operation-event is-${event.state}`} key={event.id}>
<i aria-hidden="true" />
<div>
<strong>{event.service}</strong>
<span>{event.message}</span>
</div>
<small>
{event.state === 'active'
? 'Waiting…'
: formatDuration(event.duration_ms) ?? (event.status_code ? `HTTP ${event.status_code}` : 'Done')}
</small>
</div>
))}
</div>
</div>
)}
</section>
{repairActivity?.visible && (
+74
View File
@@ -0,0 +1,74 @@
// Fixture-only action feedback and accessible dialog checks; no live API writes.
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'
;(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 downloading = true, finished = false
const events = [
{ id: '1', service: 'Magent', state: 'complete', message: 'Your action has been received.' },
{ id: '2', service: 'Radarr', state: 'active', message: 'Checking whether the replacement has been downloaded…' },
]
let releaseAction
await context.route('**/api/**', async (route) => {
const path = new URL(route.request().url()).pathname
const reply = (json) => route.fulfill({ json })
if (path === '/api/auth/me') return reply({ username: 'Member', role: 'user' })
if (path.endsWith('/snapshot')) return reply({ request_id: '12', title: 'Activity review', request_type: 'movie', state: 'DOWNLOADING', timeline: [], actions: [], presentation: {
status: { label: 'Download in progress', meaning: 'Your movie is downloading.' },
download: { visible: downloading, summary: 'Downloading (1 active).' },
nextStep: { title: 'Let the download finish', description: 'No action needed.', actionIds: [] }, pipeline: [],
} })
if (path.endsWith('/actions/recheck')) { await new Promise((resolve) => { releaseAction = resolve }); return reply({ message: 'Request checked.' }) }
if (path.includes('/operations/')) return reply({ id: 'fixture', label: 'Recheck request status', status: finished ? 'complete' : 'running', events: finished ? [...events, { id: '3', service: 'Magent', state: 'complete', message: 'This check has finished. Your movie is still downloading.' }] : events })
if (path.includes('/events/stream')) return route.fulfill({ contentType: 'text/event-stream', body: ': fixture\n\n' })
return reply({ navigation: { showRequests: true }, services: [] })
})
const page = await context.newPage()
const errors = []
page.on('pageerror', (error) => errors.push(error.message))
for (const width of [1440, 710, 390]) {
await page.setViewportSize({ width, height: 960 })
for (downloading of [true, false]) {
finished = false
await page.goto(base + '/requests/12')
await page.getByRole('button', { name: 'Recheck request', exact: true }).click({ timeout: 5000 }).catch(async (error) => {
console.error(errors, (await page.locator('body').innerText()).slice(0, 1600)); throw error
})
const card = page.locator('.latest-activity')
await card.locator('.latest-activity-message').filter({ hasText: events[1].message }).waitFor()
assert.equal(await card.getByText(events[0].message, { exact: true }).isVisible(), false)
if (width === 1440 && downloading) {
const activityBox = await card.boundingBox()
const downloadBox = await page.locator('.request-overview-block').filter({ hasText: 'Current download state' }).boundingBox()
assert.ok(activityBox.x > downloadBox.x && Math.abs(activityBox.y - downloadBox.y) < 2, 'Activity must use the spare right-hand quadrant')
}
const before = await page.locator('.request-next-step').boundingBox()
await card.getByRole('button').click()
const dialog = page.getByRole('dialog', { name: 'Recheck request status' })
await dialog.waitFor()
await dialog.getByText(events[0].message, { exact: true }).waitFor()
assert.equal(await page.evaluate(() => document.body.style.overflow), 'hidden')
const after = await page.locator('.request-next-step').boundingBox()
assert.equal(before.y, after.y, 'Opening the dialog must not expand the page')
await page.keyboard.press('Escape')
await dialog.waitFor({ state: 'hidden' })
assert.equal(await card.getByRole('button').evaluate((el) => el === document.activeElement), true)
finished = true; releaseAction()
await card.getByText('Finished', { exact: true }).waitFor()
await card.getByRole('button').click()
await dialog.getByText('This check has finished. Your movie is still downloading.', { exact: true }).waitFor()
if (process.env.REVIEW_DIR) await page.screenshot({ path: `${process.env.REVIEW_DIR}/activity-${width}-${downloading}.png` })
await dialog.getByRole('button', { name: 'Dismiss activity' }).click()
await card.waitFor({ state: 'hidden' })
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false)
assert.notEqual(await page.evaluate(() => document.body.style.overflow), 'hidden')
}
}
assert.deepEqual(errors, [])
console.log('PASS: right-hand activity card, latest event only, live updates, modal history, Escape/focus return, dismissal, desktop/tablet/mobile; all API calls mocked.')
} finally { await browser.close() }
})().catch((error) => { console.error(error); process.exitCode = 1 })