Make verified repair acceptance prominent and simplify confirmation email
Magent CI/CD / verify (push) Canceled after 5m52s
Magent CI/CD / deploy-prod (push) Canceled after 0s
Magent CI/CD / deploy-beta (push) Canceled after 0s

This commit is contained in:
2026-09-06 22:32:21 +12:00
parent 74c49fad5b
commit a32928b1c5
9 changed files with 254 additions and 43 deletions
+26 -15
View File
@@ -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]]]: def _repair_tracking(item_id: int) -> tuple[Dict[str, Any], list[Dict[str, Any]]]:
activity = list_portal_item_activity(item_id, limit=500) activity = list_portal_item_activity(item_id, limit=500)
for entry in reversed(activity): 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: if str(entry.get("event_type") or "") not in _MEDIA_REPAIR_STARTED_EVENTS:
continue continue
tracking = _activity_metadata(entry).get("repairTracking") 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 attempt_number = attempts + 1
reporter = get_user_by_username(str(item.get("created_by_username") or "")) reporter = get_user_by_username(str(item.get("created_by_username") or ""))
recipient = resolve_user_delivery_email(reporter) 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 sent = False
delivery_error: Optional[str] = None delivery_error: Optional[str] = None
if recipient: 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 = ( body_text = (
f"We have marked issue #{item['id']} as fixed and need your confirmation.\n\n" "Your repair looks ready to test.\n\n"
f"Issue: {item.get('title') or 'Untitled issue'}\n" f"{item.get('title') or 'Your reported issue'}\n\n"
f"Confirmation request: {attempt_number} of {maximum}\n\n" "Please try the affected content in Grizzlyflix. Is it fixed?\n\n"
f"Open the issue and choose whether it is fixed or still happening:\n{issue_url}\n\n" f"YES — it works: {issue_url}#yes\n"
"If you do not respond, Magent will close the issue automatically after the configured confirmation period." 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 = ( body_html = (
'<div style="font-family:Segoe UI,Arial,sans-serif;color:#132033;">' '<div style="background:#111113;padding:24px 12px;font-family:Arial,sans-serif;color:#f4f4f5;">'
'<h2 style="margin:0 0 12px;">Is your issue fixed?</h2>' '<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;">'
f'<p style="line-height:1.6;">We have marked issue <strong>#{int(item["id"])}</strong> as fixed and need your confirmation.</p>' '<p style="margin:0 0 24px;color:#c7baff;font-weight:bold;letter-spacing:2px;">GRIZZLYFLIX · MAGENT</p>'
f'<p style="line-height:1.6;"><strong>{escape(str(item.get("title") or "Untitled issue"))}</strong><br>' '<h1 style="font-size:32px;line-height:1.2;margin:0 0 16px;color:#fff;">Ready to try again?</h1>'
f'Confirmation request {attempt_number} of {maximum}</p>' '<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'<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>' f'<p style="padding:16px;background:#131315;border-radius:10px;color:#fff;">{escape(str(item.get("title") or "Your reported issue"))}</p>'
'<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>' '<h2 style="font-size:26px;color:#fff;margin:24px 0 16px;">Is it fixed?</h2>'
'</div>' 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: try:
await send_generic_email( await send_generic_email(
+55
View File
@@ -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 &lt;movie&gt;", 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)
+2
View File
@@ -9,6 +9,7 @@
- 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. - 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. - 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. - 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. - 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. - 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.
@@ -21,6 +22,7 @@ Build the frontend before reviewing. The scripts in `scripts/` run using Node an
- `review_issue_flow_ui.cjs`: fixture-only movie/TV issue flow, multi-device report payloads, subtitle routing, permissions, and collapsed-step navigation. - `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_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_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. - `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. 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.
+63
View File
@@ -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>
}
+2 -1
View File
@@ -70,7 +70,8 @@ export default function LoginPage() {
const data = await response.json() const data = await response.json()
if (!data?.authenticated) { setError('Could not sign in. Please try again.'); return } if (!data?.authenticated) { setError('Could not sign in. Please try again.'); return }
setToken('cookie') setToken('cookie')
window.location.assign('/') const next = new URLSearchParams(window.location.search).get('next') || ''
window.location.assign(/^\/issues\/confirm\/\d+$/.test(next) ? next : '/')
} catch { } catch {
setError('Could not reach Magent. Check your connection and try again.') setError('Could not reach Magent. Check your connection and try again.')
} finally { setLoading(false) } } finally { setLoading(false) }
+11 -27
View File
@@ -1,4 +1,5 @@
'use client' 'use client'
import ResolutionChoice from '../ui/ResolutionChoice'
import PageHeading from '../ui/PageHeading' import PageHeading from '../ui/PageHeading'
import IssueFlowStep from './IssueFlowStep' 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} 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' ? ( {workspace === 'request' ? (
<section className="portal-workspace-switch"> <section className="portal-workspace-switch">
<button type="button" className="is-active" disabled> <button type="button" className="is-active" disabled>
@@ -2225,6 +2233,9 @@ export default function PortalClient({ workspace }: PortalClientProps) {
</div> </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 className="user-directory-panel-header">
<div> <div>
<h2> <h2>
@@ -2294,33 +2305,6 @@ export default function PortalClient({ workspace }: PortalClientProps) {
</div> </div>
) : null} ) : 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}> <form className="admin-form compact-form portal-form-grid" onSubmit={saveItem}>
<label className="portal-field-span-2"> <label className="portal-field-span-2">
+19
View File
@@ -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>
}
+13
View File
@@ -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; } }
+63
View File
@@ -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 })