verify-member-groups-browser.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203
  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/member-groups-preview'
  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. state_existed=(WORK/'state.json').exists()
  22. try:
  23. deadline=time.monotonic()+20
  24. while not (profile/'DevToolsActivePort').exists():
  25. assert time.monotonic()<deadline and process.poll() is None
  26. time.sleep(.1)
  27. port=(profile/'DevToolsActivePort').read_text().splitlines()[0]
  28. page=next(x for x in json.load(urlopen('http://127.0.0.1:'+port+'/json/list',timeout=5)) if x['type']=='page')
  29. ws=websocket.create_connection(page['webSocketDebuggerUrl'],timeout=35,suppress_origin=True,http_no_proxy=['127.0.0.1','localhost'])
  30. def call(method,params=None):
  31. global serial
  32. serial+=1;rid=serial;ws.send(json.dumps(dict(id=rid,method=method,params=params or {})))
  33. while True:
  34. response=json.loads(ws.recv())
  35. if response.get('id')==rid:
  36. assert 'error' not in response,response
  37. return response.get('result',{})
  38. events.append(response)
  39. def evaluate(expression,promise=False):
  40. response=call('Runtime.evaluate',dict(expression=expression,returnByValue=True,awaitPromise=promise))
  41. assert 'exceptionDetails' not in response,response
  42. return response['result'].get('value')
  43. def wait(expression,timeout=25):
  44. 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)
  45. def action(body):
  46. return evaluate('(()=>{const v=document.querySelector("kf-app").shadowRoot.querySelector("view-members");'+body+'})()')
  47. V='document.querySelector("kf-app")?.shadowRoot?.querySelector("view-members")'
  48. def ready():wait(V+'?._groups && !'+V+'?._loading && !'+V+'?._busy')
  49. def shot(name):
  50. image=call('Page.captureScreenshot',dict(format='png',captureBeyondViewport=False))
  51. (WORK/(name+'.png')).write_bytes(base64.b64decode(image['data']))
  52. call('Page.enable');call('Runtime.enable');call('Network.enable')
  53. call('Emulation.setDeviceMetricsOverride',dict(width=1440,height=1100,deviceScaleFactor=1,mobile=False))
  54. call('Page.navigate',dict(url=args.url));ready()
  55. baseline=action('return structuredClone(v._groups);')
  56. initial=action('return {members:v.db.members.length,revision:v._groups.revision,groups:v._groups.groups.length};')
  57. assert initial['members']>=2,initial
  58. assert action('return !v.renderRoot.querySelector("input[type=checkbox], select, .group-actions");')
  59. def click(selector):
  60. action('v.renderRoot.querySelector('+json.dumps(selector)+').click();')
  61. def open_manager():
  62. click('#manage-groups');wait(V+'.renderRoot.querySelector("dialog").open')
  63. def submit_name(name):
  64. 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();')
  65. def select_group(gid):
  66. action('Array.from(v.renderRoot.querySelectorAll("[data-manage-group]")).find(b=>b.dataset.manageGroup==='+json.dumps(gid)+').click();')
  67. def assignments_unchanged():
  68. assert action('return Object.entries('+json.dumps(baseline['assignments'])+').every(([id,g])=>v._groups.assignments[id]===g);')
  69. name='界面验收组-'+str(int(time.time()))
  70. open_manager();click('#create-group');submit_name(name)
  71. wait('!'+V+'._edit && '+V+'._groups.groups.some(g=>g.name==='+json.dumps(name)+')');ready()
  72. gid=action('return v._groups.groups.find(g=>g.name==='+json.dumps(name)+').id;')
  73. assert action('return v._dialog && v._managedGroup==='+json.dumps(gid)+';')
  74. click('#create-group');submit_name(name)
  75. wait(V+'._formError && !'+V+'._busy')
  76. assert '同名' in action('return v._formError;')
  77. click('.editor button[type=button]')
  78. # Candidate selection comes only from the actually ungrouped stable identities.
  79. candidates=action('return v._membersOf("__ungrouped").map(m=>({id:m.id,name:m.name}));')
  80. assert candidates,'Need at least one initially ungrouped member for a non-destructive local test'
  81. assert set(action('return [...v.renderRoot.querySelectorAll("[data-candidate]")].map(e=>e.dataset.candidate);'))=={m['id'] for m in candidates}
  82. selected=candidates[:2]
  83. # The complete ungrouped list is directly selectable without a search field.
  84. assert action('return !v.renderRoot.querySelector("dialog input[type=search]");')
  85. click('input[aria-label=全选未分组成员]')
  86. wait(V+'._selected.size==='+str(len(candidates)))
  87. click('input[aria-label=全选未分组成员]')
  88. wait(V+'._selected.size===0')
  89. for m in selected:
  90. action('Array.from(v.renderRoot.querySelectorAll("[data-candidate]")).find(e=>e.dataset.candidate==='+json.dumps(m['id'])+').querySelector("input").click();')
  91. click('#add-members')
  92. wait('!'+V+'._busy && '+V+'._selected.size===0 && '+V+'._groups.assignments['+json.dumps(selected[0]['id'])+']==='+json.dumps(gid))
  93. assert action('return v._membersOf('+json.dumps(gid)+').length;')==len(selected)
  94. assert not action('return v._membersOf("__ungrouped").some(m=>m.id==='+json.dumps(selected[0]['id'])+');')
  95. assignments_unchanged();shot('manager-members-added')
  96. # Removing a member returns it to the candidate list; adding again keeps the dialog open.
  97. action('Array.from(v.renderRoot.querySelectorAll("[data-managed-member]")).find(e=>e.dataset.managedMember==='+json.dumps(selected[0]['id'])+').querySelector("button.remove").click();')
  98. wait('!'+V+'._busy && !'+V+'._groups.assignments['+json.dumps(selected[0]['id'])+']')
  99. assert action('return v._membersOf("__ungrouped").some(m=>m.id==='+json.dumps(selected[0]['id'])+');')
  100. action('Array.from(v.renderRoot.querySelectorAll("[data-candidate]")).find(e=>e.dataset.candidate==='+json.dumps(selected[0]['id'])+').querySelector("input").click();')
  101. click('#add-members');wait('!'+V+'._busy && '+V+'._groups.assignments['+json.dumps(selected[0]['id'])+']==='+json.dumps(gid))
  102. renamed=name+'-改名'
  103. click('button[aria-label="重命名'+name+'"]');submit_name(renamed)
  104. wait('!'+V+'._edit && '+V+'._groups.groups.find(g=>g.id==='+json.dumps(gid)+')?.name==='+json.dumps(renamed))
  105. click('button[aria-label=关闭分组管理]')
  106. # Main page filtering has no management controls and updates immediately.
  107. action('Array.from(v.renderRoot.querySelectorAll("button[data-group-filter]")).find(b=>b.dataset.groupFilter==='+json.dumps(gid)+').click();')
  108. wait(V+'._visibleMembers.length==='+str(len(selected)))
  109. assert action('return !v.renderRoot.querySelector("input[type=checkbox], select, .group-actions");')
  110. call('Page.reload');ready()
  111. assert action('return v._groups.groups.find(g=>g.id==='+json.dumps(gid)+')?.name;')==renamed
  112. assert action('return Object.values(v._groups.assignments).filter(id=>id==='+json.dumps(gid)+').length;')==len(selected)
  113. assignments_unchanged()
  114. open_manager();select_group(gid)
  115. # A real stale revision still rejects the write without overriding the result.
  116. 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)
  117. assert conflict['result']['errors'][0]['code']=='VERSION_CONFLICT',conflict
  118. click('button[aria-label="删除'+renamed+'"]')
  119. wait(V+'._edit?.action==="delete"');shot('manager-delete-confirmation')
  120. action('v.renderRoot.querySelector("form.editor").requestSubmit();')
  121. wait('!'+V+'._edit && !'+V+'._groups.groups.some(g=>g.id==='+json.dumps(gid)+')')
  122. assert action('return !Object.values(v._groups.assignments).includes('+json.dumps(gid)+');')
  123. assert action('return v._dialog;')
  124. assert action('return JSON.stringify(v._groups.assignments);')==json.dumps(baseline['assignments'],ensure_ascii=False,separators=(',',':'))
  125. click('button[aria-label=关闭分组管理]')
  126. call('Page.reload');ready()
  127. layouts=[]
  128. for width,height in [(1440,1100),(1024,1000),(390,844),(320,740)]:
  129. call('Emulation.setDeviceMetricsOverride',dict(width=width,height=height,deviceScaleFactor=1,mobile=width<700))
  130. evaluate('new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r)))',True)
  131. 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};')
  132. assert metrics['documentWidth']<=width,metrics
  133. assert metrics['rows']==metrics['members'],metrics
  134. shot('members-'+str(width));open_manager()
  135. 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};')
  136. assert bounds['left']>=0 and bounds['right']<=width and bounds['top']>=0 and bounds['bottom']<=height,bounds
  137. assert bounds['scrollWidth']<=bounds['clientWidth'],bounds
  138. shot('manager-'+str(width));click('#create-group');shot('manager-new-group-'+str(width))
  139. # Escape closes the manager even from an inline editor.
  140. call('Input.dispatchKeyEvent',dict(type='keyDown',key='Escape',code='Escape',windowsVirtualKeyCode=27))
  141. call('Input.dispatchKeyEvent',dict(type='keyUp',key='Escape',code='Escape',windowsVirtualKeyCode=27));wait('!'+V+'._dialog')
  142. layouts.append(dict(**metrics,dialog=bounds))
  143. # Close on the actual backdrop; clicking inside the surface leaves it open.
  144. open_manager();action('v.renderRoot.querySelector(".dialog-head").click();')
  145. assert action('return v._dialog;')
  146. call('Input.dispatchMouseEvent',dict(type='mousePressed',x=2,y=2,button='left',clickCount=1))
  147. call('Input.dispatchMouseEvent',dict(type='mouseReleased',x=2,y=2,button='left',clickCount=1));wait('!'+V+'._dialog')
  148. # All main routes share the title-free ordinary tools row, including detail routes.
  149. call('Emulation.setDeviceMetricsOverride',dict(width=1440,height=1000,deviceScaleFactor=1,mobile=False))
  150. artifact=action('return v.db.artifacts.find(a=>a.report)?.id || v.db.artifacts[0]?.id;')
  151. opportunity=action('return v.db.opportunities[0]?.id;')
  152. routes=['/','/pool','/stages','/reports','/reports/category','/ledger','/members','/library']
  153. if artifact:routes.append('/report/'+artifact)
  154. if opportunity:routes.append('/pool/'+opportunity)
  155. route_checks=[]
  156. for route in routes:
  157. evaluate('import("/src/utils/router.js").then(m=>m.navigate('+json.dumps(route)+'))',True)
  158. wait('document.querySelector("kf-app")?.shadowRoot?.querySelector("[data-path]")?.dataset.path==='+json.dumps(route))
  159. 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};})()')
  160. assert info['titleCount']==0 and info['position']=='static' and info['refresh'] and info['identity'],info
  161. route_checks.append(info)
  162. # Sticky navigation now starts at the viewport edge, with no old title-bar offset.
  163. evaluate('import("/src/utils/router.js").then(m=>m.navigate("/library"))',True)
  164. L='document.querySelector("kf-app")?.shadowRoot?.querySelector("view-library")'
  165. wait(L+'?._data && !'+L+'?._loading')
  166. evaluate('scrollTo(0,600)');evaluate('new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r)))',True)
  167. tree=evaluate('(()=>{const v='+L+',r=v.renderRoot.querySelector(".tree").getBoundingClientRect();return {top:r.top};})()')
  168. assert 14<=tree['top']<=18,tree
  169. shot('library-without-titlebar')
  170. if artifact:
  171. evaluate('import("/src/utils/router.js").then(m=>m.navigate('+json.dumps('/report/'+artifact)+'))',True)
  172. R='document.querySelector("kf-app")?.shadowRoot?.querySelector("view-report")'
  173. wait(R+'?.renderRoot.querySelector(".toc")')
  174. evaluate('scrollTo(0,600)');evaluate('new Promise(r=>setTimeout(r,120))',True)
  175. 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};})()')
  176. assert toc['position']=='sticky' and toc['inset']=='12px' and toc['top']>=10,toc
  177. shot('report-without-titlebar')
  178. failures=[x for x in events if x.get('method')=='Runtime.exceptionThrown']
  179. assert not failures,failures
  180. 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)
  181. (WORK/'browser-validation.json').write_text(json.dumps(result,ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
  182. # Archive only state wholly created by this browser, never a concurrent user's state.
  183. state_file=WORK/'state.json'
  184. if not state_existed and state_file.exists():
  185. state=json.loads(state_file.read_text(encoding='utf-8'))
  186. browser_request_ids=set()
  187. for event in events:
  188. request=event.get('params',{}).get('request',{})
  189. if event.get('method')=='Network.requestWillBeSent' and '/__dev/member-groups' in request.get('url',''):
  190. try:browser_request_ids.add(json.loads(request.get('postData','{}')).get('params',{}).get('requestId'))
  191. except (ValueError,TypeError):pass
  192. if set(state['operations']).issubset(browser_request_ids) and state['config']['value']['assignments']==baseline['assignments'] and state['config']['value']['groups']==baseline['groups']:
  193. state_file.rename(WORK/('validated-modal-state-'+str(int(time.time()))+'.json'))
  194. result['testStateArchived']=True
  195. (WORK/'browser-validation.json').write_text(json.dumps(result,ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
  196. print(json.dumps(result,ensure_ascii=False,indent=2))
  197. finally:
  198. if ws:ws.close()
  199. process.terminate()
  200. try:process.wait(timeout=8)
  201. except subprocess.TimeoutExpired:process.kill()