Files
felhom.eu/documentation/audits/burndown-apps-2026-10-16/tools/cdp_burndown.py
T
admin 836d64efa3
gates / gates (push) Successful in 6m52s
Apps burndown (controller 0.321.0 released): R-1097, R-1102, R-1101, R-1095, R-1098 closed; R-1103 opened; build-hub.sh 1.25.0
build-hub.sh refuses an existing tag and an unanswered registry (test_build_hub_push_once.sh, red-proved).
Register 179 -> 175. R-1067 stays BLOCKED: Tester-2 reports 0.292.0. Design notes in 02 and 07; STATUS;
evidence audits/burndown-apps-2026-10-16/; REPORT-burndown-apps-2026-10-16.md.

Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-16 17:27:57 +02:00

139 lines
7.6 KiB
Python

#!/usr/bin/env python3
"""v0.321.0 apps-burndown browser check (Data tab, FileBrowser page and FileBrowser itself, an install address). Minimal CDP client: log in to the controller through its PUBLIC address in headless Chrome, open each
app-page tab, assert computed styles, collect console errors, save screenshots. The password is read
from a 0600 file and only ever sent inside a CDP message; it is never printed."""
import base64, json, os, socket, struct, subprocess, sys, time, urllib.request
CHROME = os.path.expanduser('~/.cache/ms-playwright/chromium_headless_shell-1243/chrome-headless-shell-linux64/chrome-headless-shell')
BASE = sys.argv[1] # https://felhom.enkisfelhom.hu
PWFILE = sys.argv[2] # the controller password (0600 file)
FBPWFILE = sys.argv[3] # FileBrowser's admin password (0600 file) — for the cold „Fajlok" click
OUT = sys.argv[4]
PORT = 9333
os.makedirs(OUT, exist_ok=True)
prof = os.path.join(OUT, '.profile')
proc = subprocess.Popen([CHROME, f'--remote-debugging-port={PORT}', '--no-sandbox', f'--user-data-dir={prof}',
'--window-size=1280,900', 'about:blank'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
try:
for _ in range(50):
try:
tabs = json.load(urllib.request.urlopen(f'http://127.0.0.1:{PORT}/json'))
break
except Exception:
time.sleep(0.2)
ws_url = [t for t in tabs if t['type'] == 'page'][0]['webSocketDebuggerUrl']
host, path = ws_url[len('ws://'):].split('/', 1)
h, p = host.split(':')
sock = socket.create_connection((h, int(p)))
key = base64.b64encode(os.urandom(16)).decode()
sock.send(f'GET /{path} HTTP/1.1\r\nHost: {host}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: {key}\r\nSec-WebSocket-Version: 13\r\n\r\n'.encode())
buf = b''
while b'\r\n\r\n' not in buf:
buf += sock.recv(4096)
st = {'pending': buf.split(b'\r\n\r\n', 1)[1]}
def send(obj):
data = json.dumps(obj).encode()
hdr = bytearray([0x81])
n = len(data)
if n < 126: hdr.append(0x80 | n)
elif n < 65536: hdr.append(0x80 | 126); hdr += struct.pack('>H', n)
else: hdr.append(0x80 | 127); hdr += struct.pack('>Q', n)
mask = os.urandom(4); hdr += mask
sock.sendall(bytes(hdr) + bytes(b ^ mask[i % 4] for i, b in enumerate(data)))
def recv_exact(n):
while len(st['pending']) < n:
st['pending'] += sock.recv(1 << 20)
out, st['pending'] = st['pending'][:n], st['pending'][n:]
return out
def recv():
msg = b''
while True:
b0, b1 = recv_exact(2)
n = b1 & 0x7f
if n == 126: n = struct.unpack('>H', recv_exact(2))[0]
elif n == 127: n = struct.unpack('>Q', recv_exact(8))[0]
msg += recv_exact(n)
if b0 & 0x80: return json.loads(msg)
console = []
mid = [0]
def call(method, **params):
mid[0] += 1; i = mid[0]
send({'id': i, 'method': method, 'params': params})
while True:
m = recv()
if m.get('method') == 'Runtime.exceptionThrown':
console.append(('exception', m['params']['exceptionDetails'].get('text', ''), m['params']['exceptionDetails'].get('exception', {}).get('description', '')[:200]))
elif m.get('method') == 'Runtime.consoleAPICalled' and m['params']['type'] in ('error', 'warning'):
console.append((m['params']['type'], ' '.join(str(a.get('value', a.get('description', ''))) for a in m['params']['args'])[:200]))
elif m.get('method') == 'Log.entryAdded' and m['params']['entry']['level'] in ('error', 'warning'):
e = m['params']['entry']; console.append(('log-' + e['level'], e.get('text', '')[:200], e.get('url', '')))
if m.get('id') == i:
if 'error' in m: raise RuntimeError(f'{method}: {m["error"]}')
return m.get('result', {})
def ev(expr):
r = call('Runtime.evaluate', expression=expr, returnByValue=True, awaitPromise=True)
return r.get('result', {}).get('value')
def goto(url):
call('Page.navigate', url=url)
for _ in range(100):
time.sleep(0.2)
if ev('document.readyState') == 'complete':
break
time.sleep(1.5) # images' onerror, layout
for d in ('Page', 'Runtime', 'Log', 'Network'):
call(d + '.enable')
pw = open(PWFILE).read().strip()
goto(BASE + '/login')
ev("(function(){var f=document.querySelector('input[type=password]'); f.value=%s; f.form.submit(); return 1})()" % json.dumps(pw))
del pw
time.sleep(3)
print('after login url:', ev('location.pathname'))
console.clear() # the login page is not under test
def shot(name):
s = call('Page.captureScreenshot', format='png', captureBeyondViewport=True)
open(os.path.join(OUT, name + '.png'), 'wb').write(base64.b64decode(s['data']))
report = {}
for p in ['/apps/calibre-web/data', '/apps/filebrowser', '/apps/calibre-web/install']:
n0 = len(console)
goto(BASE + p)
report[p] = ev("""(function(){ return {path: location.pathname,
backupSections: document.querySelectorAll('[data-backup-section]').length,
rows: [].map.call(document.querySelectorAll('[data-backup-row]'), function(e){return e.getAttribute('data-backup-row')}),
offDrivePBS: /off-drive|\\bPBS\\b/i.test(document.body.innerText),
valueFont: (function(){var v=document.querySelector('[data-backup-row] .app-field-value'); return v?getComputedStyle(v).fontFamily.split(',')[0]:null})(),
exportToggle: (function(){var b=document.getElementById('exportPwToggle'); return b?[b.textContent.trim(), b.getAttribute('aria-label')]:null})(),
fbReveal: !!document.querySelector('#initcred-reveal'), fbStale: !!document.querySelector('[data-fb-stale]'),
changeItLine: document.body.innerText.indexOf('hagyd figyelmen') >= 0,
scrollWidth: document.documentElement.scrollWidth};})()""")
report[p]['console'] = console[n0:]
shot(p.strip('/').replace('/', '-'))
# FileBrowser itself, logged in with the box's own password: no update banner, no install-app prompt.
fbpw = open(FBPWFILE).read().strip()
call('Network.clearBrowserCookies')
goto(BASE.replace('://felhom.', '://files.') + '/')
ev("""(function(pw){var u=document.querySelector('input[type=text],input[name=username],input#username');
var p=document.querySelector('input[type=password]'); if(!u||!p) return 0;
function set(el,v){var d=Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value'); d.set.call(el,v); el.dispatchEvent(new Event('input',{bubbles:true}));}
set(u,'admin'); set(p,pw); var b=document.querySelector('form button[type=submit], form input[type=submit], button.button--blue');
if(b){b.click()} else {p.form && p.form.requestSubmit && p.form.requestSubmit()} return 1})(%s)""" % json.dumps(fbpw))
del fbpw
time.sleep(5)
report['filebrowser'] = ev("""(function(){ var t=document.body.innerText;
return {path: location.pathname, updateBanner: /update is available/i.test(t), installApp: /install app/i.test(t),
releaseBanners: document.querySelectorAll('.release-banner').length,
globals: (function(){ try { var g=window.globalVars||{}; return {disablePWAInstall: g.disablePWAInstall, updateAvailable: g.updateAvailable}; } catch(e) { return null; } })()};})()""")
shot('filebrowser-sidebar')
print(json.dumps(report, indent=1, ensure_ascii=False))
finally:
proc.terminate()
subprocess.run(['rm', '-rf', prof])