| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203 |
- """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/member-groups-preview'
- 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=[]
- state_existed=(WORK/'state.json').exists()
- 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()
- baseline=action('return structuredClone(v._groups);')
- initial=action('return {members:v.db.members.length,revision:v._groups.revision,groups:v._groups.groups.length};')
- assert initial['members']>=2,initial
- assert action('return !v.renderRoot.querySelector("input[type=checkbox], select, .group-actions");')
- def click(selector):
- action('v.renderRoot.querySelector('+json.dumps(selector)+').click();')
- def open_manager():
- click('#manage-groups');wait(V+'.renderRoot.querySelector("dialog").open')
- def submit_name(name):
- action('const i=v.renderRoot.querySelector("input[name=groupName]");i.value='+json.dumps(name)+';i.dispatchEvent(new Event("input",{bubbles:true}));v.renderRoot.querySelector("form.editor").requestSubmit();')
- def select_group(gid):
- action('Array.from(v.renderRoot.querySelectorAll("[data-manage-group]")).find(b=>b.dataset.manageGroup==='+json.dumps(gid)+').click();')
- def assignments_unchanged():
- assert action('return Object.entries('+json.dumps(baseline['assignments'])+').every(([id,g])=>v._groups.assignments[id]===g);')
- name='界面验收组-'+str(int(time.time()))
- open_manager();click('#create-group');submit_name(name)
- wait('!'+V+'._edit && '+V+'._groups.groups.some(g=>g.name==='+json.dumps(name)+')');ready()
- gid=action('return v._groups.groups.find(g=>g.name==='+json.dumps(name)+').id;')
- assert action('return v._dialog && v._managedGroup==='+json.dumps(gid)+';')
- click('#create-group');submit_name(name)
- wait(V+'._formError && !'+V+'._busy')
- assert '同名' in action('return v._formError;')
- click('.editor button[type=button]')
- # Candidate selection comes only from the actually ungrouped stable identities.
- candidates=action('return v._membersOf("__ungrouped").map(m=>({id:m.id,name:m.name}));')
- assert candidates,'Need at least one initially ungrouped member for a non-destructive local test'
- assert set(action('return [...v.renderRoot.querySelectorAll("[data-candidate]")].map(e=>e.dataset.candidate);'))=={m['id'] for m in candidates}
- selected=candidates[:2]
- # The complete ungrouped list is directly selectable without a search field.
- assert action('return !v.renderRoot.querySelector("dialog input[type=search]");')
- click('input[aria-label=全选未分组成员]')
- wait(V+'._selected.size==='+str(len(candidates)))
- click('input[aria-label=全选未分组成员]')
- wait(V+'._selected.size===0')
- for m in selected:
- action('Array.from(v.renderRoot.querySelectorAll("[data-candidate]")).find(e=>e.dataset.candidate==='+json.dumps(m['id'])+').querySelector("input").click();')
- click('#add-members')
- wait('!'+V+'._busy && '+V+'._selected.size===0 && '+V+'._groups.assignments['+json.dumps(selected[0]['id'])+']==='+json.dumps(gid))
- assert action('return v._membersOf('+json.dumps(gid)+').length;')==len(selected)
- assert not action('return v._membersOf("__ungrouped").some(m=>m.id==='+json.dumps(selected[0]['id'])+');')
- assignments_unchanged();shot('manager-members-added')
- # Removing a member returns it to the candidate list; adding again keeps the dialog open.
- action('Array.from(v.renderRoot.querySelectorAll("[data-managed-member]")).find(e=>e.dataset.managedMember==='+json.dumps(selected[0]['id'])+').querySelector("button.remove").click();')
- wait('!'+V+'._busy && !'+V+'._groups.assignments['+json.dumps(selected[0]['id'])+']')
- assert action('return v._membersOf("__ungrouped").some(m=>m.id==='+json.dumps(selected[0]['id'])+');')
- action('Array.from(v.renderRoot.querySelectorAll("[data-candidate]")).find(e=>e.dataset.candidate==='+json.dumps(selected[0]['id'])+').querySelector("input").click();')
- click('#add-members');wait('!'+V+'._busy && '+V+'._groups.assignments['+json.dumps(selected[0]['id'])+']==='+json.dumps(gid))
- renamed=name+'-改名'
- click('button[aria-label="重命名'+name+'"]');submit_name(renamed)
- wait('!'+V+'._edit && '+V+'._groups.groups.find(g=>g.id==='+json.dumps(gid)+')?.name==='+json.dumps(renamed))
- click('button[aria-label=关闭分组管理]')
- # Main page filtering has no management controls and updates immediately.
- action('Array.from(v.renderRoot.querySelectorAll("button[data-group-filter]")).find(b=>b.dataset.groupFilter==='+json.dumps(gid)+').click();')
- wait(V+'._visibleMembers.length==='+str(len(selected)))
- assert action('return !v.renderRoot.querySelector("input[type=checkbox], select, .group-actions");')
- call('Page.reload');ready()
- assert action('return v._groups.groups.find(g=>g.id==='+json.dumps(gid)+')?.name;')==renamed
- assert action('return Object.values(v._groups.assignments).filter(id=>id==='+json.dumps(gid)+').length;')==len(selected)
- assignments_unchanged()
- open_manager();select_group(gid)
- # A real stale revision still rejects the write without overriding the result.
- conflict=evaluate('(async()=>{const v='+V+';const r=await fetch("/__dev/member-groups",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({path:"/ksfManagePublicMemberGroups",params:{requestId:"browser-stale-"+crypto.randomUUID(),expectedRevision:v._groups.revision-1,action:"rename",groupId:'+json.dumps(gid)+',name:"不应保存"}})});return await r.json();})()',True)
- assert conflict['result']['errors'][0]['code']=='VERSION_CONFLICT',conflict
- click('button[aria-label="删除'+renamed+'"]')
- wait(V+'._edit?.action==="delete"');shot('manager-delete-confirmation')
- action('v.renderRoot.querySelector("form.editor").requestSubmit();')
- wait('!'+V+'._edit && !'+V+'._groups.groups.some(g=>g.id==='+json.dumps(gid)+')')
- assert action('return !Object.values(v._groups.assignments).includes('+json.dumps(gid)+');')
- assert action('return v._dialog;')
- assert action('return JSON.stringify(v._groups.assignments);')==json.dumps(baseline['assignments'],ensure_ascii=False,separators=(',',':'))
- click('button[aria-label=关闭分组管理]')
- call('Page.reload');ready()
- layouts=[]
- for width,height in [(1440,1100),(1024,1000),(390,844),(320,740)]:
- call('Emulation.setDeviceMetricsOverride',dict(width=width,height=height,deviceScaleFactor=1,mobile=width<700))
- evaluate('new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r)))',True)
- metrics=action('return {width:innerWidth,documentWidth:document.documentElement.scrollWidth,viewWidth:v.clientWidth,members:v.db.members.length,rows:v.renderRoot.querySelectorAll("li[data-member-id]").length};')
- assert metrics['documentWidth']<=width,metrics
- assert metrics['rows']==metrics['members'],metrics
- shot('members-'+str(width));open_manager()
- bounds=action('const d=v.renderRoot.querySelector("dialog"),r=d.getBoundingClientRect();return {left:r.left,right:r.right,top:r.top,bottom:r.bottom,scrollWidth:d.scrollWidth,clientWidth:d.clientWidth};')
- assert bounds['left']>=0 and bounds['right']<=width and bounds['top']>=0 and bounds['bottom']<=height,bounds
- assert bounds['scrollWidth']<=bounds['clientWidth'],bounds
- shot('manager-'+str(width));click('#create-group');shot('manager-new-group-'+str(width))
- # Escape closes the manager even from an inline editor.
- call('Input.dispatchKeyEvent',dict(type='keyDown',key='Escape',code='Escape',windowsVirtualKeyCode=27))
- call('Input.dispatchKeyEvent',dict(type='keyUp',key='Escape',code='Escape',windowsVirtualKeyCode=27));wait('!'+V+'._dialog')
- layouts.append(dict(**metrics,dialog=bounds))
- # Close on the actual backdrop; clicking inside the surface leaves it open.
- open_manager();action('v.renderRoot.querySelector(".dialog-head").click();')
- assert action('return v._dialog;')
- call('Input.dispatchMouseEvent',dict(type='mousePressed',x=2,y=2,button='left',clickCount=1))
- call('Input.dispatchMouseEvent',dict(type='mouseReleased',x=2,y=2,button='left',clickCount=1));wait('!'+V+'._dialog')
- # All main routes share the title-free ordinary tools row, including detail routes.
- call('Emulation.setDeviceMetricsOverride',dict(width=1440,height=1000,deviceScaleFactor=1,mobile=False))
- artifact=action('return v.db.artifacts.find(a=>a.report)?.id || v.db.artifacts[0]?.id;')
- opportunity=action('return v.db.opportunities[0]?.id;')
- routes=['/','/pool','/stages','/reports','/reports/category','/ledger','/members','/library']
- if artifact:routes.append('/report/'+artifact)
- if opportunity:routes.append('/pool/'+opportunity)
- route_checks=[]
- for route in routes:
- evaluate('import("/src/utils/router.js").then(m=>m.navigate('+json.dumps(route)+'))',True)
- wait('document.querySelector("kf-app")?.shadowRoot?.querySelector("[data-path]")?.dataset.path==='+json.dumps(route))
- info=evaluate('(()=>{const a=document.querySelector("kf-app"),t=a.shadowRoot.querySelector("kf-topbar"),r=t.shadowRoot;return {route:location.pathname,titleCount:r.querySelectorAll("h1,.t,.s").length,position:getComputedStyle(t).position,refresh:!!r.querySelector("button.refresh"),identity:!!r.querySelector("select#me"),width:document.documentElement.scrollWidth};})()')
- assert info['titleCount']==0 and info['position']=='static' and info['refresh'] and info['identity'],info
- route_checks.append(info)
- # Sticky navigation now starts at the viewport edge, with no old title-bar offset.
- evaluate('import("/src/utils/router.js").then(m=>m.navigate("/library"))',True)
- L='document.querySelector("kf-app")?.shadowRoot?.querySelector("view-library")'
- wait(L+'?._data && !'+L+'?._loading')
- evaluate('scrollTo(0,600)');evaluate('new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r)))',True)
- tree=evaluate('(()=>{const v='+L+',r=v.renderRoot.querySelector(".tree").getBoundingClientRect();return {top:r.top};})()')
- assert 14<=tree['top']<=18,tree
- shot('library-without-titlebar')
- if artifact:
- evaluate('import("/src/utils/router.js").then(m=>m.navigate('+json.dumps('/report/'+artifact)+'))',True)
- R='document.querySelector("kf-app")?.shadowRoot?.querySelector("view-report")'
- wait(R+'?.renderRoot.querySelector(".toc")')
- evaluate('scrollTo(0,600)');evaluate('new Promise(r=>setTimeout(r,120))',True)
- toc=evaluate('(()=>{const v='+R+',e=v.renderRoot.querySelector(".toc"),r=e.getBoundingClientRect(),s=getComputedStyle(e);return {top:r.top,height:r.height,position:s.position,inset:s.top};})()')
- assert toc['position']=='sticky' and toc['inset']=='12px' and toc['top']>=10,toc
- shot('report-without-titlebar')
- failures=[x for x in events if x.get('method')=='Runtime.exceptionThrown']
- assert not failures,failures
- result=dict(ok=True,initial=initial,tests=['clean-member-page','centered-manager','create','duplicate-name-error','ungrouped-candidates-only','candidate-list-without-search','select-all-ungrouped','add-members','remove-and-readd','rename','filter','reload-persistence','stale-version-rejected','delete-to-ungrouped','original-assignments-preserved','responsive-manager','escape-close','backdrop-close','titlebar-removed-all-routes','library-sticky-offset','report-sticky-offset'],layouts=layouts,routes=route_checks,localPreview=True,productionWrites=0,consoleExceptions=0)
- (WORK/'browser-validation.json').write_text(json.dumps(result,ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
- # Archive only state wholly created by this browser, never a concurrent user's state.
- state_file=WORK/'state.json'
- if not state_existed and state_file.exists():
- state=json.loads(state_file.read_text(encoding='utf-8'))
- browser_request_ids=set()
- for event in events:
- request=event.get('params',{}).get('request',{})
- if event.get('method')=='Network.requestWillBeSent' and '/__dev/member-groups' in request.get('url',''):
- try:browser_request_ids.add(json.loads(request.get('postData','{}')).get('params',{}).get('requestId'))
- except (ValueError,TypeError):pass
- if set(state['operations']).issubset(browser_request_ids) and state['config']['value']['assignments']==baseline['assignments'] and state['config']['value']['groups']==baseline['groups']:
- state_file.rename(WORK/('validated-modal-state-'+str(int(time.time()))+'.json'))
- result['testStateArchived']=True
- (WORK/'browser-validation.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()
|