ui: loading states, player failure overlay + retry, toasts, back nav, full history page with delete
Bundle 1-4+7 from PLAN.md: skeleton loaders, player Loading/Buffering/fatal overlay with Retry, action toasts, back-to-home crumb + logo fix, ?history full-history view with per-entry delete (GET /api/history?all=1, DELETE /api/history). 14/14 tests green.
This commit is contained in:
1 parent
19e878800c
commit
2ec0f8a034
10 files changed
+414
-20
No files matched your search
+3
-1
@@ -23,7 +23,7 @@ ROOT = Path(__file__).parent.parent
|
||||
PORT = 8399
|
||||
BASE = f"http://127.0.0.1:{PORT}"
|
||||
USERS = (("admin", "adminpw12345"), ("alice", "alicepw12345"), ("bob", "bobpw12345"),
|
||||
("dave", "davepw12345"))
|
||||
("dave", "davepw12345"), ("erin", "erinpw12345"))
|
||||
|
||||
|
||||
def _wait_ready():
|
||||
@@ -74,6 +74,8 @@ def main():
|
||||
print("ok browser (playwright)")
|
||||
asyncio.run(test_browser.test_login_ui())
|
||||
asyncio.run(test_browser.test_video_plays_behind_auth())
|
||||
asyncio.run(test_browser.test_history_page_and_delete())
|
||||
asyncio.run(test_browser.test_stream_failure_retry())
|
||||
|
||||
print("ALL TESTS PASSED")
|
||||
finally:
|
||||
|
||||
@@ -115,6 +115,29 @@ def test_history_per_user(base=BASE):
|
||||
assert all(e.get("ep_id") != "111" for e in got_b), "leak: admin entry in alice's history"
|
||||
|
||||
|
||||
def test_history_all_and_delete(base=BASE):
|
||||
jar = login("alice", "alicepw12345", base)
|
||||
e1 = {"ep_id": 201, "slug_id": "del-a", "title": "Del A", "season_id": 1,
|
||||
"ep": "1", "lang": "eng", "pos": 5}
|
||||
e2 = {"ep_id": "202", "slug_id": "del-b", "title": "Del B", "season_id": 1,
|
||||
"ep": "2", "lang": "eng", "pos": 5}
|
||||
assert req("POST", "/api/history", e1, jar=jar, base=base) == 200
|
||||
assert req("POST", "/api/history", e2, jar=jar, base=base) == 200
|
||||
all_ = _json("GET", "/api/history?all=1", jar=jar, base=base)
|
||||
assert {e["slug_id"] for e in all_} >= {"del-a", "del-b"}
|
||||
# plain GET still caps at 5
|
||||
assert len(_json("GET", "/api/history", jar=jar, base=base)) <= 5
|
||||
# loose str match: the int ep_id row is deleted via a string query param
|
||||
removed = _json("DELETE", "/api/history?slug_id=del-a&ep_id=201", jar=jar, base=base)
|
||||
assert removed["removed"] >= 1, removed
|
||||
all_ = _json("GET", "/api/history?all=1", jar=jar, base=base)
|
||||
assert all(e["slug_id"] != "del-a" for e in all_)
|
||||
assert any(e["slug_id"] == "del-b" for e in all_)
|
||||
# idempotent
|
||||
assert _json("DELETE", "/api/history?slug_id=del-a&ep_id=201",
|
||||
jar=jar, base=base)["removed"] == 0
|
||||
|
||||
|
||||
def test_self_delete(base=BASE):
|
||||
jar = login("dave", "davepw12345", base)
|
||||
assert req("POST", "/account/delete", jar=jar, base=base) == 200
|
||||
|
||||
+70
-2
@@ -5,7 +5,10 @@ Covers what HTTP tests can't: the login UI, the user chip, and real
|
||||
video playback behind auth. Needs: pip install playwright && playwright install chromium
|
||||
"""
|
||||
import asyncio
|
||||
import http.cookiejar
|
||||
import json
|
||||
import sys
|
||||
import urllib.request
|
||||
|
||||
from playwright.async_api import async_playwright
|
||||
|
||||
@@ -81,8 +84,8 @@ async def test_video_plays_behind_auth():
|
||||
await _login(page, "alice", "alicepw12345")
|
||||
await page.fill("#q", "chainsaw")
|
||||
await page.keyboard.press("Enter")
|
||||
await page.wait_for_selector(".card", timeout=30000)
|
||||
await page.click(".card", force=True)
|
||||
await page.wait_for_selector("a.card", timeout=30000)
|
||||
await page.click("a.card", force=True)
|
||||
await page.wait_for_selector("video", timeout=30000)
|
||||
await page.wait_for_function(
|
||||
"document.querySelector('video').readyState >= 4 && document.querySelector('video').currentTime > 1",
|
||||
@@ -93,8 +96,73 @@ async def test_video_plays_behind_auth():
|
||||
print("ok test_video_plays_behind_auth")
|
||||
|
||||
|
||||
async def test_history_page_and_delete():
|
||||
# self-contained: seed erin's own entries over HTTP first
|
||||
jar = http.cookiejar.CookieJar()
|
||||
op = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
|
||||
op.open(urllib.request.Request(BASE + "/login",
|
||||
data=json.dumps({"username": "erin", "password": "erinpw12345"}).encode(),
|
||||
headers={"Content-Type": "application/json"}, method="POST"))
|
||||
for e in ({"title": "Hist A", "slug_id": "hist-a", "season_id": 1, "ep_id": 7,
|
||||
"lang": "eng", "ep": 7, "pos": 5},
|
||||
{"title": "Hist B", "slug_id": "hist-b", "season_id": 2, "ep_id": 9,
|
||||
"lang": "jpn", "ep": 9, "pos": 30, "dur": 600}):
|
||||
op.open(urllib.request.Request(BASE + "/api/history",
|
||||
data=json.dumps(e).encode(),
|
||||
headers={"Content-Type": "application/json"}, method="POST"))
|
||||
|
||||
async with async_playwright() as p:
|
||||
browser = await p.chromium.launch(args=LAUNCH_ARGS)
|
||||
page = await (await browser.new_context()).new_page()
|
||||
errors = []
|
||||
page.on("pageerror", lambda e: errors.append(str(e)))
|
||||
|
||||
await _login(page, "erin", "erinpw12345")
|
||||
await page.goto(BASE + "/?history")
|
||||
await page.wait_for_selector(".card.hist", timeout=15000)
|
||||
await page.wait_for_function(
|
||||
"document.querySelectorAll('.card.hist').length == 2", timeout=15000, polling=POLL)
|
||||
await page.click(".card.hist .del", force=True)
|
||||
await page.wait_for_function(
|
||||
"document.querySelectorAll('.card.hist').length == 1", timeout=15000, polling=POLL)
|
||||
await page.wait_for_selector(".toast", timeout=5000)
|
||||
assert not errors, errors
|
||||
await browser.close()
|
||||
print("ok test_history_page_and_delete")
|
||||
|
||||
|
||||
async def test_stream_failure_retry():
|
||||
async with async_playwright() as p:
|
||||
browser = await p.chromium.launch(args=LAUNCH_ARGS)
|
||||
page = await (await browser.new_context()).new_page()
|
||||
|
||||
async def abort_stream(route):
|
||||
await route.abort()
|
||||
|
||||
# note: **/stream/* (single star) — must NOT match the
|
||||
# /api/episode/{id}/stream token endpoint, only the /stream/{token} proxy
|
||||
await page.route("**/stream/*", abort_stream)
|
||||
await _login(page, "admin", "adminpw12345")
|
||||
await page.fill("#q", "chainsaw")
|
||||
await page.keyboard.press("Enter")
|
||||
await page.wait_for_selector("a.card", timeout=30000)
|
||||
await page.click("a.card", force=True)
|
||||
# detail auto-plays ep1; with /stream aborted hls.js exhausts its
|
||||
# retries and surfaces the fatal error -> failure overlay with Retry
|
||||
await page.wait_for_selector(".pl-overlay button", timeout=90000)
|
||||
await page.unroute("**/stream/*", abort_stream)
|
||||
await page.click(".pl-overlay button", force=True)
|
||||
await page.wait_for_function(
|
||||
"document.querySelector('video').readyState >= 4 && document.querySelector('video').currentTime > 1",
|
||||
timeout=120000, polling=250)
|
||||
await browser.close()
|
||||
print("ok test_stream_failure_retry")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) > 1:
|
||||
BASE = sys.argv[1]
|
||||
asyncio.run(test_login_ui())
|
||||
asyncio.run(test_video_plays_behind_auth())
|
||||
asyncio.run(test_history_page_and_delete())
|
||||
asyncio.run(test_stream_failure_retry())
|
||||
Reference in new issue
Block a user