verify-unified-preview-browser.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154
  1. """Browser acceptance against real public reads and local-only group writes."""
  2. import argparse
  3. import base64
  4. import json
  5. from pathlib import Path
  6. import subprocess
  7. import tempfile
  8. import time
  9. from urllib.request import urlopen
  10. import websocket
  11. ROOT=Path(__file__).resolve().parents[1]
  12. WORK=ROOT/'work/preview-unification-20261105/browser'
  13. WORK.mkdir(parents=True,exist_ok=True)
  14. parser=argparse.ArgumentParser();parser.add_argument('--url',default='http://127.0.0.1:4181/members');args=parser.parse_args()
  15. profile=Path(tempfile.mkdtemp(prefix='browser-',dir=WORK))
  16. process=subprocess.Popen(['C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe','--headless=new','--disable-gpu',
  17. '--remote-debugging-port=0','--disable-background-networking','--disable-component-update','--disable-sync','--disable-extensions',
  18. '--no-first-run','--no-default-browser-check','--user-data-dir='+str(profile),'about:blank'],
  19. stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,creationflags=subprocess.CREATE_NO_WINDOW)
  20. serial=0;ws=None;events=[]
  21. try:
  22. deadline=time.monotonic()+20
  23. while not (profile/'DevToolsActivePort').exists():
  24. assert time.monotonic()<deadline and process.poll() is None
  25. time.sleep(.1)
  26. port=(profile/'DevToolsActivePort').read_text().splitlines()[0]
  27. page=next(x for x in json.load(urlopen('http://127.0.0.1:'+port+'/json/list',timeout=5)) if x['type']=='page')
  28. ws=websocket.create_connection(page['webSocketDebuggerUrl'],timeout=35,suppress_origin=True,http_no_proxy=['127.0.0.1','localhost'])
  29. def call(method,params=None):
  30. global serial
  31. serial+=1;rid=serial;ws.send(json.dumps(dict(id=rid,method=method,params=params or {})))
  32. while True:
  33. response=json.loads(ws.recv())
  34. if response.get('id')==rid:
  35. assert 'error' not in response,response
  36. return response.get('result',{})
  37. events.append(response)
  38. def evaluate(expression,promise=False):
  39. response=call('Runtime.evaluate',dict(expression=expression,returnByValue=True,awaitPromise=promise))
  40. assert 'exceptionDetails' not in response,response
  41. return response['result'].get('value')
  42. def wait(expression,timeout=25):
  43. evaluate('new Promise((resolve,reject)=>{const end=Date.now()+'+str(timeout*1000)+';const tick=()=>{try{if('+expression+'){resolve(true);return;}}catch{}if(Date.now()>end)reject(new Error("Timed out: "+'+json.dumps(expression)+'));else setTimeout(tick,60)};tick()})',True)
  44. def action(body):
  45. return evaluate('(()=>{const v=document.querySelector("kf-app").shadowRoot.querySelector("view-members");'+body+'})()')
  46. V='document.querySelector("kf-app")?.shadowRoot?.querySelector("view-members")'
  47. def ready():wait(V+'?._groups && !'+V+'?._loading && !'+V+'?._busy')
  48. def shot(name):
  49. image=call('Page.captureScreenshot',dict(format='png',captureBeyondViewport=False))
  50. (WORK/(name+'.png')).write_bytes(base64.b64decode(image['data']))
  51. call('Page.enable');call('Runtime.enable');call('Network.enable')
  52. call('Emulation.setDeviceMetricsOverride',dict(width=1440,height=1100,deviceScaleFactor=1,mobile=False))
  53. call('Page.navigate',dict(url=args.url));ready()
  54. A='document.querySelector("kf-app")'
  55. def app(body):return evaluate('(()=>{const a='+A+';'+body+'})()')
  56. def settle():evaluate('new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r)))',True)
  57. def go(route,tag):
  58. evaluate('import("/src/utils/router.js").then(m=>m.navigate('+json.dumps(route)+'))',True)
  59. wait(A+'?._route.path==='+json.dumps(route.split('?')[0])+' && '+A+'?.renderRoot.querySelector('+json.dumps(tag)+')')
  60. settle()
  61. def escape():
  62. call('Input.dispatchKeyEvent',dict(type='keyDown',key='Escape',code='Escape',windowsVirtualKeyCode=27))
  63. call('Input.dispatchKeyEvent',dict(type='keyUp',key='Escape',code='Escape',windowsVirtualKeyCode=27))
  64. checks=[]
  65. assert evaluate('fetch("/__dev/info").then(r=>r.json())',True)['preview']=='unified'
  66. wait(V+'?.db?.members.length>0')
  67. db=action('return {members:v.db.members.length,artifacts:v.db.artifacts.length,opportunities:v.db.opportunities.length};')
  68. assert app('return ![...a.renderRoot.querySelector("kf-nav").renderRoot.querySelectorAll("a")].some(e=>new URL(e.href).pathname==="/showcase");')
  69. checks.append('single-source-preview-with-board-only-navigation')
  70. # Real ledger stage/layer grid has four horizontal cells instead of the old stacked grid.
  71. go('/ledger','view-ledger')
  72. heat=app('const v=a.renderRoot.querySelector("view-ledger"),h=v.renderRoot.querySelector("kf-heat");return {layout:h.getAttribute("layout"),cells:h.days.length,style:getComputedStyle(v.renderRoot.querySelector(".ledger-overview")).gridTemplateColumns};')
  73. assert heat['layout']=='row' and heat['cells']==4,heat
  74. shot('unified-ledger');checks.append('ledger-horizontal-stage-layer-cells')
  75. # A genuine opportunity card opens in a centered window; report and source links round-trip.
  76. go('/pool?sort=newest','view-pool')
  77. wait(A+'.renderRoot.querySelector("view-pool").renderRoot.querySelector("kf-opportunity-card")')
  78. card=app('const c=a.renderRoot.querySelector("view-pool").renderRoot.querySelector("kf-opportunity-card");return {id:c.opp.id,report:c.opp.reportArtifact?.id};')
  79. app('a.renderRoot.querySelector("view-pool").renderRoot.querySelector("kf-opportunity-card").shadowRoot.querySelector("kf-glass-card").shadowRoot.querySelector(".frame").click();')
  80. wait(A+'._route.name==="mode" && '+A+'.renderRoot.querySelector("kf-drawer").shadowRoot.querySelector("dialog").open')
  81. centered=app('const d=a.renderRoot.querySelector("kf-drawer"),r=d.shadowRoot.querySelector("dialog").getBoundingClientRect();return {layout:d.layout,left:r.left,right:r.right,width:r.width,top:r.top};')
  82. assert centered['layout']=='center' and centered['left']>50 and centered['right']<1440,centered
  83. shot('unified-opportunity-center');checks.append('opportunity-centered')
  84. linked=app('const d=a.renderRoot.querySelector("kf-drawer");return !!d.shadowRoot.querySelector(".linked-title button.lnk");')
  85. if linked:
  86. app('a.renderRoot.querySelector("kf-drawer").shadowRoot.querySelector(".linked-title button.lnk").click();')
  87. wait(A+'._drawer.type==="artifact" && '+A+'.renderRoot.querySelector("kf-drawer").shadowRoot.querySelector("dialog").open')
  88. assert app('return a.renderRoot.querySelector("kf-drawer").layout;')=='center'
  89. shot('unified-linked-artifact');escape()
  90. wait(A+'._drawer?.type==="opp"')
  91. checks.append('linked-artifact-centered-and-return')
  92. # Report links carry an explicit return path and chapter anchors avoid the removed bar.
  93. full=app('const d=a.renderRoot.querySelector("kf-drawer"),link=d.shadowRoot.querySelector("a.report-cta");return link?link.href:null;')
  94. if full:
  95. app('a.renderRoot.querySelector("kf-drawer").shadowRoot.querySelector("a.report-cta").click();')
  96. wait(A+'._route.name==="report" && '+A+'.renderRoot.querySelector("view-report")?.renderRoot.querySelector("article.ch")')
  97. report=app('const v=a.renderRoot.querySelector("view-report");return {id:v.artifactId,ch:v.renderRoot.querySelector("article.ch").id,back:v.renderRoot.querySelector("a.report-back")?.href};')
  98. assert report['back'] and '/pool/' in report['back'],report
  99. app('a.renderRoot.querySelector("view-report").renderRoot.querySelector("[data-toc] a").click();')
  100. wait('new URL(location.href).searchParams.has("ch")');evaluate('new Promise(r=>setTimeout(r,500))',True)
  101. chapter=app('const v=a.renderRoot.querySelector("view-report"),t=v.renderRoot.querySelector("[data-toc]").getBoundingClientRect(),c=v.renderRoot.querySelector("article.ch").getBoundingClientRect();return {chapterTop:c.top,tocBottom:t.bottom};')
  102. assert chapter['chapterTop']>=chapter['tocBottom']-2,chapter
  103. shot('unified-report-chapter');call('Page.reload')
  104. wait(A+'?.renderRoot.querySelector("view-report")?.renderRoot.querySelector("article.ch")')
  105. app('a.renderRoot.querySelector("view-report").renderRoot.querySelector("a.report-back").click();')
  106. wait(A+'._route.name==="mode" && '+A+'._drawer?.type==="opp"')
  107. checks.append('report-reading-chapter-reload-and-return')
  108. escape();wait(A+'._route.name==="pool" && !'+A+'._drawer')
  109. assert evaluate('new URL(location.href).searchParams.get("sort")')=='newest'
  110. checks.append('opportunity-close-preserves-pool-filters')
  111. # Live screens and research library remain available at the same origin.
  112. for route,tag in [('/','view-workbench'),('/stages','view-stages'),('/reports','view-reports'),('/reports/category','view-report-type'),('/members','view-members'),('/library','view-library')]:
  113. go(route,tag)
  114. if route=='/members':
  115. ready();assert action('return !!v.renderRoot.querySelector("#manage-groups") && !v.renderRoot.querySelector("input[type=checkbox]");')
  116. if route=='/library':
  117. wait(A+'.renderRoot.querySelector("view-library")._data && !'+A+'.renderRoot.querySelector("view-library")._loading')
  118. assert app('return a.renderRoot.querySelector("view-library")._summary.counts.products;')==4639
  119. assert app('return !a.renderRoot.querySelector("kf-topbar").renderRoot.querySelector("h1");')
  120. shot('unified-'+tag)
  121. checks.extend(['group-modal-preserved','research-library-preserved','titlebar-removal-preserved'])
  122. # Retired introduction and sample URLs return to the actual workbench.
  123. for route in ['/showcase','/showcase/leadership','/showcase/research','/showcase/collaboration','/showcase/validation','/showcase/classroom','/cockpit','/intel','/coop','/review','/class']:
  124. evaluate('import("/src/utils/router.js").then(m=>m.navigate('+json.dumps(route)+'))',True)
  125. wait(A+'?._route.name==="workbench" && location.pathname==="/" && '+A+'.renderRoot.querySelector("view-workbench")')
  126. assert app('return !a.renderRoot.querySelector("sample-shell,view-showcase");')
  127. call('Page.navigate',dict(url=args.url.split('/members')[0]+'/showcase/research?case=A'))
  128. wait(A+'?._route.name==="workbench" && location.pathname==="/" && !location.search && '+A+'.renderRoot.querySelector("view-workbench")')
  129. call('Page.reload')
  130. wait(A+'?.renderRoot.querySelector("view-workbench")')
  131. assert not evaluate('performance.getEntriesByType("resource").some(e=>/\\/(?:assets\\/showcase|sample-shell|view-showcase)/.test(e.name))')
  132. checks.extend(['retired-demo-links-return-to-workbench','retired-demo-deep-link','workbench-reload','no-demo-resources-loaded'])
  133. # Mobile and desktop share the same route table and retained navigation.
  134. layouts=[]
  135. for width,height in [(1440,1000),(390,844),(320,740)]:
  136. call('Emulation.setDeviceMetricsOverride',dict(width=width,height=height,deviceScaleFactor=1,mobile=width<700))
  137. for route,tag in [('/members','view-members'),('/ledger','view-ledger'),('/','view-workbench'),('/library','view-library')]:
  138. go(route,tag)
  139. info=evaluate('({route:location.pathname,width:innerWidth,scrollWidth:document.documentElement.scrollWidth})')
  140. assert info['scrollWidth']<=width,info
  141. layouts.append(info)
  142. shot('unified-overview-'+str(width))
  143. failures=[e for e in events if e.get('method')=='Runtime.exceptionThrown']
  144. assert not failures,failures
  145. result=dict(ok=True,base=args.url,checks=checks,db=db,layouts=layouts,consoleExceptions=0,productionWrites=0)
  146. (WORK/'acceptance.json').write_text(json.dumps(result,ensure_ascii=False,indent=2)+'\n',encoding='utf-8');print(json.dumps(result,ensure_ascii=False,indent=2))
  147. finally:
  148. if ws:ws.close()
  149. process.terminate()
  150. try:process.wait(timeout=8)
  151. except subprocess.TimeoutExpired:process.kill()