Files

164 lines
8.5 KiB
Python

"""Render and smoke-check the delivered local HTML using a temporary headless browser."""
from pathlib import Path
import base64
import json
import shutil
import subprocess
import tempfile
import time
import urllib.request
import websocket
ASSETS = Path(__file__).resolve().parent
ROOT = ASSETS.parent
PAGE = ROOT / 'Week7_Stata代码详解.html'
browser = Path(r'C:\Program Files\Google\Chrome\Application\chrome.exe')
profile = Path(tempfile.mkdtemp(prefix='browser_profile_', dir=ASSETS)).resolve()
stderr = (ASSETS / 'browser_stderr.log').open('w', encoding='utf-8')
process = subprocess.Popen([
str(browser), '--headless=new', '--disable-gpu', '--no-sandbox', '--disable-gpu-sandbox', '--no-first-run',
'--no-default-browser-check', '--disable-extensions', '--disable-sync',
'--disable-background-networking', '--disable-component-update',
'--remote-debugging-port=0', '--user-data-dir=' + str(profile), 'about:blank'
], stdout=subprocess.DEVNULL, stderr=stderr, creationflags=subprocess.CREATE_NO_WINDOW)
socket = None
errors = []
events = []
seq = 0
def command(method, params=None):
global seq
seq += 1
message_id = seq
socket.send(json.dumps({'id': message_id, 'method': method, 'params': params or {}}))
while True:
response = json.loads(socket.recv())
if response.get('method') == 'Runtime.exceptionThrown':
errors.append(response['params'])
if response.get('method'):
events.append(response)
if response.get('id') == message_id:
if 'error' in response:
raise RuntimeError(response['error'])
return response.get('result', {})
def evaluate(expression):
response = command('Runtime.evaluate', {'expression': expression, 'returnByValue': True, 'awaitPromise': True})
if 'exceptionDetails' in response:
raise RuntimeError(response['exceptionDetails'])
return response.get('result', {}).get('value')
def settle():
evaluate('new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))')
def screenshot(name):
settle()
result = command('Page.captureScreenshot', {'format': 'png', 'captureBeyondViewport': False})
(ASSETS / name).write_bytes(base64.b64decode(result['data']))
try:
port_file = profile / 'DevToolsActivePort'
deadline = time.monotonic() + 25
while not port_file.exists() and time.monotonic() < deadline:
if process.poll() is not None:
raise RuntimeError('Browser exited before opening DevTools')
time.sleep(.1)
port = port_file.read_text().splitlines()[0]
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
with opener.open(f'http://127.0.0.1:{port}/json/list', timeout=5) as response:
targets = json.load(response)
target = next(item for item in targets if item['type'] == 'page')
socket = websocket.create_connection(target['webSocketDebuggerUrl'], timeout=20, suppress_origin=True)
command('Page.enable')
command('Runtime.enable')
command('Page.addScriptToEvaluateOnNewDocument', {'source': 'window.matchMediaOriginal=window.matchMedia;'})
command('Emulation.setDeviceMetricsOverride', {'width': 1440, 'height': 1000, 'deviceScaleFactor': 1, 'mobile': False})
command('Page.navigate', {'url': PAGE.as_uri()})
deadline = time.monotonic() + 20
while time.monotonic() < deadline:
if evaluate('document.readyState') == 'complete':
break
time.sleep(.1)
evaluate('document.fonts.ready.then(()=>true)')
evaluate("document.documentElement.style.scrollBehavior='auto'; true")
checks = evaluate('''(()=>({
title:document.title,
sections:document.querySelectorAll('main section').length,
codeBlocks:document.querySelectorAll('.code-block').length,
anchorsValid:[...document.querySelectorAll('a[href^="#"]')].every(a=>document.getElementById(a.hash.slice(1))),
imageLoaded:[...document.images].every(i=>i.complete && i.naturalWidth>0),
viewport:innerWidth, documentWidth:document.documentElement.scrollWidth,
externalResources:performance.getEntriesByType('resource').filter(r=>/^https?:/.test(r.name)).length,
codeTextMatches:[...document.querySelectorAll('.code-block')].every(b=>[...b.querySelectorAll('.src')].map(s=>s.textContent).join('\\n')===new TextDecoder().decode(Uint8Array.from(atob(b.dataset.source),c=>c.charCodeAt(0))))
}))()''')
assert checks['sections'] == 20
assert checks['codeBlocks'] == 17
assert checks['anchorsValid'] and checks['imageLoaded'] and checks['codeTextMatches']
assert checks['externalResources'] == 0
assert checks['documentWidth'] <= checks['viewport']
screenshot('preview_desktop.png')
evaluate("document.getElementById('audit').scrollIntoView(); true")
screenshot('preview_code.png')
evaluate("document.querySelector('#plot figure').scrollIntoView(); true")
screenshot('preview_figure.png')
# Exercise the print event without launching a print UI.
evaluate("window.__printClicked=false; window.print=()=>{window.__printClicked=true};document.getElementById('print-btn').click();true")
assert evaluate('window.__printClicked') is True
# Clipboard is mocked to verify payload; browser permission itself is not asserted.
evaluate("window.__copiedText=null;Object.defineProperty(navigator,'clipboard',{configurable:true,value:{writeText:async text=>{window.__copiedText=text}}});document.querySelector('.copy-btn').click();true")
settle()
copied = evaluate('window.__copiedText')
assert copied == '\n'.join((ROOT / 'week7_analysis.do').read_text(encoding='utf-8-sig').splitlines()[:8])
evaluate("document.getElementById('copy-status').textContent='';true")
command('Emulation.setDeviceMetricsOverride', {'width': 390, 'height': 844, 'deviceScaleFactor': 1, 'mobile': True})
evaluate('scrollTo({top:0,behavior:"instant"});true')
settle()
mobile = evaluate('({viewport:innerWidth,documentWidth:document.documentElement.scrollWidth,mobileNav:getComputedStyle(document.querySelector(".mobile-nav")).display,sidebar:getComputedStyle(document.querySelector(".sidebar")).display})')
assert mobile['documentWidth'] <= mobile['viewport']
assert mobile['mobileNav'] != 'none' and mobile['sidebar'] == 'none'
screenshot('preview_mobile.png')
evaluate('document.querySelector(".mobile-nav summary").click();true')
assert evaluate('document.querySelector(".mobile-nav").open') is True
evaluate('''document.querySelector(".mobile-nav a[href='#reshape']").click();true''')
settle()
assert evaluate('document.querySelector(".mobile-nav").open') is False
assert evaluate('location.hash') == '#reshape'
screenshot('preview_mobile_code.png')
# Check the long plot command fits in its code container at narrow width.
narrow = evaluate('''(()=>{const b=document.querySelector('#plot .code-block');return {width:b.clientWidth,scrollWidth:b.scrollWidth,viewport:innerWidth,documentWidth:document.documentElement.scrollWidth}})()''')
assert narrow['scrollWidth'] <= narrow['width'] + 1
assert narrow['documentWidth'] <= narrow['viewport']
# Emulate print CSS to check navigation and action buttons disappear.
command('Emulation.setEmulatedMedia', {'media': 'print'})
assert evaluate('getComputedStyle(document.querySelector(".back-top")).display') == 'none'
command('Emulation.setEmulatedMedia', {'media': ''})
assert not errors, errors
result = {'status': 'passed', 'desktop': checks, 'mobile': mobile, 'long_code_mobile': narrow,
'runtime_exceptions': errors, 'copy_payload_verified_with_mock_clipboard': True,
'print_action_verified_with_mock_handler': True, 'mobile_navigation_verified': True,
'print_css_verified': True,
'screenshots': ['preview_desktop.png', 'preview_code.png', 'preview_figure.png', 'preview_mobile.png', 'preview_mobile_code.png']}
(ASSETS / 'browser_verification.json').write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding='utf-8')
print(json.dumps(result, ensure_ascii=True, indent=2))
finally:
if socket:
try:
command('Browser.close')
except Exception:
pass
socket.close()
try:
process.wait(timeout=8)
except subprocess.TimeoutExpired:
process.terminate()
process.wait(timeout=5)
stderr.close()
# Delete only the unique profile created by this script, inside its own assets directory.
assert profile.parent == ASSETS.resolve() and profile.name.startswith('browser_profile_')
shutil.rmtree(profile, ignore_errors=True)