legacy-arrflix/testing/HEADLESS-PROBE.md
s8n d9d6bdba64 testing/ folder: theme-edit guides + error catalog + headless recipes
7 docs in /testing/ — institutional memory after 6+ regressions in
24-48h on the v6 theme. Read before any edit.

  README.md           — index + quickstart
  THEMING.md          — safe-edit checklist + layer/specificity tables
  ERROR-PATTERNS.md   — 12 cataloged patterns (Symptom/Cause/Diag/Fix/Prev)
  HEADLESS-PROBE.md   — 11 playwright recipes (md5 chain, darkPct,
                        ancestor bg sample, dropdown listItem probe)
  ROLLBACK.md         — 8 emergency revert recipes (overlay, branding,
                        encoding, full-from-repo, dev-clone-prod,
                        git-revert, pw-reset, bind-mount inode-swap)
  SMOKE-TEST.md       — manual + headless verify checklist
  DEPLOY.md           — dev → prod promotion workflow with backup +
                        chown root + restart inode-swap

Empty subdirs: snipUSER-Es/, recipes/, incidents/ (post-mortems land here).

Goal: stop reinventing the same fixes. Catalog every error class,
codify the recovery, build a skills folder for future ARRFLIX work.
2026-05-10 00:47:20 +01:00

9.1 KiB

HEADLESS-PROBE — playwright + DOM recipes

Copy-paste these to verify any theme/playback change. All use mcr.microsoft.com/playwright/python:v1.49.0-noble with --userns=host --network container:jellyfin-dev (or jellyfin for prod).

Setup (one-time per session)

ssh user@192.168.0.100 'docker pull mcr.microsoft.com/playwright/python:v1.49.0-noble' >/dev/null
mkdir -p /tmp/arrflix-probes
# Run pattern (on nullstone):
docker run --rm --userns=host --network container:jellyfin-dev \
  -v /tmp/arrflix-probes:/out -v /tmp/probe-X.py:/probe.py:ro \
  mcr.microsoft.com/playwright/python:v1.49.0-noble python /probe.py

RECIPE 1 — auth + pre-seed credentials

Boilerplate every recipe imports. /Users/AuthenticateByName returns {AccessToken, User.Id, ServerId}. Jellyfin web reads localStorage['jellyfin_credentials'] on boot — pre-seeding via add_init_script skips login.

import asyncio, json, urllib.request
from playwright.async_api import async_playwright
URL='http://127.0.0.1:8096'
USER,PW='test','123'
def auth():
    req=urllib.request.Request(f"{URL}/Users/AuthenticateByName",
        data=json.dumps({"Username":USER,"Pw":PW}).encode(),
        headers={"Content-Type":"application/json","Authorization":'MediaBrowser Client="probe", Device="x", DeviceId="probe-1", Version="1.0"'},method="POST")
    return json.loads(urllib.request.urlopen(req,timeout=15).read())
a=auth(); token=a["AccessToken"]; uid=a["User"]["Id"]; sid=a["ServerId"]

Pre-seed creds via add_init_script:

await page.add_init_script(f"""
  localStorage.setItem('jellyfin_credentials', JSON.stringify({{Servers:[{{ManualAddress:'{URL}',Id:'{sid}',Name:'D',UserId:'{uid}',AccessToken:'{token}',DateLastAccessed:Date.now(),UserLinkType:'LinkedUser'}}]}}));
""")

If auth returns 401 + sqlite-readonly in docker logs jellyfin-dev, test password got nuked. Recovery: docker exec jellyfin-dev sqlite3 /config/data/jellyfin.db "UPDATE Users SET Password=NULL,EasyPassword=NULL WHERE Username='test'" then docker restart jellyfin-dev and POST /Users/{uid}/Password with {NewPw:"123"}.

RECIPE 2 — bg-color of every ancestor of <video>

Tests L1/L2 transparent rules. Run during playback.

() => {
  const v = document.querySelector('video.htmlvideoplayer'); if (!v) return {found:false};
  const chain=[];
  for (let el=v; el; el=el.parentElement) {
    chain.push({tag:el.tagName, cls:String(el.className).slice(0,80), id:el.id, bg:getComputedStyle(el).backgroundColor, z:getComputedStyle(el).zIndex});
  }
  return {found:true, chain};
}

Expect every ancestor rgba(0,0,0,0) except <html> = rgb(0,0,0).

RECIPE 3 — darkPct on rendered viewport

Detects "video decodes but is visually black" (doc 28 INC7).

() => {
  const v = document.querySelector('video.htmlvideoplayer'); if (!v) return null;
  const c = document.createElement('canvas'); c.width=320; c.height=180;
  const ctx = c.getContext('2d'); ctx.drawImage(v, 0, 0, 320, 180);
  const data = ctx.getImageData(0, 0, 320, 180).data;
  let dark=0, total=320*180;
  for (let i=0; i<data.length; i+=4) {
    const max = Math.max(data[i], data[i+1], data[i+2]);
    if (max < 32) dark++;
  }
  return {darkPct: dark/total, currentTime:v.currentTime, videoWidth:v.videoWidth};
}

Expect darkPct < 0.2 during playback. > 0.7 = black overlay.

RECIPE 4 — md5 chain (host → container → served)

ssh user@nullstone 'md5sum /opt/docker/jellyfin-dev/web-overrides/index-dev.html'
ssh user@nullstone 'docker exec jellyfin-dev md5sum /jellyfin/jellyfin-web/index.html'
ssh user@nullstone 'docker exec jellyfin-dev curl -s http://127.0.0.1:8096/web/index.html | md5sum'

All three must match. If host ≠ container: bind-mount inode swap (ERROR-PATTERNS#3) — docker restart jellyfin-dev.

RECIPE 5 — computed style of selector

() => {
  const el = document.querySelector('TARGET-SELECTOR-HERE');
  if (!el) return {found:false};
  const cs = getComputedStyle(el);
  const props = ['backgroundColor', 'color', 'outline', 'border', 'zIndex', 'display', 'visibility'];
  return {found:true, computed:Object.fromEntries(props.map(p=>[p, cs[p]]))};
}

RECIPE 6 — dump CSS rules matching selector token (cascade debug)

(token) => {
  const rules=[];
  for (const s of document.styleSheets) {
    try { for (const r of s.cssRules) {
      if (r.style && r.selectorText && r.selectorText.indexOf(token)>=0)
        rules.push({sel:r.selectorText.slice(0,180), css:r.style.cssText.slice(0,200), src:(s.href||'inline').slice(-80)});
    }} catch(e){ rules.push({err:String(e).slice(0,40), src:s.href}); }
  }
  return rules; // later sheets override earlier; arrflix overrides should appear last
}
// page.evaluate("(t)=>{...}", "htmlVideoPlayer")

RECIPE 7 — open dropdown, sample selected listItem

Audio/subtitle picker theme check.

async () => {
  const btn = document.querySelector('.btnAudio, .audioMenuButton'); btn?.click();
  await new Promise(r => setTimeout(r, 600));
  const sel = document.querySelector('.actionSheet .listItem.selected, .actionSheet .listItem-button.selected, .actionSheet .listItem.focused');
  if (!sel) return {found:false};
  const cs = getComputedStyle(sel);
  return {found:true, computed:{outline:cs.outline, bg:cs.backgroundColor, color:cs.color}};
}

RECIPE 8 — full playback smoke (auth + play + sample @5/10/15s)

Reuses Recipe 1 boilerplate. Save as testing/recipes/smoke-playback.py:

# (prelude from Recipe 1: auth(), token/uid/sid)
ITEM='324f75b84f394a5d9b0749c0679f23b9'
SAMPLE = """()=>{const v=document.querySelector('video.htmlvideoplayer');
  if(!v)return{hasVideo:false};
  const c=document.createElement('canvas');c.width=320;c.height=180;
  const x=c.getContext('2d');x.drawImage(v,0,0,320,180);
  const d=x.getImageData(0,0,320,180).data;let dark=0;
  for(let i=0;i<d.length;i+=4)if(Math.max(d[i],d[i+1],d[i+2])<32)dark++;
  return{hasVideo:true,t:v.currentTime,w:v.videoWidth,darkPct:dark/57600};}"""
async def main():
    async with async_playwright() as p:
        b=await p.chromium.launch(headless=True,args=["--ignore-certificate-errors","--autoplay-policy=no-user-gesture-required"])
        page=await (await b.new_context(viewport={"width":1280,"height":720})).new_page()
        await page.add_init_script(f"localStorage.setItem('jellyfin_credentials',JSON.stringify({{Servers:[{{ManualAddress:'{URL}',Id:'{sid}',Name:'D',UserId:'{uid}',AccessToken:'{token}',DateLastAccessed:Date.now(),UserLinkType:'LinkedUser'}}]}}))")
        await page.goto(f"{URL}/web/index.html#/details?id={ITEM}",wait_until="networkidle",timeout=30000)
        await page.wait_for_timeout(4000)
        try: await page.click('button.btnPlay,.mainDetailButtons .btnPlay,button[title="Play"]',timeout=5000)
        except: pass
        out=[]
        for t in (5,10,15):
            await page.wait_for_timeout(5000)
            out.append({"at_s":t, **(await page.evaluate(SAMPLE))})
        await page.screenshot(path="/out/smoke.png"); print("SMOKE",json.dumps(out,indent=1))
        await b.close()
asyncio.run(main())

Pass: all three samples hasVideo:true, t advancing, darkPct < 0.2.

RECIPE 9 — compare two overlays (visual diff)

# Nullstone: swap+render each, scp screenshots back.
for V in baseline candidate; do
  ssh user@nullstone "docker cp /tmp/$V-index.html jellyfin-dev:/jellyfin/jellyfin-web/index.html && docker restart jellyfin-dev"; sleep 8
  ssh user@nullstone "docker run --rm --userns=host --network container:jellyfin-dev -v /tmp/probe-real.py:/p.py:ro -v /tmp/arrflix-probes:/out mcr.microsoft.com/playwright/python:v1.49.0-noble python /p.py"
  scp user@nullstone:/tmp/arrflix-probes/dev-real-vid.png /tmp/$V.png
done
# Onyx: Pillow diff
python -c "from PIL import Image,ImageChops as C
a=Image.open('/tmp/baseline.png').convert('RGB');b=Image.open('/tmp/candidate.png').convert('RGB')
d=C.difference(a,b);h=d.histogram();print(f'changed={sum(h[1:256])+sum(h[257:512])+sum(h[513:768])} bbox={d.getbbox()}');d.save('/tmp/overlay-diff.png')"

RECIPE 10 — favicon shim (lockFavicon) verify

() => Array.from(document.querySelectorAll('link[rel*="icon"]')).map(l => ({
  rel:l.rel, sizes:l.sizes?.value, dataAttr:l.getAttribute('data-arrflix-icon'),
  isArrflixA:l.href.indexOf('iVBORw0KGgoAAAANSUhEUgAAAIo')>0,
  hrefHead:l.href.slice(0,80)
}))

Expect dataAttr:"1" + arrflix base64 prefix on every icon link after ~5s (poll interval).

RECIPE 11 — force arrflix-video-active (theme isolation, no real playback)

() => {
  const v=document.createElement('div'); v.className='htmlVideoPlayer';
  v.style.cssText='position:fixed;inset:0;z-index:5;'; document.body.appendChild(v);
  document.body.classList.add('arrflix-video-active');
  const els=['body','#reactRoot','.skinBody','.backgroundContainer','.mainAnimatedPages','.pageContainer','.videoPlayerContainer','.htmlVideoPlayer'];
  return Object.fromEntries(els.map(s=>{const el=document.querySelector(s); return [s, el?{bg:getComputedStyle(el).backgroundColor,z:getComputedStyle(el).zIndex}:null];}));
}

Where to store probe scripts

testing/snipUSER-Es/ — one-liners + bash. testing/recipes/ — full python (recipes 1, 8, 9 live here).