| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154 |
- """Browser acceptance against real public reads and local-only group writes."""
- import argparse
- import base64
- import json
- from pathlib import Path
- import subprocess
- import tempfile
- import time
- from urllib.request import urlopen
- import websocket
- ROOT=Path(__file__).resolve().parents[1]
- WORK=ROOT/'work/preview-unification-20261105/browser'
- WORK.mkdir(parents=True,exist_ok=True)
- parser=argparse.ArgumentParser();parser.add_argument('--url',default='http://127.0.0.1:4181/members');args=parser.parse_args()
- profile=Path(tempfile.mkdtemp(prefix='browser-',dir=WORK))
- process=subprocess.Popen(['C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe','--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)
- serial=0;ws=None;events=[]
- try:
- deadline=time.monotonic()+20
- while not (profile/'DevToolsActivePort').exists():
- assert time.monotonic()<deadline and process.poll() is None
- time.sleep(.1)
- port=(profile/'DevToolsActivePort').read_text().splitlines()[0]
- page=next(x for x in json.load(urlopen('http://127.0.0.1:'+port+'/json/list',timeout=5)) if x['type']=='page')
- ws=websocket.create_connection(page['webSocketDebuggerUrl'],timeout=35,suppress_origin=True,http_no_proxy=['127.0.0.1','localhost'])
- def call(method,params=None):
- global serial
- serial+=1;rid=serial;ws.send(json.dumps(dict(id=rid,method=method,params=params or {})))
- while True:
- response=json.loads(ws.recv())
- if response.get('id')==rid:
- assert 'error' not in response,response
- return response.get('result',{})
- events.append(response)
- def evaluate(expression,promise=False):
- response=call('Runtime.evaluate',dict(expression=expression,returnByValue=True,awaitPromise=promise))
- assert 'exceptionDetails' not in response,response
- return response['result'].get('value')
- def wait(expression,timeout=25):
- 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)
- def action(body):
- return evaluate('(()=>{const v=document.querySelector("kf-app").shadowRoot.querySelector("view-members");'+body+'})()')
- V='document.querySelector("kf-app")?.shadowRoot?.querySelector("view-members")'
- def ready():wait(V+'?._groups && !'+V+'?._loading && !'+V+'?._busy')
- def shot(name):
- image=call('Page.captureScreenshot',dict(format='png',captureBeyondViewport=False))
- (WORK/(name+'.png')).write_bytes(base64.b64decode(image['data']))
- call('Page.enable');call('Runtime.enable');call('Network.enable')
- call('Emulation.setDeviceMetricsOverride',dict(width=1440,height=1100,deviceScaleFactor=1,mobile=False))
- call('Page.navigate',dict(url=args.url));ready()
- A='document.querySelector("kf-app")'
- def app(body):return evaluate('(()=>{const a='+A+';'+body+'})()')
- def settle():evaluate('new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r)))',True)
- def go(route,tag):
- evaluate('import("/src/utils/router.js").then(m=>m.navigate('+json.dumps(route)+'))',True)
- wait(A+'?._route.path==='+json.dumps(route.split('?')[0])+' && '+A+'?.renderRoot.querySelector('+json.dumps(tag)+')')
- settle()
- def escape():
- call('Input.dispatchKeyEvent',dict(type='keyDown',key='Escape',code='Escape',windowsVirtualKeyCode=27))
- call('Input.dispatchKeyEvent',dict(type='keyUp',key='Escape',code='Escape',windowsVirtualKeyCode=27))
- checks=[]
- assert evaluate('fetch("/__dev/info").then(r=>r.json())',True)['preview']=='unified'
- wait(V+'?.db?.members.length>0')
- db=action('return {members:v.db.members.length,artifacts:v.db.artifacts.length,opportunities:v.db.opportunities.length};')
- assert app('return ![...a.renderRoot.querySelector("kf-nav").renderRoot.querySelectorAll("a")].some(e=>new URL(e.href).pathname==="/showcase");')
- checks.append('single-source-preview-with-board-only-navigation')
- # Real ledger stage/layer grid has four horizontal cells instead of the old stacked grid.
- go('/ledger','view-ledger')
- 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};')
- assert heat['layout']=='row' and heat['cells']==4,heat
- shot('unified-ledger');checks.append('ledger-horizontal-stage-layer-cells')
- # A genuine opportunity card opens in a centered window; report and source links round-trip.
- go('/pool?sort=newest','view-pool')
- wait(A+'.renderRoot.querySelector("view-pool").renderRoot.querySelector("kf-opportunity-card")')
- card=app('const c=a.renderRoot.querySelector("view-pool").renderRoot.querySelector("kf-opportunity-card");return {id:c.opp.id,report:c.opp.reportArtifact?.id};')
- app('a.renderRoot.querySelector("view-pool").renderRoot.querySelector("kf-opportunity-card").shadowRoot.querySelector("kf-glass-card").shadowRoot.querySelector(".frame").click();')
- wait(A+'._route.name==="mode" && '+A+'.renderRoot.querySelector("kf-drawer").shadowRoot.querySelector("dialog").open')
- 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};')
- assert centered['layout']=='center' and centered['left']>50 and centered['right']<1440,centered
- shot('unified-opportunity-center');checks.append('opportunity-centered')
- linked=app('const d=a.renderRoot.querySelector("kf-drawer");return !!d.shadowRoot.querySelector(".linked-title button.lnk");')
- if linked:
- app('a.renderRoot.querySelector("kf-drawer").shadowRoot.querySelector(".linked-title button.lnk").click();')
- wait(A+'._drawer.type==="artifact" && '+A+'.renderRoot.querySelector("kf-drawer").shadowRoot.querySelector("dialog").open')
- assert app('return a.renderRoot.querySelector("kf-drawer").layout;')=='center'
- shot('unified-linked-artifact');escape()
- wait(A+'._drawer?.type==="opp"')
- checks.append('linked-artifact-centered-and-return')
- # Report links carry an explicit return path and chapter anchors avoid the removed bar.
- full=app('const d=a.renderRoot.querySelector("kf-drawer"),link=d.shadowRoot.querySelector("a.report-cta");return link?link.href:null;')
- if full:
- app('a.renderRoot.querySelector("kf-drawer").shadowRoot.querySelector("a.report-cta").click();')
- wait(A+'._route.name==="report" && '+A+'.renderRoot.querySelector("view-report")?.renderRoot.querySelector("article.ch")')
- 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};')
- assert report['back'] and '/pool/' in report['back'],report
- app('a.renderRoot.querySelector("view-report").renderRoot.querySelector("[data-toc] a").click();')
- wait('new URL(location.href).searchParams.has("ch")');evaluate('new Promise(r=>setTimeout(r,500))',True)
- 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};')
- assert chapter['chapterTop']>=chapter['tocBottom']-2,chapter
- shot('unified-report-chapter');call('Page.reload')
- wait(A+'?.renderRoot.querySelector("view-report")?.renderRoot.querySelector("article.ch")')
- app('a.renderRoot.querySelector("view-report").renderRoot.querySelector("a.report-back").click();')
- wait(A+'._route.name==="mode" && '+A+'._drawer?.type==="opp"')
- checks.append('report-reading-chapter-reload-and-return')
- escape();wait(A+'._route.name==="pool" && !'+A+'._drawer')
- assert evaluate('new URL(location.href).searchParams.get("sort")')=='newest'
- checks.append('opportunity-close-preserves-pool-filters')
- # Live screens and research library remain available at the same origin.
- for route,tag in [('/','view-workbench'),('/stages','view-stages'),('/reports','view-reports'),('/reports/category','view-report-type'),('/members','view-members'),('/library','view-library')]:
- go(route,tag)
- if route=='/members':
- ready();assert action('return !!v.renderRoot.querySelector("#manage-groups") && !v.renderRoot.querySelector("input[type=checkbox]");')
- if route=='/library':
- wait(A+'.renderRoot.querySelector("view-library")._data && !'+A+'.renderRoot.querySelector("view-library")._loading')
- assert app('return a.renderRoot.querySelector("view-library")._summary.counts.products;')==4639
- assert app('return !a.renderRoot.querySelector("kf-topbar").renderRoot.querySelector("h1");')
- shot('unified-'+tag)
- checks.extend(['group-modal-preserved','research-library-preserved','titlebar-removal-preserved'])
- # Retired introduction and sample URLs return to the actual workbench.
- for route in ['/showcase','/showcase/leadership','/showcase/research','/showcase/collaboration','/showcase/validation','/showcase/classroom','/cockpit','/intel','/coop','/review','/class']:
- evaluate('import("/src/utils/router.js").then(m=>m.navigate('+json.dumps(route)+'))',True)
- wait(A+'?._route.name==="workbench" && location.pathname==="/" && '+A+'.renderRoot.querySelector("view-workbench")')
- assert app('return !a.renderRoot.querySelector("sample-shell,view-showcase");')
- call('Page.navigate',dict(url=args.url.split('/members')[0]+'/showcase/research?case=A'))
- wait(A+'?._route.name==="workbench" && location.pathname==="/" && !location.search && '+A+'.renderRoot.querySelector("view-workbench")')
- call('Page.reload')
- wait(A+'?.renderRoot.querySelector("view-workbench")')
- assert not evaluate('performance.getEntriesByType("resource").some(e=>/\\/(?:assets\\/showcase|sample-shell|view-showcase)/.test(e.name))')
- checks.extend(['retired-demo-links-return-to-workbench','retired-demo-deep-link','workbench-reload','no-demo-resources-loaded'])
- # Mobile and desktop share the same route table and retained navigation.
- layouts=[]
- for width,height in [(1440,1000),(390,844),(320,740)]:
- call('Emulation.setDeviceMetricsOverride',dict(width=width,height=height,deviceScaleFactor=1,mobile=width<700))
- for route,tag in [('/members','view-members'),('/ledger','view-ledger'),('/','view-workbench'),('/library','view-library')]:
- go(route,tag)
- info=evaluate('({route:location.pathname,width:innerWidth,scrollWidth:document.documentElement.scrollWidth})')
- assert info['scrollWidth']<=width,info
- layouts.append(info)
- shot('unified-overview-'+str(width))
- failures=[e for e in events if e.get('method')=='Runtime.exceptionThrown']
- assert not failures,failures
- result=dict(ok=True,base=args.url,checks=checks,db=db,layouts=layouts,consoleExceptions=0,productionWrites=0)
- (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))
- finally:
- if ws:ws.close()
- process.terminate()
- try:process.wait(timeout=8)
- except subprocess.TimeoutExpired:process.kill()
|