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

209 lines
9.1 KiB
Markdown

# 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)
```bash
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.
```python
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`:
```python
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.
```js
() => {
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).
```js
() => {
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)
```bash
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
```js
() => {
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)
```js
(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.
```js
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`:
```python
# (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)
```bash
# 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
```js
() => 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)
```js
() => {
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).