File size: 11,427 Bytes
84aa36a c6bca85 84aa36a c6bca85 84aa36a c6bca85 84aa36a 202850b 84aa36a 227ab63 84aa36a 202850b 84aa36a 202850b 84aa36a 202850b 84aa36a 227ab63 84aa36a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 | """Opt-in, read-only browser benchmark for pinned samples and random Reader opens."""
import argparse
import json
import statistics
import time
from datetime import datetime, timezone
from pathlib import Path
from urllib.parse import urlencode
from playwright.sync_api import sync_playwright
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--base-url', required=True)
parser.add_argument('--path-prefix', default='')
parser.add_argument('--samples', type=Path, required=True,
help='JSON array of {name, params}: pinned Reader query parameters')
parser.add_argument('--repeat', type=int, default=2)
parser.add_argument('--random-attempts', type=int, default=0)
parser.add_argument('--timeout', type=float, default=45)
parser.add_argument('--browser', choices=['chromium', 'firefox'], default='chromium')
parser.add_argument('--output', type=Path, required=True)
parser.add_argument('--max-ready-seconds', type=float,
help='Optional explicit acceptance budget; failures always exit nonzero')
args = parser.parse_args()
if args.repeat < 1 or args.timeout <= 0 or args.random_attempts < 0:
parser.error('repeat/timeout must be positive and random-attempts nonnegative')
samples = json.loads(args.samples.read_text())
if not isinstance(samples, list) or any(not isinstance(item, dict) or not isinstance(item.get('params'), dict) or not item.get('name') for item in samples):
parser.error('samples must be an array of named Reader parameter objects')
base = args.base_url.rstrip('/')
prefix = args.path_prefix.rstrip('/')
report = {'started_at': datetime.now(timezone.utc).isoformat(), 'base_url': base,
'path_prefix': prefix, 'samples': samples, 'timeout_seconds': args.timeout,
'cache_policy': 'default HTTP cache, active Service Worker, no routed requests',
'results': []}
rows = report['results']
def save():
args.output.write_text(json.dumps(report, ensure_ascii=False, indent=2))
with sync_playwright() as playwright:
browser = getattr(playwright, args.browser).launch(headless=True, args=['--no-sandbox'] if args.browser == 'chromium' else [])
report['browser_engine'] = args.browser
report['browser'] = browser.version
context = browser.new_context(viewport={'width': 390, 'height': 844})
context.add_init_script('''window.__readerStages=[];
new MutationObserver(()=>{
const item={at:performance.now(),phase:document.documentElement.dataset.readerPhase,
status:document.querySelector('#status')?.textContent};
const previous=__readerStages.at(-1);
if(!previous||previous.phase!==item.phase||previous.status!==item.status) __readerStages.push(item);
}).observe(document,{subtree:true,childList:true,attributes:true,characterData:true});''')
page = context.new_page()
page_errors = []
page.on('pageerror', lambda error: page_errors.append(str(error)))
page.set_default_timeout(args.timeout * 1000)
requests = {}
cdp = context.new_cdp_session(page) if args.browser == 'chromium' else None
if cdp:
cdp.send('Network.enable')
def sent(event):
headers = event['request'].get('headers', {})
requests[event['requestId']] = {'url': event['request']['url'], 'started': event['timestamp'],
'method': event['request']['method'],
'range': next((value for name, value in headers.items() if name.lower() == 'range'), None)}
def received(event):
row = requests.get(event['requestId'])
if row is not None:
response = event['response']
row.update(status=response['status'], headers_ms=round((event['timestamp']-row['started'])*1000, 2),
server_timing={k:v for k,v in response.get('headers', {}).items() if k.lower()=='server-timing'},
transport_timing=response.get('timing'), from_sw=response.get('fromServiceWorker', False),
protocol=response.get('protocol'), connection_id=response.get('connectionId'),
connection_reused=response.get('connectionReused'), remote_address=response.get('remoteIPAddress'),
from_disk_cache=response.get('fromDiskCache', False))
def data_received(event):
row = requests.get(event['requestId'])
if row is not None:
at = round((event['timestamp'] - row['started']) * 1000, 2)
row.setdefault('first_data_ms', at)
if 'last_data_ms' in row:
row['max_data_gap_ms'] = max(row.get('max_data_gap_ms', 0), round(at - row['last_data_ms'], 2))
row['last_data_ms'] = at
row['received_bytes'] = row.get('received_bytes', 0) + event.get('dataLength', 0)
def finished(event):
row = requests.get(event['requestId'])
if row is not None:
row.update(total_ms=round((event['timestamp']-row['started'])*1000, 2),
bytes=event.get('encodedDataLength'), error=event.get('errorText'))
if cdp:
cdp.on('Network.requestWillBeSent', sent)
cdp.on('Network.responseReceived', received)
cdp.on('Network.dataReceived', data_received)
cdp.on('Network.loadingFinished', finished)
cdp.on('Network.loadingFailed', finished)
def home():
page.goto(base + prefix + '/', wait_until='domcontentloaded')
page.wait_for_function('()=>typeof navigateToReader==="function" && !!navigator.serviceWorker.controller')
page.wait_for_timeout(2000)
try:
home()
selections = [('random', None, attempt) for attempt in range(args.random_attempts)]
selections += [(item['name'], item['params'], attempt) for item in samples for attempt in range(args.repeat)]
for name, params, attempt in selections:
requests.clear()
started = time.monotonic()
row = {'sample': name, 'attempt': attempt}
return_url = page.url
reset_host = False
try:
if params is None:
page.evaluate('()=>{randomTxt();}')
else:
page.evaluate('url=>navigateToReader(url)', base + prefix + '/static/reader.html?' + urlencode(params))
overlay = page.locator('iframe.reader-overlay')
overlay.wait_for()
row['iframe_seconds'] = round(time.monotonic()-started, 3)
frame = overlay.element_handle().content_frame()
frame.locator('.reader-toolbar').wait_for()
row['shell_seconds'] = round(time.monotonic()-started, 3)
frame.wait_for_function('()=>["ready","failed"].includes(document.documentElement.dataset.readerPhase)')
row['ready_seconds'] = round(time.monotonic()-started, 3)
row.update(frame.evaluate(r'''()=>({phase:document.documentElement.dataset.readerPhase,
error_code:document.querySelector('#content')?.dataset.errorCode,
reader_mode:document.querySelector('#content')?.dataset.mode,
stages:window.__readerStages,
resources:performance.getEntriesByType('resource').map(e=>e.toJSON()),
assets:performance.getEntriesByType('resource').filter(e=>/\.(js|mjs|css)(\?|$)/.test(e.name)).map(e=>e.name)})'''))
if row['phase'] != 'ready':
raise RuntimeError('Reader failed: ' + str(row.get('error_code')))
if row.get('reader_mode') in ('pdf', 'pdf-pages', 'image-pages'):
target_page = frame.locator('#page-number').input_value()
frame.locator(f'.reader-page[data-page="{target_page}"] img.ready, '
f'.reader-page[data-page="{target_page}"] canvas.ready').wait_for()
row['first_surface_seconds'] = round(time.monotonic()-started, 3)
if row['reader_mode'] in ('audio', 'video'):
frame.wait_for_function('()=>document.querySelector("audio,video")?.readyState>=1')
row['media_metadata_seconds'] = round(time.monotonic()-started, 3)
row['success'] = True
frame.locator('#back').click()
overlay.wait_for(state='detached', timeout=5000)
page.wait_for_function('url=>location.pathname===new URL(url).pathname', arg=return_url, timeout=5000)
except Exception as error:
row.update(success=False, error=str(error), elapsed_seconds=round(time.monotonic()-started, 3))
reset_host = True
row['network'] = list(requests.values())
rows.append(row)
print(json.dumps({k:v for k,v in row.items() if k not in ('network','stages','assets','resources')}, ensure_ascii=False), flush=True)
save()
if reset_host:
# Closing only the iframe leaves an overlay history entry that
# a later Back can reopen. Reset this benchmark's failed trial.
page.evaluate('''url=>{
closeReaderOverlay();
sessionStorage.removeItem('reader-navigation-current');
history.replaceState(null,'',url);
}''', base + prefix + '/')
home()
page.wait_for_timeout(500)
except Exception as error:
row = {'sample': 'search-host', 'success': False, 'error': str(error),
'page_errors': page_errors, 'network': list(requests.values())}
rows.append(row)
print(json.dumps(row), flush=True)
finally:
context.close()
browser.close()
save()
report['summary'] = {}
for name in {row['sample'] for row in rows}:
selected = [row for row in rows if row['sample'] == name]
times = [max(row['ready_seconds'], row.get('first_surface_seconds', 0), row.get('media_metadata_seconds', 0))
for row in selected if row.get('success')]
report['summary'][name] = {'runs': len(selected), 'failures': sum(not row.get('success') for row in selected),
'median_usable_seconds': statistics.median(times) if times else None,
'max_usable_seconds': max(times) if times else None}
save()
print(json.dumps(report['summary'], ensure_ascii=False), flush=True)
return int(any(not row.get('success') or args.max_ready_seconds is not None and
max(row.get('ready_seconds', float('inf')), row.get('media_metadata_seconds', 0)) > args.max_ready_seconds for row in rows))
if __name__ == '__main__':
raise SystemExit(main())
|