"""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(){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()