verify-workshop-retest-browser.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210
  1. """Check independent case replies, full reports and current renderer on loopback."""
  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. ROOT = Path(__file__).resolve().parents[1]
  15. WORK = ROOT / 'docs/workshop-skill-retest-20261109-r2'
  16. OUT = WORK / 'browser'
  17. OUT.mkdir(exist_ok=True)
  18. class Handler(http.server.SimpleHTTPRequestHandler):
  19. def log_message(self, *args):
  20. pass
  21. def do_GET(self):
  22. if not urlparse(self.path).path.startswith(('/src/', '/lib/', '/docs/workshop-skill-retest-20261109-r2/')):
  23. self.send_error(404)
  24. return
  25. super().do_GET()
  26. def list_directory(self, path):
  27. self.send_error(404)
  28. return None
  29. server = http.server.ThreadingHTTPServer(('127.0.0.1', 0), functools.partial(Handler, directory=str(ROOT)))
  30. threading.Thread(target=server.serve_forever, daemon=True).start()
  31. base = 'http://127.0.0.1:' + str(server.server_port)
  32. profile = Path(tempfile.mkdtemp(prefix='ksf-retest-browser-'))
  33. edge = 'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe'
  34. 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)
  35. seq = 0
  36. ws = None
  37. errors = []
  38. requests = []
  39. try:
  40. deadline = time.monotonic() + 20
  41. while not (profile / 'DevToolsActivePort').exists():
  42. assert time.monotonic() < deadline and proc.poll() is None
  43. time.sleep(.1)
  44. port = (profile / 'DevToolsActivePort').read_text().splitlines()[0]
  45. page = next(p for p in json.load(urlopen('http://127.0.0.1:' + port + '/json/list', timeout=5)) if p['type'] == 'page')
  46. ws = websocket.create_connection(page['webSocketDebuggerUrl'], timeout=30, suppress_origin=True, http_no_proxy=['127.0.0.1', 'localhost'])
  47. def call(method, params=None):
  48. global seq
  49. seq += 1
  50. rid = seq
  51. ws.send(json.dumps({'id': rid, 'method': method, 'params': params or {}}))
  52. while True:
  53. msg = json.loads(ws.recv())
  54. if msg.get('id') == rid:
  55. assert 'error' not in msg, msg
  56. return msg.get('result', {})
  57. if msg.get('method') == 'Runtime.exceptionThrown':
  58. errors.append(msg['params'])
  59. if msg.get('method') == 'Network.requestWillBeSent':
  60. requests.append(msg['params']['request']['url'])
  61. def ev(code, promise=False):
  62. r = call('Runtime.evaluate', {'expression': code, 'returnByValue': True, 'awaitPromise': promise})
  63. assert 'exceptionDetails' not in r, r
  64. return r['result'].get('value')
  65. def wait(code):
  66. return ev('new Promise((resolve,reject)=>{const end=Date.now()+18000;const tick=()=>{try{if(' + code + '){resolve(true);return;}}catch{}if(Date.now()>end)reject(new Error("retest page timeout"));else setTimeout(tick,70)};tick()})', True)
  67. def settle():
  68. return ev('new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r)))', True)
  69. def shot(name):
  70. (OUT / (name + '.png')).write_bytes(base64.b64decode(call('Page.captureScreenshot', {'format': 'png', 'captureBeyondViewport': False})['data']))
  71. call('Page.enable')
  72. call('Runtime.enable')
  73. call('Network.enable')
  74. call('Network.setBlockedURLs', {'urls': ['https://*']})
  75. call('Page.navigate', {'url': base + '/docs/workshop-skill-retest-20261109-r2/' + quote('独立案例复测.html')})
  76. wait('document.querySelectorAll("#steps button").length===13')
  77. preview_checks = []
  78. report_checks = []
  79. viewports = [(1440, 1000, 'desktop'), (390, 844, 'mobile'), (360, 800, 'narrow')]
  80. for width, height, name in viewports:
  81. call('Emulation.setDeviceMetricsOverride', {'width': width, 'height': height, 'deviceScaleFactor': 1, 'mobile': width < 500})
  82. settle()
  83. for index in range(13):
  84. if width < 500:
  85. ev(f'(()=>{{const s=document.querySelector("#mobile-stage");s.value="{index}";s.dispatchEvent(new Event("change",{{bubbles:true}}));}})()')
  86. else:
  87. ev(f'document.querySelectorAll("#steps button")[{index}].click()')
  88. settle()
  89. if index == 0:
  90. ev('scrollTo(0,0)')
  91. settle()
  92. first_screen = ev('({leadTop:document.querySelector(".lead").getBoundingClientRect().top,height:innerHeight})')
  93. assert first_screen['leadTop'] < height - 80, (name, first_screen)
  94. shot('initial-' + name)
  95. reply = ev('({text:document.querySelector("#report-body").innerText,bodyFont:Math.min(...[...document.querySelectorAll("#report-body p,#report-body li,#report-body td")].map(e=>parseFloat(getComputedStyle(e).fontSize))),scroll:document.documentElement.scrollWidth,mode:document.querySelector("#reply-mode").getAttribute("aria-pressed")})')
  96. assert len(reply['text']) > 60 and reply['bodyFont'] >= 15 and reply['scroll'] <= width and reply['mode'] == 'true', (index, reply)
  97. if index in (0, 10):
  98. ev('scrollTo(0,document.querySelector("#sample").getBoundingClientRect().top+scrollY-12)')
  99. settle()
  100. shot('reply-' + name + '-' + str(index))
  101. ev('document.querySelector("#report-mode").click()')
  102. settle()
  103. r = ev('({id:OUT[selected].id,scroll:document.documentElement.scrollWidth,width:innerWidth,sections:document.querySelectorAll(".report-section").length,expected:OUT[selected].sections.length,allBodyFonts:Math.min(...[...document.querySelectorAll("#report-body p,#report-body td")].map(e=>parseFloat(getComputedStyle(e).fontSize))),text:document.querySelector("#report-body").innerText})')
  104. assert r['scroll'] <= width and r['sections'] == r['expected'] and r['allBodyFonts'] >= 15, r
  105. source = ev('OUT[selected].sections')
  106. for s in source:
  107. assert s['title'] in r['text'], (r['id'], s['title'])
  108. if s['kind'] == 'table':
  109. for row in s['data']['rows']:
  110. for cell in row:
  111. assert cell in r['text'], (r['id'], cell)
  112. else:
  113. assert s['text'] in r['text'], (r['id'], s['title'])
  114. supplemental = ev('OUT[selected].supplementalReports||[]')
  115. for sup in supplemental:
  116. ev('document.querySelector("details[data-supplement="+CSS.escape(' + json.dumps(sup['id']) + ')+"]").open=true')
  117. text = ev('document.querySelector("details[data-supplement="+CSS.escape(' + json.dumps(sup['id']) + ')+"]").innerText')
  118. for s in sup['sections']:
  119. assert s['title'] in text, (sup['id'], s['title'])
  120. if s['kind'] == 'table':
  121. for row in s['data']['rows']:
  122. for cell in row:
  123. assert cell in text, (sup['id'], cell)
  124. else:
  125. assert s['text'] in text, (sup['id'], s['title'])
  126. assert ev('document.documentElement.scrollWidth') <= width
  127. preview_checks.append({k: v for k, v in r.items() if k != 'text'} | {'viewport': name, 'replyReadable': True})
  128. if index in (6, 10):
  129. ev('scrollTo(0,document.querySelector("#sample").getBoundingClientRect().top+scrollY-12)')
  130. settle()
  131. shot('full-' + name + '-' + r['id'])
  132. for target in ['candidates', 'checks']:
  133. ev(f'document.querySelector("#tab-{target}").click()')
  134. settle()
  135. assert ev(f'!document.querySelector("#{target}").hidden&&document.documentElement.scrollWidth<=innerWidth')
  136. if target == 'candidates':
  137. candidates = ev('DATA.samples.candidates')
  138. for c in candidates:
  139. ev('document.querySelector("details[data-candidate="+CSS.escape(' + json.dumps(c['key']) + ')+"]").open=true')
  140. text = ev('document.querySelector("details[data-candidate="+CSS.escape(' + json.dumps(c['key']) + ')+"]").innerText')
  141. for s in c['reportSections']:
  142. assert s['title'] in text, (c['key'], s['title'])
  143. if s['kind'] == 'table':
  144. for row in s['data']['rows']:
  145. for cell in row:
  146. assert cell in text, (c['key'], cell)
  147. else:
  148. assert s['text'] in text, (c['key'], s['title'])
  149. ev('document.querySelector("details[data-candidate="+CSS.escape(' + json.dumps(c['key']) + ')+"]").open=false')
  150. svg = ev('(()=>{const s=document.querySelector("#costchart svg"),v=s.viewBox.baseVal;return [...s.querySelectorAll("text")].map(t=>{const b=t.getBBox();return {text:t.textContent,x:b.x,end:b.x+b.width,y:b.y,bottom:b.y+b.height,vw:v.width,vh:v.height};});})()')
  151. assert all(t['x'] >= 0 and t['end'] <= t['vw'] and t['y'] >= 0 and t['bottom'] <= t['vh'] for t in svg), svg
  152. ev('scrollTo(0,0)')
  153. settle()
  154. shot('candidates-' + name)
  155. ev('document.querySelector("#costchart").scrollIntoView({block:"center"})')
  156. settle()
  157. shot('cost-' + name)
  158. ev('document.querySelector("#tab-samples").click()')
  159. call('Page.navigate', {'url': base + '/docs/workshop-skill-retest-20261109-r2/report-render-harness.html'})
  160. wait('window.localReady===true')
  161. sample_data = json.loads((WORK / 'sample-outputs.json').read_text(encoding='utf-8'))
  162. outputs = sample_data['outputs'] + sample_data['supplementalOutputs'] + sample_data['cardOutputs']
  163. for width, height, name in viewports:
  164. call('Emulation.setDeviceMetricsOverride', {'width': width, 'height': height, 'deviceScaleFactor': 1, 'mobile': width < 500})
  165. for m in outputs:
  166. ev('window.localSet(' + json.dumps(m['artifactId']) + ')', True)
  167. settle()
  168. result = ev('(()=>{const v=document.querySelector("view-report");function walk(n){if(n.nodeType===3)return n.parentElement?.getClientRects().length?n.textContent:"";if(n.nodeType===1&&(n.matches("style,script,noscript,[hidden]")||getComputedStyle(n).display==="none"))return "";return [...n.childNodes].map(walk).join(" ")+(n.shadowRoot?walk(n.shadowRoot):"");}return {text:walk(v.shadowRoot),width:innerWidth,scroll:document.documentElement.scrollWidth};})()')
  169. for s in m['sections']:
  170. assert s['title'] in result['text'], (name, m['id'], s['title'])
  171. if s['kind'] == 'table':
  172. for row in s['data']['rows']:
  173. for cell in row:
  174. assert cell in result['text'], (name, m['id'], cell)
  175. else:
  176. assert s['text'] in result['text'], (name, m['id'], s['title'])
  177. assert result['scroll'] <= width, (name, m['id'], result['scroll'])
  178. report_checks.append({'viewport': name, 'moduleId': m['id'], 'allCoreContentRendered': True, 'pageOverflow': False})
  179. if m['id'] == 'D2-06':
  180. ev('scrollTo(0,0)')
  181. settle()
  182. shot('current-report-' + name)
  183. external = [u for u in requests if u.startswith(('http://', 'https://')) and not u.startswith(base + '/')]
  184. assert not external and not errors, (external, errors)
  185. receipt = {'ok': True, 'previewModuleViewportChecks': len(preview_checks), 'currentRendererModuleViewportChecks': len(report_checks), 'totalChecks': len(preview_checks) + len(report_checks), 'viewports': [v[0] for v in viewports], 'preview': preview_checks, 'currentRenderer': report_checks, 'supplementalReportsExpandedAndChecked': 3, 'candidateFullReportsExpandedAndChecked': 5, 'replyMinimumFontPx': 15, 'externalRequests': external, 'browserExceptions': errors, 'scope': 'loopback preview and current report component; no production calls'}
  186. (WORK / 'browser-acceptance.json').write_text(json.dumps(receipt, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
  187. print(json.dumps({k: receipt[k] for k in ['ok', 'totalChecks', 'viewports', 'externalRequests', 'browserExceptions']}, ensure_ascii=False))
  188. finally:
  189. if ws:
  190. ws.close()
  191. proc.terminate()
  192. try:
  193. proc.wait(timeout=10)
  194. except subprocess.TimeoutExpired:
  195. proc.kill()
  196. server.shutdown()
  197. server.server_close()