#!/usr/bin/env python3 """Mobile Dashboard scroll/layout and appearance browser regression. Uses the shipped static web assets in a Chromium touch-device emulation, not physical Safari, the authenticated server or a running Rust backend. """ from pathlib import Path import json import shutil from bs4 import BeautifulSoup from playwright.sync_api import sync_playwright ROOT = Path(__file__).resolve().parents[2] WEB = ROOT / 'src/admin/web' soup = BeautifulSoup((WEB / 'index.html').read_text(), 'html.parser') widgets = soup.select('#dashboard-overview-grid > [data-dashboard-widget]') assert len(widgets) == 62 widget_ids = [widget['data-dashboard-widget'] for widget in widgets] css = (WEB / 'style.css').read_text() source = (WEB / 'app/preferences.js').read_text() layout_code = source.split('function applyDashboardLayout(order, hidden) {')[1].split('function dashboardWidgetLabel(')[0] html = ('' '
Mobile navigation
' '
' '
' + ''.join(map(str, widgets)) + '
' '
Vhost
' '
Entity
' 'AUTO → H2
' '') with sync_playwright() as browser_tool: chromium = browser_tool.chromium.launch(headless=True, executable_path=shutil.which('chromium') or shutil.which('chromium-browser'), args=['--no-sandbox']) context = chromium.new_context(viewport={'width': 393, 'height': 844}, device_scale_factor=3, is_mobile=True, has_touch=True) page = context.new_page() page.set_content(html, wait_until='domcontentloaded') page.add_script_tag(content=(WEB / 'app/overview-masonry.js').read_text()) preference_fixture = ( 'const ADMIN_DASHBOARD_WIDGETS=' + json.dumps(widget_ids) + ';' + 'const ADMIN_DEFAULT_HIDDEN_WIDGETS=[];' + 'const latestStatus=null; const currentPanel="overview";' + 'const normalizeDashboardWidgets=(ids)=>ids;' + 'const normalizeHiddenDashboardWidgets=(ids)=>ids;' + 'function applyDashboardLayout(order, hidden) {' + layout_code ) page.add_script_tag(content=preference_fixture) for width in (320, 375, 393, 402, 430, 550): page.set_viewport_size({'width': width, 'height': 844}) snapshot = page.evaluate('''() => { const grid=document.getElementById('dashboard-overview-grid'); const cards=[...grid.querySelectorAll('[data-dashboard-widget].stat')] .filter(node => !node.hidden).slice(0,6).map(node => { const r=node.getBoundingClientRect(); return {left:r.left,top:r.top,right:r.right,bottom:r.bottom,width:r.width}; }); return {columns:getComputedStyle(grid).gridTemplateColumns.split(' ').length, cards,gridWidth:grid.clientWidth,gridScroll:grid.scrollWidth, screenScroll:document.documentElement.scrollWidth, screen:innerWidth, touch:getComputedStyle(grid.querySelector('.stat')).touchAction}; }''') a,b,c,d,e,f = snapshot['cards'] assert snapshot['columns'] == 2, (width, snapshot) assert abs(a['top']-b['top']) < 1 and abs(c['top']-d['top']) < 1 assert abs(e['top']-f['top']) < 1 and c['top'] > a['top'] and e['top'] > c['top'] assert b['left'] >= a['right'] and d['left'] >= c['right'] assert a['width'] > 50 and b['width'] > 50 assert snapshot['gridScroll'] <= snapshot['gridWidth']+2, (width,snapshot) assert snapshot['screenScroll'] <= snapshot['screen']+2, (width,snapshot) assert 'pan-y' in snapshot['touch'] action = page.evaluate('''() => { const tile=document.querySelector('.stat-with-action'); const label=tile.querySelector(':scope > span').getBoundingClientRect(); const button=tile.querySelector('.stat-corner-action').getBoundingClientRect(); const value=tile.querySelector('strong').getBoundingClientRect(); const card=tile.getBoundingClientRect(); return {labelBottom:label.bottom,buttonTop:button.top, buttonBottom:button.bottom,valueTop:value.top, buttonRight:button.right,cardRight:card.right}; }''') assert action['buttonTop'] >= action['labelBottom'] - 2, (width,action) assert action['valueTop'] >= action['buttonBottom'] - 2, (width,action) assert action['buttonRight'] <= action['cardRight'] + 1, (width,action) page.evaluate('window.scrollTo(0,350)') before = page.evaluate('window.scrollY') assert before > 0, width page.evaluate('''() => { const grid=document.getElementById('dashboard-overview-grid'); window.rustProxyMasonry.refresh(); for(const [index,node] of [...grid.querySelectorAll('.stat strong')].entries()) { node.textContent=index%2 ? '26216' : '49,750'; } }''') after = page.evaluate('window.scrollY') assert abs(after - before) <= 2, (width, before, after) # Reapplying an unchanged preference must NOT reparent the grid children. moves = page.evaluate('''() => { const grid=document.getElementById('dashboard-overview-grid'); const order=[...grid.children].map(n=>n.dataset.dashboardWidget); let moves=0; const observer=new MutationObserver(items => { moves+=items.filter(i=>i.type==='childList').length; }); observer.observe(grid,{childList:true}); applyDashboardLayout(order,[]); observer.takeRecords().forEach(i=>{if(i.type==='childList')moves++}); observer.disconnect(); return moves; }''') assert moves == 0, (width, moves) assert abs(page.evaluate('window.scrollY')-before) <= 2 print(f'PASS: {width}px touch emulation, two metric columns, no overflow or scroll jumps') for style,surface,control in (('square','0px','0px'), ('subtle','5px','4px'), ('rounded','12px','8px'), ('extra','18px','12px')): page.evaluate('(s) => document.documentElement.dataset.cornerStyle=s', style) for selector in ('#vhost-test','#entity-test','.stat'): radius = page.locator(selector).first.evaluate('node=>getComputedStyle(node).borderTopLeftRadius') assert radius == surface, (style,selector,radius) radius = page.locator('#badge-test').evaluate('node=>getComputedStyle(node).borderTopLeftRadius') assert radius == control, (style,'origin protocol badge',radius) print('PASS: vhost, entity, badge/control and metric corner presets') chromium.close()