ARRFLIX/bin/headless-test.py
s8n 9b06bb48c6 doc 26 INC2+INC3: pin backdrop, transparent sub-sections
After INC1 fixed the Abspielen + first-fold backdrop, owner reported black
band hiding artwork in More from Season 1 / below-fold sections. Two more
patches required:

INC2 — pin .backdropContainer + .backgroundContainer position:fixed; height
100vh so backdrop persists during scroll. Added vertical fade ::after.

INC3 — extend transparent-scope to ALL detail-page sub-sections
(.detailVerticalSection, .scrollSlider, .padded-bottom-page,
.itemsContainer etc) so section wrappers don't paint over the pinned
backdrop section by section.

bin/headless-test.py now takes top + scrolled viewport screenshots.
full_page=True hides position:fixed regressions, dual-screenshot exposes
them. Use both to bisect.

bin/apply-26-incident-fixes.sh updated with INC2+INC3.

Open: AV1+Opus playback (Mike Nolan Show) still tracked for 10.11.8
migration. .detailLogo regression possible — test in actual browser.
2026-05-09 01:21:01 +01:00

186 lines
8.1 KiB
Python
Executable file

#!/usr/bin/env python3
"""ARRFLIX headless smoke-test. Logs in via API, navigates to a detail page,
captures screenshot + console errors + network failures + computed-style for
backdrop. Pass dev or prod URL as argv[1]."""
import sys, json, time, os, asyncio, urllib.request, urllib.error
from playwright.async_api import async_playwright
URL = sys.argv[1] if len(sys.argv) > 1 else "https://dev.arrflix.s8n.ru"
USER = sys.argv[2] if len(sys.argv) > 2 else "guest-mirror"
PASS = sys.argv[3] if len(sys.argv) > 3 else "dev-test-guest"
ITEM = sys.argv[4] if len(sys.argv) > 4 else None # auto-pick first Series if absent
OUT = sys.argv[5] if len(sys.argv) > 5 else "/tmp/arrflix-headless"
os.makedirs(OUT, exist_ok=True)
DEVICE = "headless-test"
DEVICE_ID = "headless-test-2026-05-09"
CLIENT = "Headless"
VERSION = "1.0"
def auth_header(token=None):
h = (f'MediaBrowser Client="{CLIENT}", Device="{DEVICE}", '
f'DeviceId="{DEVICE_ID}", Version="{VERSION}"')
if token:
h += f', Token="{token}"'
return h
def api_post(path, body, token=None):
req = urllib.request.Request(
f"{URL}{path}",
data=json.dumps(body).encode(),
headers={
"Authorization": auth_header(token),
"Content-Type": "application/json",
},
method="POST",
)
ctx = __import__("ssl")._create_unverified_context()
with urllib.request.urlopen(req, context=ctx) as r:
return json.loads(r.read())
def api_get(path, token=None):
req = urllib.request.Request(
f"{URL}{path}",
headers={"Authorization": auth_header(token)},
)
ctx = __import__("ssl")._create_unverified_context()
with urllib.request.urlopen(req, context=ctx) as r:
return json.loads(r.read())
def login():
r = api_post("/Users/AuthenticateByName",
{"Username": USER, "Pw": PASS})
return r["AccessToken"], r["User"]["Id"], r["ServerId"]
async def main():
token, user_id, server_id = login()
print(f"[+] Authenticated as {USER} ({user_id})")
item_id = ITEM
if not item_id:
items = api_get(
f"/Users/{user_id}/Items?Recursive=true&IncludeItemTypes=Series&Limit=5",
token)
if items.get("Items"):
item_id = items["Items"][0]["Id"]
print(f"[+] Auto-picked Series: {items['Items'][0]['Name']} ({item_id})")
else:
print("[!] No series found, falling back to root")
console_messages = []
network_failures = []
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True,
args=["--no-sandbox", "--disable-dev-shm-usage"])
ctx = await browser.new_context(
viewport={"width": 1600, "height": 900},
ignore_https_errors=True)
page = await ctx.new_page()
page.on("console", lambda m: console_messages.append(
f"[{m.type}] {m.text}"))
page.on("requestfailed", lambda r: network_failures.append(
f"{r.method} {r.url} :: {r.failure}"))
page.on("response", lambda r: None if r.status < 400 else
network_failures.append(f"HTTP {r.status} {r.url}"))
# Auth via login form
await page.goto(f"{URL}/web/", wait_until="networkidle", timeout=30000)
await asyncio.sleep(3)
# Wait for any input rendered by SPA
try:
await page.wait_for_selector("input", timeout=20000)
inputs = await page.evaluate(
"() => Array.from(document.querySelectorAll('input')).map(i => ({id:i.id, name:i.name, type:i.type, placeholder:i.placeholder}))")
print(f"[*] inputs: {inputs}")
# Find username input by heuristic
user_sel = None
pass_sel = None
for i in inputs:
fid, fname, ftype = i.get('id',''), i.get('name',''), i.get('type','')
if not user_sel and (ftype == 'text' or 'user' in (fid+fname).lower() or 'name' in (fid+fname).lower()):
user_sel = f'#{fid}' if fid else f'input[name="{fname}"]'
if not pass_sel and ftype == 'password':
pass_sel = f'#{fid}' if fid else f'input[name="{fname}"]'
print(f"[*] user_sel={user_sel} pass_sel={pass_sel}")
if user_sel and pass_sel:
await page.fill(user_sel, USER)
await page.fill(pass_sel, PASS)
await page.keyboard.press("Enter")
await page.wait_for_load_state("networkidle", timeout=20000)
await asyncio.sleep(2)
print("[+] logged in via form")
else:
print("[!] could not locate login fields")
except Exception as e:
print(f"[!] form login failed: {e}")
# Navigate to detail page
target = (f"{URL}/web/#/details?id={item_id}&serverId={server_id}"
if item_id else f"{URL}/web/")
print(f"[*] navigating: {target}")
await page.goto(target, wait_until="networkidle", timeout=30000)
await asyncio.sleep(4) # let SPA paint backdrop
# Probe key DOM elements (extended)
probe = await page.evaluate("""() => {
const result = {};
const sel = ['.itemBackdrop', '.detailBackdrop', '.backdropContainer',
'.backgroundContainer', '.layout-desktop',
'body', '#reactRoot', '.itemDetailPage',
'video', '.htmlvideoplayer', '.btnPlay', '.detailPagePrimaryContainer',
'.detailSection', '.detailVerticalSection', '.itemsContainer',
'.padded-bottom-page', '.mainAnimatedPages', '.pageContainer',
'.cardScalable', '.scrollSlider', '.sectionTitleContainer',
'.detailPageContent', '.detailPageWrapperContainer'];
for (const s of sel) {
const els = document.querySelectorAll(s);
if (els.length === 0) { result[s] = '<absent>'; continue; }
const el = els[0];
const cs = getComputedStyle(el);
result[s] = {
count: els.length,
display: cs.display,
opacity: cs.opacity,
visibility: cs.visibility,
background: cs.backgroundColor,
backgroundImage: cs.backgroundImage.slice(0, 80),
zIndex: cs.zIndex,
rect: el.getBoundingClientRect().toJSON(),
};
}
result['__title'] = document.title;
const playBtn = document.querySelector('.btnPlay, [data-action="play"]');
result['__playBtnText'] = playBtn ? (playBtn.innerText || playBtn.textContent || '').trim() : null;
result['__bodyClasses'] = document.body.className;
result['__url'] = location.href;
return result;
}""")
# Two screenshots: top viewport + scrolled to mid-page (so fixed backdrop renders correctly)
screenshot = os.path.join(OUT, f"{URL.replace('https://','').replace('.','_')}-detail.png")
await page.screenshot(path=screenshot, full_page=False)
# Scroll halfway down to verify pinned backdrop persists
await page.evaluate("() => window.scrollTo(0, document.body.scrollHeight * 0.5)")
await asyncio.sleep(1)
scrolled = os.path.join(OUT, f"{URL.replace('https://','').replace('.','_')}-scrolled.png")
await page.screenshot(path=scrolled, full_page=False)
print(f"[+] screenshot: {screenshot}")
with open(os.path.join(OUT, "probe.json"), "w") as f:
json.dump({
"url": URL,
"user": USER,
"item": item_id,
"probe": probe,
"console": console_messages[-50:],
"network_failures": network_failures[-50:],
}, f, indent=2)
print(f"[+] probe.json: {os.path.join(OUT, 'probe.json')}")
print(f"[+] console msgs: {len(console_messages)}")
print(f"[+] network failures: {len(network_failures)}")
await browser.close()
asyncio.run(main())