gates / gates (push) Successful in 6m54s
Claude-Session: https://claude.ai/code/session_012qRErfCoiTkvDK9N5XHbzb
142 lines
8.3 KiB
Python
142 lines
8.3 KiB
Python
#!/usr/bin/env python3
|
|
"""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
|
|
SLUG = sys.argv[2] # calibre-web
|
|
PWFILE = sys.argv[3]
|
|
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
|
|
|
|
report = {}
|
|
for tab in ['install']:
|
|
n0 = len(console)
|
|
goto(f'{BASE}/apps/{SLUG}/{tab}')
|
|
res = ev('''(function(){
|
|
function cs(el, props){ if(!el) return null; var s=getComputedStyle(el), o={}; props.forEach(function(p){o[p]=s.getPropertyValue(p)}); return o; }
|
|
var secs=[].slice.call(document.querySelectorAll('.app-section, .deploy-form .form-section')).filter(function(e){return e.offsetParent!==null});
|
|
var col=document.querySelector('.app-page');
|
|
var t=document.querySelector('.deploy-form .form-section h4'); var first=secs[0];
|
|
var gap = (t && first) ? Math.round(t.getBoundingClientRect().top - t.closest('.app-section, .form-section').getBoundingClientRect().top) : null;
|
|
var widths = secs.map(function(e){return Math.round(e.getBoundingClientRect().width)});
|
|
var spacing = []; for (var i=1;i<secs.length;i++){ spacing.push(Math.round(secs[i].getBoundingClientRect().top - secs[i-1].getBoundingClientRect().bottom)); }
|
|
return {path: location.pathname,
|
|
activeTab: cs(document.querySelector('.app-tab-active'), ['background-color','color','border-bottom-color','border-bottom-width','padding-top','padding-left']),
|
|
tabBar: cs(document.querySelector('.app-tabs'), ['background-color','border-top-color','padding-top']),
|
|
hero: cs(document.querySelector('.app-hero'), ['background-color','border-top-color','padding-top','padding-left']),
|
|
section: cs(first, ['background-color','border-top-color','border-top-width','padding-top','padding-left','margin-bottom','border-radius']),
|
|
visibleSections: secs.length, sectionWidths: widths, columnWidth: col ? Math.round(col.getBoundingClientRect().width) : null,
|
|
sectionSpacing: spacing, titleTopGap: gap,
|
|
loginPanel: !!document.querySelector('[data-login-panel]'),
|
|
loginFields: [].map.call(document.querySelectorAll('[data-login-field]'), function(e){return e.getAttribute('data-login-field')}),
|
|
notStored: [].map.call(document.querySelectorAll('[data-login-not-stored]'), function(e){return e.getAttribute('data-login-not-stored')}),
|
|
reveals: [].map.call(document.querySelectorAll('[data-login-reveal]'), function(e){return e.getAttribute('data-login-reveal')}),
|
|
loginPairCols: (function(){var g=document.querySelector('.app-login-pair'); return g?getComputedStyle(g).gridTemplateColumns:null})(),
|
|
domainInput: !!document.querySelector('#auto-field-DOMAIN, #field-DOMAIN'), deployForm: !!document.querySelector('#deploy-form'),
|
|
logBoxWidth: (function(){var l=document.querySelector('.logs-container'); return l?Math.round(l.getBoundingClientRect().width):null})()
|
|
};})()''')
|
|
res['console'] = console[n0:]
|
|
shot = call('Page.captureScreenshot', format='png', captureBeyondViewport=True)
|
|
open(os.path.join(OUT, f'{SLUG}-{tab}.png'), 'wb').write(base64.b64decode(shot['data']))
|
|
report[tab] = res
|
|
print(json.dumps(report, indent=1, ensure_ascii=False)); raise SystemExit
|
|
call('Emulation.setDeviceMetricsOverride', width=390, height=844, deviceScaleFactor=1, mobile=True)
|
|
goto(f'{BASE}/apps/{SLUG}/settings')
|
|
report['settings_390px'] = {'loginPairCols': ev("(function(){var g=document.querySelector('.app-login-pair'); return g?getComputedStyle(g).gridTemplateColumns:null})()"),
|
|
'scrollWidth': ev('document.documentElement.scrollWidth')}
|
|
shot = call('Page.captureScreenshot', format='png', captureBeyondViewport=True)
|
|
open(os.path.join(OUT, f'{SLUG}-settings-390px.png'), 'wb').write(base64.b64decode(shot['data']))
|
|
print(json.dumps(report, indent=1, ensure_ascii=False))
|
|
finally:
|
|
proc.terminate()
|
|
subprocess.run(['rm', '-rf', prof])
|