verify-workshop-local-browser.py 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115
  1. """Exercise the standalone samples and current report component using loopback only."""
  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-local-acceptance-20261109'
  16. OUT=WORK/'browser'
  17. OUT.mkdir(exist_ok=True)
  18. class Handler(http.server.SimpleHTTPRequestHandler):
  19. def log_message(self,*args): pass
  20. def do_GET(self):
  21. p=urlparse(self.path).path
  22. if not p.startswith(('/src/','/lib/','/docs/workshop-skill-local-acceptance-20261109/')):
  23. self.send_error(404);return
  24. super().do_GET()
  25. def list_directory(self,path): self.send_error(404);return None
  26. server=http.server.ThreadingHTTPServer(('127.0.0.1',0),functools.partial(Handler,directory=str(ROOT)))
  27. thread=threading.Thread(target=server.serve_forever,daemon=True);thread.start()
  28. base='http://127.0.0.1:'+str(server.server_port)
  29. profile=Path(tempfile.mkdtemp(prefix='ksf-local-browser-'))
  30. edge=Path('C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe')
  31. proc=subprocess.Popen([str(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)
  32. seq=0;ws=None;errors=[];network=[]
  33. try:
  34. deadline=time.monotonic()+20
  35. while not (profile/'DevToolsActivePort').exists():
  36. assert time.monotonic()<deadline and proc.poll() is None
  37. time.sleep(.1)
  38. port=(profile/'DevToolsActivePort').read_text().splitlines()[0]
  39. page=next(x for x in json.load(urlopen('http://127.0.0.1:'+port+'/json/list',timeout=5)) if x['type']=='page')
  40. ws=websocket.create_connection(page['webSocketDebuggerUrl'],timeout=30,suppress_origin=True,http_no_proxy=['127.0.0.1','localhost'])
  41. def call(method,params=None):
  42. global seq
  43. seq+=1;rid=seq;ws.send(json.dumps({'id':rid,'method':method,'params':params or {}}))
  44. while True:
  45. msg=json.loads(ws.recv())
  46. if msg.get('id')==rid:
  47. assert 'error' not in msg,msg
  48. return msg.get('result',{})
  49. if msg.get('method')=='Runtime.exceptionThrown':errors.append(msg['params'])
  50. if msg.get('method')=='Network.requestWillBeSent':network.append(msg['params']['request']['url'])
  51. def ev(code,await_promise=False):
  52. r=call('Runtime.evaluate',{'expression':code,'returnByValue':True,'awaitPromise':await_promise})
  53. assert 'exceptionDetails' not in r,r
  54. return r['result'].get('value')
  55. def wait(code):
  56. 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("local page timeout"));else setTimeout(tick,70)};tick()})',True)
  57. def settle():return ev('new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r)))',True)
  58. def shot(name):
  59. (OUT/(name+'.png')).write_bytes(base64.b64decode(call('Page.captureScreenshot',{'format':'png','captureBeyondViewport':False})['data']))
  60. call('Page.enable');call('Runtime.enable');call('Network.enable');call('Network.setBlockedURLs',{'urls':['https://*']})
  61. preview=base+'/docs/workshop-skill-local-acceptance-20261109/'+quote('工作坊成果与回复样张.html')
  62. call('Page.navigate',{'url':preview});wait('document.querySelectorAll("#steps button").length===13')
  63. preview_checks=[];report_checks=[]
  64. for width,height,name in [(1440,1000,'desktop'),(390,844,'mobile')]:
  65. call('Emulation.setDeviceMetricsOverride',{'width':width,'height':height,'deviceScaleFactor':1,'mobile':width<500});settle()
  66. for index in range(13):
  67. if width<500:
  68. ev(f'(()=>{{const s=document.querySelector("#mobile-stage");s.value="{index}";s.dispatchEvent(new Event("change",{{bubbles:true}}));}})()')
  69. else:
  70. ev(f'document.querySelectorAll("#steps button")[{index}].click()')
  71. settle()
  72. reply=ev('({mode:document.querySelector("#reply-mode").getAttribute("aria-pressed"),finding:document.querySelector(".finding p").innerText,tableRows:document.querySelectorAll("#report-body tbody tr").length})')
  73. assert reply['mode']=='true' and reply['finding'] and reply['tableRows']>0,reply
  74. ev('document.querySelector("#report-mode").click()');settle()
  75. r=ev('({id:OUT[selected].id,width:innerWidth,scroll:document.documentElement.scrollWidth,sections:document.querySelectorAll("#report-body .report-section").length,expected:OUT[selected].sections.length,minBodyFont:Math.min(...[...document.querySelectorAll("#report-body td,#report-body p")].map(e=>parseFloat(getComputedStyle(e).fontSize))),tables:document.querySelectorAll("#report-body table").length})')
  76. assert r['scroll']<=width and r['sections']==r['expected'] and r['minBodyFont']>=14,r
  77. preview_checks.append({'viewport':name,**r})
  78. if index in (0,10):ev('scrollTo(0,document.querySelector("#sample").getBoundingClientRect().top+scrollY-12)');settle();shot('preview-'+name+'-'+r['id'])
  79. for target in ['real','checks']:
  80. ev(f'document.querySelector("#tab-{target}").click()');settle()
  81. assert ev('document.documentElement.scrollWidth')<=width
  82. assert ev(f'!document.querySelector("#{target}").hidden')
  83. if target=='real':ev('scrollTo(0,0)');settle();shot('real-rewrite-'+name)
  84. ev('document.querySelector("#tab-samples").click()')
  85. # Exercise the existing application report component with the local public-board projection.
  86. call('Page.navigate',{'url':base+'/docs/workshop-skill-local-acceptance-20261109/report-render-harness.html'})
  87. wait('window.localReady===true')
  88. samples=json.loads((WORK/'sample-outputs.json').read_text(encoding='utf-8'))['outputs']
  89. for width,height,name in [(1440,1000,'desktop'),(390,844,'mobile')]:
  90. call('Emulation.setDeviceMetricsOverride',{'width':width,'height':height,'deviceScaleFactor':1,'mobile':width<500});settle()
  91. for m in samples:
  92. ev('window.localSet('+json.dumps(m['artifactId'])+')',True);settle()
  93. 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):"");}const r=v.shadowRoot;return {text:walk(r),tables:r.querySelectorAll("table").length,sections:r.querySelectorAll("article.ch").length,width:innerWidth,scroll:document.documentElement.scrollWidth};})()')
  94. for s in m['sections']:
  95. assert s['title'] in result['text'],(name,m['id'],s['title'],result['text'][:600])
  96. if s['kind']=='table':
  97. for row in s['data']['rows']:
  98. for cell in row:assert cell in result['text'],(name,m['id'],cell)
  99. else:assert s['text'] in result['text'],(name,m['id'],s['title'])
  100. assert result['scroll']<=width,(name,m['id'],result['scroll'])
  101. report_checks.append({'viewport':name,'moduleId':m['id'],'tables':result['tables'],'chapters':result['sections'],'width':width,'scrollWidth':result['scroll'],'allSectionContentRendered':True})
  102. if m['id'] in ('D1-01','D2-06'):ev('scrollTo(0,0)');settle();shot('current-report-'+name+'-'+m['id'])
  103. external=[u for u in network if u.startswith(('http://','https://')) and not u.startswith(base+'/')]
  104. assert not external,external
  105. assert not errors,errors
  106. result={'ok':True,'previewModuleViewportChecks':len(preview_checks),'currentRendererModuleViewportChecks':len(report_checks),'preview':preview_checks,'currentRenderer':report_checks,'tabsAndReplyReportSwitchesVerified':True,'mobileWidth':390,'desktopWidth':1440,'allPreviewBodyTextAtLeast14px':True,'externalRequests':external,'browserExceptions':errors,'scope':'loopback static files and local in-memory board data; no production requests or writes'}
  107. (WORK/'browser-acceptance.json').write_text(json.dumps(result,ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
  108. print(json.dumps({k:result[k] for k in ['ok','previewModuleViewportChecks','currentRendererModuleViewportChecks','externalRequests','browserExceptions']},ensure_ascii=False))
  109. finally:
  110. if ws:ws.close()
  111. proc.terminate()
  112. try:proc.wait(timeout=10)
  113. except subprocess.TimeoutExpired:proc.kill()
  114. server.shutdown();server.server_close()