#!/usr/bin/env python3 """Turning the phone keeps the same video on screen. Run against a locally running backend: backend/main.py & .venv/bin/python tests/smoke_rotate.py Both the status and the listing are fixtures, so the reel is long enough to skip forward in and the test never waits on a real source. A rotation is not one event: the browser fires `resize` several times as its chrome and the viewport settle, and the scroll-snap container re-anchors on pixel positions measured against the old height somewhere in the middle of that. The test replays exactly that -- a second `resize` and then a stale scroll position -- and asks that the reel still be on the video it was on. """ import json import os import re import sys from playwright.sync_api import sync_playwright # A real (tiny, navy, silent) clip: the feed drops a video whose source fails, # and a reel that empties itself has no scroll positions to get wrong. CLIP = os.path.join(os.path.dirname(__file__), "fixtures", "clip.mp4") BASE = "http://127.0.0.1:5000/" SERVER = "https://fixture.test" CHANNEL = "clips" PORTRAIT = {"width": 430, "height": 930} LANDSCAPE = {"width": 930, "height": 430} STATUS = { "online": True, "channels": [{ "id": CHANNEL, "name": "Fixture Clips", "description": "A channel that only exists for this test.", "options": [], }], } SEED = """([server, channel]) => { localStorage.setItem('config', JSON.stringify({ servers: [{ [server]: {} }] })); localStorage.setItem('preferences', JSON.stringify({ [server]: { channelId: channel } })); localStorage.removeItem('session'); }""" WHERE = """() => { const scroller = document.getElementById('feed-scroll'); const active = document.querySelector('.feed-slide.is-active'); const h = scroller.clientHeight; return { index: App.state.feedActiveIndex, videoId: App.state.feedActiveVideoId, scrollTop: scroller.scrollTop, expected: App.state.feedActiveIndex * h, height: h, // Where the active slide actually sits: the one thing the reader sees. activeTop: active ? Math.round(active.getBoundingClientRect().top) : null, activeId: active ? active.dataset.step : null, // Every rendered slide must be exactly a scroller tall, or // `index * clientHeight` stops addressing the slide it names. offHeight: Array.from(document.querySelectorAll('.feed-slide')) .map((s) => s.getBoundingClientRect().height) .filter((sh) => Math.abs(sh - h) > 1) }; }""" # A rotation as the browser actually delivers it: another resize while things # are still moving, then the container's own re-anchor on a position measured # against the height from before. ROTATION_NOISE = """(oldHeight) => new Promise((resolve) => { const scroller = document.getElementById('feed-scroll'); const index = App.state.feedActiveIndex; setTimeout(() => window.dispatchEvent(new Event('resize')), 60); setTimeout(() => { scroller.scrollTop = index * oldHeight; resolve(index * oldHeight); }, 140); })""" GO = """(step) => { const scroller = document.getElementById('feed-scroll'); scroller.scrollTop = step * scroller.clientHeight; return App.state.feedActiveIndex; }""" class Checks: def __init__(self): self.failed = 0 def ok(self, label, condition, detail=""): mark = "PASS" if condition else "FAIL" if not condition: self.failed += 1 print(f" [{mark}] {label}" + (f" -- {detail}" if detail and not condition else "")) def swipe_to(page, step): """One step at a time, the way a thumb gets there: only the slides around the active one exist, so the scroll range doesn't reach step 7 in one go.""" for n in range(1, step + 1): page.evaluate(GO, n) page.wait_for_timeout(250) page.wait_for_timeout(400) def items(): return [{"id": f"vid-{i}", "title": f"clip {i}", "url": f"https://fixture.test/v{i}.mp4", "thumb": "", "channel": CHANNEL, "duration": 30, "isLive": False, "tags": []} for i in range(36)] def rotate(page, c, size, label): before = page.evaluate(WHERE) page.set_viewport_size(size) stale = page.evaluate(ROTATION_NOISE, before["height"]) page.wait_for_timeout(1400) after = page.evaluate(WHERE) print(f" (was step {before['index']} at {before['scrollTop']}px of {before['height']};" f" the stale re-anchor asked for {stale}px)") c.ok(f"{label}: the same video is still the one playing", after["videoId"] == before["videoId"], f"{before['videoId']} -> {after['videoId']}") c.ok(f"{label}: and the same step", after["index"] == before["index"], f"{before['index']} -> {after['index']}") c.ok(f"{label}: the scroll position is the one that step sits at", abs(after["scrollTop"] - after["expected"]) <= 1, str(after)) c.ok(f"{label}: so the step fills the screen rather than straddling two", after["activeTop"] == 0, str(after["activeTop"])) c.ok(f"{label}: every slide is exactly a screenful", after["offHeight"] == [], str(after["offHeight"])) return after def main(): c = Checks() with sync_playwright() as p: browser = p.chromium.launch(args=[ "--no-sandbox", "--disable-dev-shm-usage", "--disable-gpu", "--autoplay-policy=no-user-gesture-required", "--renderer-process-limit=1", "--js-flags=--max-old-space-size=512", ]) page = browser.new_page(viewport=PORTRAIT) crashes = [] page.on("pageerror", lambda e: crashes.append(str(e))) page.route("**/api/status", lambda route: route.fulfill( status=200, content_type="application/json", body=json.dumps(STATUS))) page.route("**/api/videos", lambda route: route.fulfill( status=200, content_type="application/json", body=json.dumps({"items": items(), "pageInfo": {"hasNextPage": False}}))) page.route(re.compile(r"\.mp4"), lambda route: route.fulfill( status=200, content_type="video/mp4", body=open(CLIP, "rb").read())) page.goto(BASE, wait_until="domcontentloaded") page.evaluate(SEED, [SERVER, CHANNEL]) page.goto(BASE, wait_until="load") page.wait_for_selector(".video-card", timeout=30000) page.wait_for_timeout(500) page.evaluate("() => App.feed.open()") page.wait_for_selector(".feed-slide", timeout=20000) page.wait_for_timeout(800) print("\nseven videos into the reel") swipe_to(page, 7) start = page.evaluate(WHERE) c.ok("that is where the reel is", start["index"] == 7, str(start)) c.ok("and the slides are a screenful each to begin with", start["offHeight"] == [], str(start["offHeight"])) print("\nturning it sideways") land = rotate(page, c, LANDSCAPE, "landscape") c.ok("the slides took the new height", land["height"] == 430, str(land["height"])) print("\nand back upright") port = rotate(page, c, PORTRAIT, "portrait") c.ok("the slides took the height back", port["height"] == 930, str(port["height"])) print("\nswiping still works once it has settled") page.evaluate(GO, port["index"] + 1) page.wait_for_timeout(700) moved = page.evaluate(WHERE) c.ok("a real swipe after a rotation moves the reel on", moved["index"] == port["index"] + 1, str(moved["index"])) print("\nrotating twice in a row, as a wrist does") page.set_viewport_size(LANDSCAPE) page.wait_for_timeout(90) page.set_viewport_size(PORTRAIT) page.wait_for_timeout(1500) twice = page.evaluate(WHERE) c.ok("the reel is where it was left", twice["index"] == moved["index"] and twice["videoId"] == moved["videoId"], str(twice)) c.ok("and sitting on its snap point", abs(twice["scrollTop"] - twice["expected"]) <= 1, str(twice)) c.ok("nothing threw along the way", not crashes, str(crashes[:2])) browser.close() print(f"\n{'OK' if not c.failed else 'FAILED'}: {c.failed} check(s) failed") return 1 if c.failed else 0 sys.exit(main())