verify_quality_preview.py 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120
  1. """Check actual local preview text, navigation and desktop/mobile layout in Edge."""
  2. from pathlib import Path
  3. import base64
  4. import functools
  5. import http.server
  6. import json
  7. import subprocess
  8. import tempfile
  9. import threading
  10. import time
  11. from urllib.parse import quote, urlparse
  12. from urllib.request import urlopen
  13. import websocket
  14. WORK = Path(__file__).resolve().parent
  15. OUT = WORK / 'browser'
  16. OUT.mkdir(exist_ok=True)
  17. source = json.loads((WORK / 'probe-outputs.json').read_text(encoding='utf-8'))
  18. class Handler(http.server.SimpleHTTPRequestHandler):
  19. def log_message(self, *args):
  20. pass
  21. def list_directory(self, path):
  22. self.send_error(404)
  23. return None
  24. server = http.server.ThreadingHTTPServer(('127.0.0.1', 0), functools.partial(Handler, directory=str(WORK)))
  25. threading.Thread(target=server.serve_forever, daemon=True).start()
  26. base = 'http://127.0.0.1:' + str(server.server_port)
  27. profile = Path(tempfile.mkdtemp(prefix='ksf-quality-browser-'))
  28. edge = 'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe'
  29. proc = subprocess.Popen([edge, '--headless=new', '--disable-gpu', '--remote-debugging-port=0', '--disable-background-networking', '--disable-component-update', '--disable-sync', '--disable-extensions', '--no-first-run', '--no-default-browser-check', '--user-data-dir=' + str(profile), 'about:blank'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, creationflags=subprocess.CREATE_NO_WINDOW)
  30. seq = 0
  31. ws = None
  32. errors = []
  33. requests = []
  34. checks = []
  35. try:
  36. deadline = time.monotonic() + 20
  37. while not (profile / 'DevToolsActivePort').exists():
  38. assert time.monotonic() < deadline and proc.poll() is None
  39. time.sleep(.1)
  40. port = (profile / 'DevToolsActivePort').read_text().splitlines()[0]
  41. page = next(p for p in json.load(urlopen('http://127.0.0.1:' + port + '/json/list', timeout=5)) if p['type'] == 'page')
  42. ws = websocket.create_connection(page['webSocketDebuggerUrl'], timeout=20, suppress_origin=True, http_no_proxy=['127.0.0.1', 'localhost'])
  43. def call(method, params=None):
  44. global seq
  45. seq += 1
  46. rid = seq
  47. ws.send(json.dumps({'id': rid, 'method': method, 'params': params or {}}))
  48. while True:
  49. msg = json.loads(ws.recv())
  50. if msg.get('id') == rid:
  51. assert 'error' not in msg, msg
  52. return msg.get('result', {})
  53. if msg.get('method') == 'Runtime.exceptionThrown':
  54. errors.append(msg['params'])
  55. if msg.get('method') == 'Network.requestWillBeSent':
  56. requests.append(msg['params']['request']['url'])
  57. def ev(code, promise=False):
  58. r = call('Runtime.evaluate', {'expression': code, 'returnByValue': True, 'awaitPromise': promise})
  59. assert 'exceptionDetails' not in r, r
  60. return r['result'].get('value')
  61. def settle():
  62. ev('new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r)))', True)
  63. call('Page.enable')
  64. call('Runtime.enable')
  65. call('Network.enable')
  66. call('Network.setBlockedURLs', {'urls': ['https://*']})
  67. call('Page.navigate', {'url': base + '/' + quote('分析与成果样张.html')})
  68. ev('new Promise((resolve,reject)=>{const end=Date.now()+15000;const tick=()=>{if(document.querySelectorAll("[data-select]").length===5){resolve(true);return;}if(Date.now()>end)reject(new Error("preview timeout"));else setTimeout(tick,60)};tick()})', True)
  69. for width, height, name in [(1440, 1000, 'desktop'), (390, 844, 'mobile'), (360, 800, 'narrow')]:
  70. call('Emulation.setDeviceMetricsOverride', {'width': width, 'height': height, 'deviceScaleFactor': 1, 'mobile': width < 500})
  71. settle()
  72. for i, sample in enumerate(source['samples']):
  73. ev('document.querySelector(' + json.dumps(f'[data-select="{i}"]') + ').click()')
  74. for mode in ('reply', 'report'):
  75. ev('document.querySelector(' + json.dumps(f'[data-mode="{mode}"]') + ').click()')
  76. settle()
  77. result = ev('({text:document.querySelector("#content").innerText,width:innerWidth,scroll:document.documentElement.scrollWidth,minFont:Math.min(...[...document.querySelectorAll("#content p,#content td,#content th")].filter(e=>e.getClientRects().length).map(e=>parseFloat(getComputedStyle(e).fontSize))),reportHidden:document.querySelector("#report").hidden,selected:document.querySelector("[aria-current=true]").dataset.select})')
  78. assert result['scroll'] <= width and result['minFont'] >= 15, (name, sample['id'], result)
  79. assert int(result['selected']) == i and result['reportHidden'] == (mode == 'reply')
  80. for part in ('title', 'answer', 'meaning', 'evidence'):
  81. assert sample[part] in result['text'], (sample['id'], part)
  82. if sample['next']:
  83. assert sample['next'] in result['text'], sample['id']
  84. if mode == 'report':
  85. for cell in sample['columns'] + [c for row in sample['rows'] for c in row]:
  86. assert cell in result['text'], (sample['id'], cell)
  87. scrollable = ev('(()=>{let e=document.querySelector(".table-wrap");if(!e)return null;let before=e.scrollLeft;e.scrollLeft=99999;let changed=e.scrollLeft>before;e.scrollLeft=0;return {needed:e.scrollWidth>e.clientWidth,changed};})()')
  88. if width < 500 and scrollable:
  89. assert scrollable['needed'] and scrollable['changed'], scrollable
  90. checks.append({k:v for k,v in result.items() if k != 'text'} | {'sample':sample['id'],'viewport':name,'mode':mode,'fullContentMatches':True})
  91. if (name, sample['id'], mode) in [('desktop','rd','report'),('desktop','trend','report'),('mobile','consumer','reply'),('mobile','revision','report')]:
  92. ev('scrollTo(0,document.querySelector("#content").getBoundingClientRect().top+scrollY-12)')
  93. settle()
  94. shot = call('Page.captureScreenshot', {'format':'png','captureBeyondViewport':False})
  95. (OUT / f'{name}-{sample["id"]}-{mode}.png').write_bytes(base64.b64decode(shot['data']))
  96. ev('scrollTo(0,0)')
  97. assert not errors, errors
  98. external = [u for u in requests if not u.startswith(base + '/') and not u.startswith('data:')]
  99. assert not external, external
  100. receipt = {'schema':'ksf.decision-quality-browser.v1','ok':True,'scope':'Local standalone preview only; not target Agent, production board or semantic quality validation','checks':checks,'renderChecks':len(checks),'samples':len(source['samples']),'viewportWidths':[1440,390,360],'minimumMainTextFont':15,'bodyHorizontalOverflow':False,'exceptions':errors,'externalPageRequests':external,'screenshotsManuallyReviewed':False}
  101. (WORK / 'browser-checks.json').write_text(json.dumps(receipt,ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
  102. print(json.dumps({k:v for k,v in receipt.items() if k != 'checks'},ensure_ascii=False,indent=2))
  103. finally:
  104. if ws:
  105. ws.close()
  106. proc.terminate()
  107. proc.wait(timeout=10)
  108. server.shutdown()
  109. server.server_close()