Fix episode position logging/resume: NaN savePos, clobber-on-boot, unvalidated history writes
- app.js: pause/pagehide listeners passed the Event into savePos(floor), making pos NaN -> serialized as null -> every pause/unload clobbered the saved position. Wrap in arrows; pagehide now posts keepalive so the final save survives page unload - app.js: play() re-fetches history on a miss (boot fetch swallows errors) and only bumps an existing row instead of posting pos:0 at start-of-play - app.py: POST /api/history requires slug_id (was stored as "None"), validates/clamps pos; upsert keeps the old payload when the incoming pos is null so stale clients can't corrupt a row - tests: +3 API tests (slug required, pos clamping, null-pos keeps saved), +1 end-to-end browser test (resume ~400s with a failed boot fetch); lockout test now uses a throwaway username so it can't lock out users the browser tests log in with; hardened the flaky autoplay test
This commit is contained in:
1 parent
7444e29592
commit
d90b1af8d6
4 files changed
+156
-23
No files matched your search
+74
-9
@@ -31,7 +31,7 @@ async def _login(page, user, pw):
|
||||
# "element stable" actionability check hangs non-deterministically;
|
||||
# fill() leaves focus on the field, so a bare keypress submits.
|
||||
await page.keyboard.press("Enter")
|
||||
await page.wait_for_selector("#q", timeout=15000)
|
||||
await page.wait_for_selector("#q", timeout=30000) # 30s: full-suite load slows the home render
|
||||
await page.wait_for_function(
|
||||
f"document.getElementById('me').textContent.includes('{user}')",
|
||||
timeout=5000, polling=POLL)
|
||||
@@ -73,7 +73,7 @@ async def _login_ui():
|
||||
|
||||
|
||||
async def _login_settled(page):
|
||||
await page.wait_for_selector("#q", timeout=15000)
|
||||
await page.wait_for_selector("#q", timeout=30000) # 30s: full-suite load slows the home render
|
||||
await page.wait_for_function(
|
||||
"document.getElementById('me').textContent.length > 0", timeout=5000, polling=POLL)
|
||||
|
||||
@@ -333,6 +333,62 @@ async def _episode_watch_state():
|
||||
|
||||
|
||||
|
||||
def test_resume_saved_position():
|
||||
asyncio.run(_resume_saved_position())
|
||||
|
||||
|
||||
async def _resume_saved_position():
|
||||
# end-to-end resume: seed a saved position, abort the first
|
||||
# /api/history fetch (a failed boot fetch leaves history = []), and the
|
||||
# page must resume near the saved time — not restart at 0. POSTs are
|
||||
# aborted too, so the dying page's pagehide save can't clobber the seed.
|
||||
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, "bob", "bobpw12345")
|
||||
await page.goto(BASE + "/?anime=chainsaw-man-922")
|
||||
await page.wait_for_selector("#eps .ep", timeout=30000)
|
||||
e1_id = await page.evaluate("document.querySelector('#eps .ep').dataset.ep")
|
||||
# seed exactly the way savePos writes (same session, same fields)
|
||||
status = await page.evaluate(
|
||||
"(id) => fetch('api/history', {method: 'POST'," \
|
||||
" headers: {'Content-Type': 'application/json', 'X-Aniweb': '1'}," \
|
||||
" body: JSON.stringify({title: detail.name, slug_id: detail.slug_id,"
|
||||
" season_id: seasonId, ep_id: +id, lang: 'jpn', ep: 1,"
|
||||
" pos: 400, dur: 1500})}).then(r => r.status)",
|
||||
e1_id)
|
||||
assert status == 200
|
||||
|
||||
state = {"aborted": 0}
|
||||
async def hist_route(route):
|
||||
if route.request.method == "POST":
|
||||
await route.abort()
|
||||
return
|
||||
if state["aborted"] == 0: # the reloaded page's boot fetch
|
||||
state["aborted"] += 1
|
||||
await route.abort()
|
||||
return
|
||||
await route.continue_()
|
||||
await page.route("**/api/history*", hist_route)
|
||||
|
||||
await page.reload()
|
||||
# resumes near 400 (keyframe-aligned), not at 0
|
||||
await page.wait_for_function(
|
||||
"(document.querySelector('video')?.currentTime || 0) > 350",
|
||||
timeout=120000, polling=250)
|
||||
got = await page.evaluate("fetch('api/history?all=1').then(r => r.json())")
|
||||
row = [x for x in got if x["slug_id"] == "chainsaw-man-922"
|
||||
and x["ep_id"] == int(e1_id)]
|
||||
# the row still holds the seeded position (a boot-write of
|
||||
# pos: saved?.pos || 0 used to clobber it when the fetch failed)
|
||||
assert row and row[0]["pos"] == 400, row
|
||||
assert not errors, errors
|
||||
await browser.close()
|
||||
|
||||
|
||||
def test_autoplay_blocked_shows_tap_to_play():
|
||||
asyncio.run(_autoplay_blocked())
|
||||
|
||||
@@ -364,15 +420,24 @@ async def _autoplay_blocked():
|
||||
await _login(page, "admin", "adminpw12345")
|
||||
await page.goto(BASE + "/?anime=chainsaw-man-922")
|
||||
await page.wait_for_selector("#eps .ep", timeout=30000)
|
||||
# blocked autoplay: the dead "Loading…" must become a tappable overlay
|
||||
# blocked autoplay: the dead "Loading…" must become a tappable overlay.
|
||||
# The fake block only rejects the FIRST play(); the app's 3s safety
|
||||
# net then retriggers doPlay unblocked, so "already playing" is also a
|
||||
# valid end state. 60s: under full-suite load the CDN can be slow.
|
||||
await page.wait_for_function(
|
||||
"document.querySelector('.pl-overlay') && "
|
||||
"document.querySelector('.pl-overlay').textContent.includes('Tap to play')",
|
||||
timeout=15000, polling=POLL)
|
||||
# tapping (the gesture) starts real playback
|
||||
await page.click(".pl-overlay .btn", force=True)
|
||||
"(document.querySelector('video')?.currentTime || 0) > 0.5 || "
|
||||
"!!(document.querySelector('.pl-overlay .btn') && "
|
||||
" document.querySelector('.pl-overlay').textContent.includes('Tap to play'))",
|
||||
timeout=60000, polling=POLL)
|
||||
# tapping (the gesture) starts real playback; if buffering swapped the
|
||||
# overlay away in the meantime the video is already playing
|
||||
if await page.query_selector(".pl-overlay .btn"):
|
||||
try:
|
||||
await page.click(".pl-overlay .btn", force=True, timeout=10000)
|
||||
except Exception:
|
||||
pass # a lost race fails loudly at the final wait below
|
||||
await page.wait_for_function(
|
||||
"document.querySelector('video').currentTime > 0.5",
|
||||
"(document.querySelector('video')?.currentTime || 0) > 0.5",
|
||||
timeout=120000, polling=250)
|
||||
assert not errors, errors
|
||||
await browser.close()
|
||||
Reference in new issue
Block a user