Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a32928b1c5 | ||
|
|
74c49fad5b |
@@ -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"
|
||||
|
||||
|
||||
|
||||
@@ -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."
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -129,6 +129,10 @@ def _activity_metadata(entry: Dict[str, Any]) -> Dict[str, Any]:
|
||||
def _repair_tracking(item_id: int) -> tuple[Dict[str, Any], list[Dict[str, Any]]]:
|
||||
activity = list_portal_item_activity(item_id, limit=500)
|
||||
for entry in reversed(activity):
|
||||
# A rejected repair must not be proposed again simply because the same
|
||||
# replacement file is still present. Wait for a NEW repair attempt.
|
||||
if str(entry.get("event_type") or "") == "resolution_rejected":
|
||||
return {}, activity
|
||||
if str(entry.get("event_type") or "") not in _MEDIA_REPAIR_STARTED_EVENTS:
|
||||
continue
|
||||
tracking = _activity_metadata(entry).get("repairTracking")
|
||||
@@ -205,27 +209,34 @@ async def _contact_reporter(item: Dict[str, Any]) -> Dict[str, Any]:
|
||||
attempt_number = attempts + 1
|
||||
reporter = get_user_by_username(str(item.get("created_by_username") or ""))
|
||||
recipient = resolve_user_delivery_email(reporter)
|
||||
issue_url = _issue_url(int(item["id"]))
|
||||
issue_url = f"{_app_url()}/issues/confirm/{int(item['id'])}"
|
||||
sent = False
|
||||
delivery_error: Optional[str] = None
|
||||
if recipient:
|
||||
subject = f"Is your issue fixed? #{item['id']} {item.get('title') or ''}".strip()
|
||||
subject = f"Ready to try again? Grizzlyflix issue #{item['id']}"
|
||||
body_text = (
|
||||
f"We have marked issue #{item['id']} as fixed and need your confirmation.\n\n"
|
||||
f"Issue: {item.get('title') or 'Untitled issue'}\n"
|
||||
f"Confirmation request: {attempt_number} of {maximum}\n\n"
|
||||
f"Open the issue and choose whether it is fixed or still happening:\n{issue_url}\n\n"
|
||||
"If you do not respond, Magent will close the issue automatically after the configured confirmation period."
|
||||
"Your repair looks ready to test.\n\n"
|
||||
f"{item.get('title') or 'Your reported issue'}\n\n"
|
||||
"Please try the affected content in Grizzlyflix. Is it fixed?\n\n"
|
||||
f"YES — it works: {issue_url}#yes\n"
|
||||
f"NO — still broken: {issue_url}#no\n\n"
|
||||
"Confirm your answer in Magent. You may need to sign in first.\n"
|
||||
"Yes closes the report. No keeps it open for another look.\n\n"
|
||||
f"Reminder {attempt_number} of {maximum}. If we do not hear back after the reminder period, this report will close automatically."
|
||||
)
|
||||
body_html = (
|
||||
'<div style="font-family:Segoe UI,Arial,sans-serif;color:#132033;">'
|
||||
'<h2 style="margin:0 0 12px;">Is your issue fixed?</h2>'
|
||||
f'<p style="line-height:1.6;">We have marked issue <strong>#{int(item["id"])}</strong> as fixed and need your confirmation.</p>'
|
||||
f'<p style="line-height:1.6;"><strong>{escape(str(item.get("title") or "Untitled issue"))}</strong><br>'
|
||||
f'Confirmation request {attempt_number} of {maximum}</p>'
|
||||
f'<a href="{escape(issue_url)}" style="display:inline-block;padding:11px 18px;border-radius:8px;background:#1c6bff;color:#fff;text-decoration:none;font-weight:700;">Confirm the outcome</a>'
|
||||
'<p style="margin-top:18px;color:#64748b;line-height:1.6;">If you do not respond, Magent will close the issue automatically after the configured confirmation period.</p>'
|
||||
'</div>'
|
||||
'<div style="background:#111113;padding:24px 12px;font-family:Arial,sans-serif;color:#f4f4f5;">'
|
||||
'<table role="presentation" style="max-width:560px;width:100%;margin:auto;background:#202023;border:1px solid #45454d;border-radius:18px;"><tr><td style="padding:28px;">'
|
||||
'<p style="margin:0 0 24px;color:#c7baff;font-weight:bold;letter-spacing:2px;">GRIZZLYFLIX · MAGENT</p>'
|
||||
'<h1 style="font-size:32px;line-height:1.2;margin:0 0 16px;color:#fff;">Ready to try again?</h1>'
|
||||
'<p style="font-size:17px;line-height:1.6;color:#e4e4e7;">Your repair looks ready to test. Give the affected content a try, then let us know:</p>'
|
||||
f'<p style="padding:16px;background:#131315;border-radius:10px;color:#fff;">{escape(str(item.get("title") or "Your reported issue"))}</p>'
|
||||
'<h2 style="font-size:26px;color:#fff;margin:24px 0 16px;">Is it fixed?</h2>'
|
||||
f'<a href="{escape(issue_url)}#yes" style="display:block;text-align:center;padding:20px;margin-bottom:12px;border-radius:12px;background:#b4f4d2;color:#10261b;text-decoration:none;font-size:24px;font-weight:bold;">YES — it works</a>'
|
||||
f'<a href="{escape(issue_url)}#no" style="display:block;text-align:center;padding:20px;border-radius:12px;background:#ffc1c5;color:#391318;text-decoration:none;font-size:24px;font-weight:bold;">NO — still broken</a>'
|
||||
'<p style="font-size:14px;line-height:1.6;color:#dedee3;">Confirm your answer in Magent. You may need to sign in first.<br>Yes closes the report. No keeps it open for another look.</p>'
|
||||
f'<p style="font-size:12px;line-height:1.6;color:#b9b9c3;">Reminder {attempt_number} of {maximum} · Issue #{int(item["id"])}<br>If we do not hear back after the reminder period, this report will close automatically.</p>'
|
||||
'</td></tr></table></div>'
|
||||
)
|
||||
try:
|
||||
await send_generic_email(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import json
|
||||
import unittest
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from backend.app import db
|
||||
from backend.app.services import issue_resolution as service
|
||||
from backend.tests.test_backend_quality import TempDatabaseMixin
|
||||
|
||||
|
||||
class IssueAcceptanceTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase):
|
||||
def issue(self):
|
||||
item = db.create_portal_item(kind="issue", title="Broken <movie>", description="Repair",
|
||||
created_by_username="reporter", created_by_id=None, status="in_progress", issue_type="broken_media")
|
||||
self.start(item["id"])
|
||||
return item
|
||||
|
||||
def start(self, item_id):
|
||||
db.add_portal_item_activity(item_id, event_type="replacement_started", actor_username="reporter",
|
||||
actor_role="user", message="New repair", metadata_json=json.dumps({"repairTracking": {"requestId": "12", "actionId": "replace_media"}}))
|
||||
|
||||
async def test_importing_or_unverified_media_does_not_email_reporter(self):
|
||||
self.issue()
|
||||
for phase in ["collecting", "indexing", "unavailable"]:
|
||||
with patch.object(service, "_media_repair_evidence", new=AsyncMock(return_value={"complete": False, "phase": phase})), patch.object(service, "begin_issue_confirmation", new=AsyncMock()) as begin:
|
||||
await service.process_active_media_repairs()
|
||||
begin.assert_not_awaited()
|
||||
|
||||
async def test_verified_repair_emails_once_and_no_requires_a_new_repair(self):
|
||||
item = self.issue()
|
||||
with (
|
||||
patch.object(service, "_media_repair_evidence", new=AsyncMock(return_value={"complete": True, "phase": "complete"})),
|
||||
patch.object(service, "_workflow_settings", return_value=(3, 2, "days")),
|
||||
patch.object(service, "get_user_by_username", return_value={"username": "reporter"}),
|
||||
patch.object(service, "resolve_user_delivery_email", return_value="reporter@example.test"),
|
||||
patch.object(service, "send_generic_email", new=AsyncMock()) as email,
|
||||
):
|
||||
await service.process_active_media_repairs()
|
||||
await service.process_active_media_repairs()
|
||||
self.assertEqual(email.await_count, 1)
|
||||
self.assertEqual(db.get_portal_item(item["id"])["status"], "awaiting_confirmation")
|
||||
content = email.await_args.kwargs
|
||||
self.assertIn("YES — it works", content["body_html"])
|
||||
self.assertIn("NO — still broken", content["body_html"])
|
||||
self.assertIn(f"/issues/confirm/{item['id']}#yes", content["body_html"])
|
||||
self.assertIn("Broken <movie>", content["body_html"])
|
||||
self.assertNotIn("<movie>", content["body_html"])
|
||||
self.assertIn("Confirm your answer in Magent", content["body_text"])
|
||||
service.respond_to_issue_confirmation(item["id"], resolved=False, actor_username="reporter", actor_role="user")
|
||||
await service.process_active_media_repairs()
|
||||
await service.process_due_issue_confirmations()
|
||||
self.assertEqual(email.await_count, 1)
|
||||
self.assertEqual(db.get_portal_item(item["id"])["status"], "in_progress")
|
||||
self.start(item["id"])
|
||||
await service.process_active_media_repairs()
|
||||
self.assertEqual(email.await_count, 2)
|
||||
@@ -8,6 +8,8 @@
|
||||
- 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.
|
||||
- Issue acceptance uses `ui/ResolutionChoice.tsx`: large YES/NO choices at the top of issue details and on `/issues/confirm/[id]`. Email links only open that page; answers require an authenticated POST. A NO must wait for a new repair before automatic acceptance is proposed again.
|
||||
- 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 +21,8 @@ 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_acceptance_ui.cjs`: fixture-only acceptance choices, exact YES/NO submissions, email-link safety, permissions and sign-in return links.
|
||||
- `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,63 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { authFetch, getApiBase, clearToken } from '../../../lib/auth'
|
||||
import ResolutionChoice from '../../../ui/ResolutionChoice'
|
||||
|
||||
type Issue = { id: number; kind: string; title: string; status: string; permissions?: { can_confirm_resolution?: boolean } }
|
||||
|
||||
export default function ConfirmIssuePage() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const router = useRouter()
|
||||
const [item, setItem] = useState<Issue | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [result, setResult] = useState('')
|
||||
const login = () => {
|
||||
clearToken()
|
||||
router.replace(`/login?next=${encodeURIComponent(`/issues/confirm/${id}`)}`)
|
||||
}
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
setLoading(true); setItem(null); setError(''); setResult('')
|
||||
const load = async () => {
|
||||
try {
|
||||
const response = await authFetch(`${getApiBase()}/portal/items/${id}`, { signal: controller.signal, cache: 'no-store' })
|
||||
if (response.status === 401) { login(); return }
|
||||
if (!response.ok) throw new Error('This issue is unavailable. Please sign in with the account that reported it.')
|
||||
const data = await response.json()
|
||||
if (data.item?.kind !== 'issue') throw new Error('This link does not belong to an issue.')
|
||||
setItem(data.item)
|
||||
} catch (err) { if (!controller.signal.aborted) setError(err instanceof Error ? err.message : 'Could not load this issue. Please try again.') }
|
||||
finally { if (!controller.signal.aborted) setLoading(false) }
|
||||
}
|
||||
void load()
|
||||
return () => controller.abort()
|
||||
// The confirmation link identifies one issue. Never submit an answer on GET.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [id])
|
||||
|
||||
const answer = async (resolved: boolean) => {
|
||||
if (busy) return
|
||||
setBusy(true); setError('')
|
||||
try {
|
||||
const response = await authFetch(`${getApiBase()}/portal/issues/${id}/resolution-response`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ resolved }),
|
||||
})
|
||||
if (response.status === 401) { login(); return }
|
||||
if (!response.ok) throw new Error('Your answer could not be saved. This issue may already have been answered. Refresh to check before trying again.')
|
||||
setResult(resolved ? 'Thanks! Your issue is now closed.' : 'Thanks for letting us know. Your issue stays open for another look.')
|
||||
} catch (err) { setError(err instanceof Error ? err.message : 'Could not save your answer. Please try again.') }
|
||||
finally { setBusy(false) }
|
||||
}
|
||||
return <main className="resolution-response-page">
|
||||
{error && <p role="alert" className="status-banner">{error}</p>}
|
||||
{loading ? <p role="status">Loading your issue…</p> : result ? <section className="resolution-choice" role="status"><h2>{result}</h2><a href="/portal/issues">Back to issues</a></section> : item ? (
|
||||
item.status === 'awaiting_confirmation' && item.permissions?.can_confirm_resolution
|
||||
? <ResolutionChoice title={item.title} busy={busy} onAnswer={(value) => void answer(value)} />
|
||||
: <section className="resolution-choice"><h2>{item.status === 'awaiting_confirmation' ? 'This question is for the person who reported the issue.' : 'No answer is needed right now.'}</h2><p>{item.title}</p><a href={`/portal/issues?item=${item.id}`}>View issue</a></section>
|
||||
) : null}
|
||||
</main>
|
||||
}
|
||||
@@ -70,7 +70,8 @@ export default function LoginPage() {
|
||||
const data = await response.json()
|
||||
if (!data?.authenticated) { setError('Could not sign in. Please try again.'); return }
|
||||
setToken('cookie')
|
||||
window.location.assign('/')
|
||||
const next = new URLSearchParams(window.location.search).get('next') || ''
|
||||
window.location.assign(/^\/issues\/confirm\/\d+$/.test(next) ? next : '/')
|
||||
} catch {
|
||||
setError('Could not reach Magent. Check your connection and try again.')
|
||||
} finally { setLoading(false) }
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
'use client'
|
||||
import ResolutionChoice from '../ui/ResolutionChoice'
|
||||
|
||||
import PageHeading from '../ui/PageHeading'
|
||||
import IssueFlowStep from './IssueFlowStep'
|
||||
@@ -1501,6 +1502,13 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
||||
actions={workspace === 'issue' ? <span className="page-heading-meta">{visibleKindCount} reported {visibleKindCount === 1 ? 'issue' : 'issues'}</span> : undefined}
|
||||
/>
|
||||
|
||||
{workspace === 'issue' && items.filter((item) => item.status === 'awaiting_confirmation' && item.permissions?.can_confirm_resolution && item.created_by_username === me?.username).map((item) => (
|
||||
<section key={item.id} className="resolution-choice">
|
||||
<h2>Is it fixed?</h2><p>{item.title}</p>
|
||||
<a className="button" href={`/issues/confirm/${item.id}`}>Answer YES or NO →</a>
|
||||
</section>
|
||||
))}
|
||||
|
||||
{workspace === 'request' ? (
|
||||
<section className="portal-workspace-switch">
|
||||
<button type="button" className="is-active" disabled>
|
||||
@@ -2225,6 +2233,9 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{selectedItem.kind === 'issue' && selectedItem.status === 'awaiting_confirmation' && selectedItem.permissions?.can_confirm_resolution && (
|
||||
<ResolutionChoice title={selectedItem.title} busy={respondingResolution} onAnswer={(value) => void respondToResolution(value)} />
|
||||
)}
|
||||
<div className="user-directory-panel-header">
|
||||
<div>
|
||||
<h2>
|
||||
@@ -2294,33 +2305,6 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{selectedItem.kind === 'issue' && selectedItem.status === 'awaiting_confirmation' ? (
|
||||
<section className="issue-confirmation-card" aria-live="polite">
|
||||
<div>
|
||||
<span className="section-kicker">Resolution check</span>
|
||||
<h3>Has this issue been fixed?</h3>
|
||||
<p>
|
||||
Magent is waiting for the reporter to confirm the result.
|
||||
{(selectedItem.issue?.confirmation?.maximum_attempts ?? 0) > 0
|
||||
? ` ${selectedItem.issue?.confirmation?.attempts_sent ?? 0} of ${selectedItem.issue?.confirmation?.maximum_attempts ?? 0} confirmation emails have been attempted.`
|
||||
: ' Confirmation emails are disabled, so this issue will close automatically.'}
|
||||
</p>
|
||||
{selectedItem.issue?.confirmation?.next_contact_at ? (
|
||||
<small>Next reminder or automatic closure check: {formatDate(selectedItem.issue.confirmation.next_contact_at)}</small>
|
||||
) : null}
|
||||
</div>
|
||||
{selectedItem.permissions?.can_confirm_resolution ? (
|
||||
<div className="issue-confirmation-actions">
|
||||
<button type="button" disabled={respondingResolution} onClick={() => void respondToResolution(true)}>
|
||||
Yes, it is fixed
|
||||
</button>
|
||||
<button type="button" className="ghost-button" disabled={respondingResolution} onClick={() => void respondToResolution(false)}>
|
||||
No, it is still happening
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<form className="admin-form compact-form portal-form-grid" onSubmit={saveItem}>
|
||||
<label className="portal-field-span-2">
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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 && (
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
'use client'
|
||||
|
||||
import './resolution-choice.css'
|
||||
|
||||
export default function ResolutionChoice({ title, busy, onAnswer }: {
|
||||
title: string; busy: boolean; onAnswer: (resolved: boolean) => void
|
||||
}) {
|
||||
return <section className="resolution-choice" aria-labelledby="resolution-question" aria-busy={busy}>
|
||||
<span className="section-kicker">Your answer is needed</span>
|
||||
<h2 id="resolution-question">Is it fixed?</h2>
|
||||
<p>{title}</p>
|
||||
<p>Try the affected content in Grizzlyflix, then choose:</p>
|
||||
<div className="resolution-choice-buttons">
|
||||
<button id="yes" type="button" className="resolution-yes" disabled={busy} onClick={() => onAnswer(true)}><strong>YES</strong><span>It works — close this issue</span></button>
|
||||
<button id="no" type="button" className="resolution-no" disabled={busy} onClick={() => onAnswer(false)}><strong>NO</strong><span>Still broken — keep it open</span></button>
|
||||
</div>
|
||||
{busy && <p role="status">Saving your answer…</p>}
|
||||
</section>
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
.resolution-choice { padding: clamp(20px, 4vw, 36px); border: 1px solid var(--ops-border, #555); border-radius: 18px; background: var(--ops-surface, #202023); margin-bottom: 20px; }
|
||||
.resolution-choice h2 { margin: 10px 0; font-size: clamp(2rem, 5vw, 3.25rem); line-height: 1.1; }
|
||||
.resolution-choice p { line-height: 1.5; overflow-wrap: anywhere; }
|
||||
.resolution-choice-buttons { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 24px; }
|
||||
.resolution-choice-buttons button { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; min-height: 130px; padding: 20px; border-radius: 14px; border: 2px solid transparent; text-transform: none; }
|
||||
.resolution-choice-buttons button strong { font-size: 2.75rem; line-height: 1; color: inherit; }
|
||||
.resolution-choice-buttons button span { color: inherit; opacity: 1; font-size: .9rem; }
|
||||
/* Scoped overrides for the legacy global !important button palette. */
|
||||
.page .resolution-choice-buttons button.resolution-yes { background: #b4f4d2 !important; color: #10261b !important; border-color: #b4f4d2 !important; }
|
||||
.page .resolution-choice-buttons button.resolution-no { background: #ffc1c5 !important; color: #391318 !important; border-color: #ffc1c5 !important; }
|
||||
.resolution-choice-buttons button:focus-visible { outline: 3px solid var(--ops-accent, #c7baff); outline-offset: 4px; }
|
||||
.resolution-response-page { width: min(760px, 100%); margin: 20px auto; }
|
||||
@media (max-width: 520px) { .resolution-choice-buttons { grid-template-columns: 1fr; } .resolution-choice-buttons button { min-height: 104px; } }
|
||||
@@ -0,0 +1,63 @@
|
||||
// All responses are fixtures. No real emails or issue updates.
|
||||
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 status = 'awaiting_confirmation', canConfirm = true, unauthorized = false
|
||||
const answers = [], errors = []
|
||||
await context.route('**/api/**', (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 === '/api/auth/logout') return reply({ status: 'ok' })
|
||||
if (path === '/api/auth/login' || path === '/api/auth/jellyfin/login') { unauthorized = false; status = 'awaiting_confirmation'; return reply({ authenticated: true }) }
|
||||
if (path === '/api/portal/items/12') return unauthorized ? route.fulfill({ status: 401, json: { detail: 'Sign in' } }) : reply({ item: { id: 12, kind: 'issue', title: 'Picture broken: Example movie', status, permissions: { can_confirm_resolution: canConfirm } } })
|
||||
if (path.endsWith('/resolution-response')) { answers.push(route.request().postDataJSON()); return reply({}) }
|
||||
if (route.request().method() !== 'GET') throw Error('Unexpected mutation: ' + path)
|
||||
return reply({ navigation: { showRequests: true }, services: [], login: { showJellyfinLogin: true } })
|
||||
})
|
||||
const page = await context.newPage()
|
||||
page.on('pageerror', (error) => errors.push(error.message))
|
||||
for (const width of [1440, 390]) {
|
||||
await page.setViewportSize({ width, height: 1000 })
|
||||
for (const yes of [true, false]) {
|
||||
const count = answers.length
|
||||
await page.goto(base + '/issues/confirm/12#' + (yes ? 'yes' : 'no'))
|
||||
await page.reload()
|
||||
const button = page.getByRole('button', { name: yes ? 'YES It works — close this issue' : 'NO Still broken — keep it open', exact: true })
|
||||
await button.waitFor()
|
||||
assert.equal(await button.evaluate((el) => getComputedStyle(el).backgroundColor), yes ? 'rgb(180, 244, 210)' : 'rgb(255, 193, 197)')
|
||||
assert.equal(answers.length, count, 'Email GET must never submit an answer')
|
||||
const box = await button.boundingBox()
|
||||
assert.ok(box.height >= 100 && box.y + box.height <= 1000, 'Both choices should be large and immediately visible')
|
||||
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false)
|
||||
if (process.env.REVIEW_DIR) await page.screenshot({ path: `${process.env.REVIEW_DIR}/acceptance-${width}.png` })
|
||||
await button.click()
|
||||
await page.getByRole('heading', { name: yes ? 'Thanks! Your issue is now closed.' : 'Thanks for letting us know. Your issue stays open for another look.', exact: true }).waitFor()
|
||||
assert.deepEqual(answers.at(-1), { resolved: yes })
|
||||
}
|
||||
}
|
||||
canConfirm = false
|
||||
await page.goto(base + '/issues/confirm/12')
|
||||
await page.getByRole('heading', { name: 'This question is for the person who reported the issue.' }).waitFor()
|
||||
assert.equal(await page.getByRole('button', { name: /^YES/ }).count(), 0)
|
||||
canConfirm = true; status = 'closed'
|
||||
await page.goto(base + '/issues/confirm/12')
|
||||
await page.getByRole('heading', { name: 'No answer is needed right now.' }).waitFor()
|
||||
unauthorized = true
|
||||
await page.goto(base + '/issues/confirm/12')
|
||||
await page.waitForURL('**/login?next=*')
|
||||
assert.equal(new URL(page.url()).searchParams.get('next'), '/issues/confirm/12')
|
||||
await page.getByLabel('Username', { exact: true }).fill('fixture-user')
|
||||
await page.getByLabel('Password', { exact: true }).fill('fixture-password')
|
||||
await page.getByRole('button', { name: 'Sign in', exact: true }).click()
|
||||
await page.waitForURL('**/issues/confirm/12')
|
||||
await page.getByRole('button', { name: /^YES/ }).waitFor()
|
||||
assert.deepEqual(errors, [])
|
||||
console.log('PASS: prominent desktop/mobile YES/NO, exact answer POSTs, safe email GET, reporter permission, closed issue and sign-in return link; no live writes.')
|
||||
} finally { await browser.close() }
|
||||
})().catch((error) => { console.error(error); process.exitCode = 1 })
|
||||
@@ -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 })
|
||||
Reference in New Issue
Block a user