| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321 |
- """Real-browser acceptance for multi-select library materials and exact database locators, with no page overrides."""
- import argparse
- import base64
- from datetime import datetime, timezone
- import json
- from pathlib import Path
- import subprocess
- import tempfile
- import time
- from urllib.request import Request, urlopen
- import websocket
- ROOT = Path(__file__).resolve().parents[1]
- def main():
- parser = argparse.ArgumentParser(description=__doc__)
- parser.add_argument('--url', default='http://127.0.0.1:4183')
- parser.add_argument('--out', default='work/library-card-selection-20261107-r1/browser')
- args = parser.parse_args()
- out = (ROOT / args.out).resolve()
- out.mkdir(parents=True, exist_ok=True)
- profile = Path(tempfile.mkdtemp(prefix='library-material-edge-', dir=ROOT / 'work'))
- browser = subprocess.Popen(['C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe', '--headless=new',
- '--disable-gpu', '--disable-background-networking', '--disable-component-update', '--disable-extensions',
- '--no-first-run', '--remote-debugging-port=0', '--user-data-dir=' + str(profile), 'about:blank'],
- stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, creationflags=subprocess.CREATE_NO_WINDOW)
- serial, ws, events = 0, None, []
- report = {'ok': False, 'checkedAt': datetime.now(timezone.utc).isoformat(), 'url': args.url,
- 'checks': [], 'layouts': [], 'responseOverrides': 0, 'paidCalls': 0, 'productionWrites': 0}
- try:
- end = time.monotonic() + 20
- while not (profile / 'DevToolsActivePort').exists():
- assert time.monotonic() < end and browser.poll() is None
- time.sleep(.1)
- port = (profile / 'DevToolsActivePort').read_text().splitlines()[0]
- page = next(p for p in json.load(urlopen('http://127.0.0.1:' + port + '/json/list')) if p['type'] == 'page')
- ws = websocket.create_connection(page['webSocketDebuggerUrl'], timeout=45, suppress_origin=True, http_no_proxy=['127.0.0.1'])
- def call(method, params=None):
- nonlocal serial
- serial += 1
- ws.send(json.dumps({'id': serial, 'method': method, 'params': params or {}}))
- while True:
- d = json.loads(ws.recv())
- if d.get('id') == serial:
- assert 'error' not in d, d
- return d.get('result', {})
- events.append(d)
- def ev(code):
- d = call('Runtime.evaluate', {'expression': code, 'returnByValue': True, 'awaitPromise': True})
- assert 'exceptionDetails' not in d, d
- return d['result'].get('value')
- view = 'document.querySelector("kf-app")?.renderRoot?.querySelector("view-library")'
- def action(code): return ev('(()=>{const p=' + view + ';' + code + '})()')
- def ready():
- ev('new Promise((resolve,reject)=>{let end=Date.now()+30000;function tick(){const p=' + view +
- ';if(p && !p._loading && p._summary && (p._data || p._detail || p._type==="reports")){p.updateComplete.then(()=>resolve(true));return}'
- 'if(p?._error)reject(Error(p._error));else if(Date.now()>end)reject(Error("timeout"));else setTimeout(tick,70)}tick()})')
- def navigate(path):
- call('Page.navigate', {'url': args.url + path}); ready()
- def settle(): ev('new Promise(r=>setTimeout(r,120))')
- def child_ready(panel):
- ev('new Promise((resolve,reject)=>{let end=Date.now()+20000;function tick(){const p=' + view + ';const c=p?.renderRoot?.querySelector("library-product-detail");if(c && c.panel===' + json.dumps(panel) + ' && !c._loading && c._data){c.updateComplete.then(()=>resolve(true));return}if(c?._error)reject(Error(c._error));else if(Date.now()>end)reject(Error("child timeout"));else setTimeout(tick,70)}tick()})')
- def copied_ready():
- ev('new Promise((resolve,reject)=>{let end=Date.now()+15000;function tick(){const p=' + view + ';if(!p._copying && p._copyText){resolve(true);return}if(Date.now()>end)reject(Error("copy timeout"));else setTimeout(tick,70)}tick()})')
- def shot(name):
- (out / name).write_bytes(base64.b64decode(call('Page.captureScreenshot', {'format': 'png', 'captureBeyondViewport': False})['data']))
- def check(name): report['checks'].append(name)
- def mouse_click(selector):
- point=action('const b=p.renderRoot.querySelector('+json.dumps(selector)+');b.scrollIntoView({block:"center"});const r=b.getBoundingClientRect();return {x:r.x+r.width/2,y:r.y+r.height/2}')
- call('Input.dispatchMouseEvent',{'type':'mousePressed','x':point['x'],'y':point['y'],'button':'left','clickCount':1})
- call('Input.dispatchMouseEvent',{'type':'mouseReleased','x':point['x'],'y':point['y'],'button':'left','clickCount':1});settle()
- call('Runtime.enable'); call('Page.enable'); call('Network.enable')
- call('Emulation.setFocusEmulationEnabled', {'enabled': True})
- call('Browser.grantPermissions', {'origin': args.url, 'permissions': ['clipboardReadWrite', 'clipboardSanitizedWrite']})
- call('Emulation.setDeviceMetricsOverride', {'width': 1440, 'height': 1050, 'deviceScaleFactor': 1, 'mobile': False})
- navigate('/library?type=products&quality=audit&category=banmian&platform=jd&brand=ksf')
- generation = action('return p._summary.generation')
- assert action('return p.renderRoot.querySelectorAll("[data-record-id]").length') == 24
- assert action('return !p.renderRoot.querySelector("library-product-compare,.compare-toggle,.compare-bar")')
- assert not action('return p._isSelecting')
- assert action('return !p.renderRoot.querySelector(".material-choice-footer,[data-select-material],.select-page,.selection-bar")')
- first_product=action('return p._data.records[0].record_id')
- action('p.renderRoot.querySelector("[data-record-id]").click();return true');settle();ready()
- assert action('return p.recordId')==first_product
- assert action('return p._selected.length')==0
- navigate('/library?type=products&quality=audit&category=banmian&platform=jd&brand=ksf')
- shot('browse-1440.png')
- action('p.renderRoot.querySelector("[data-selection-mode]").click();return true');settle()
- assert action('return p._isSelecting')
- assert action('return [...p.renderRoot.querySelectorAll("[data-record-id]")].every(e=>e.getAttribute("role")==="checkbox"&&!e.hasAttribute("href")&&!e.hasAttribute("data-link"))')
- check('browse-click-opens-detail-and-selection-mode-has-no-card-checkbox-footers')
- count_before = len([e for e in events if e['method']=='Network.requestWillBeSent' and '/api/library' in e['params']['request']['url']])
- mouse_click('.product-choice:nth-child(1) library-product-image')
- mouse_click('.product-choice:nth-child(2) h4')
- assert action('return p._selected.length') == 2
- count_after = len([e for e in events if e['method']=='Network.requestWillBeSent' and '/api/library' in e['params']['request']['url']])
- assert count_after == count_before, (count_before,count_after)
- first_ids = action('return p._selected.map(x=>x.id)')
- assert not action('return p.recordId')
- assert action('return p.renderRoot.querySelectorAll(".card-selection-mark").length')==2
- action('p.renderRoot.querySelector("[data-record-id]").focus();return true')
- for key,code,vk in [(' ','Space',32),('Enter','Enter',13)]:
- call('Input.dispatchKeyEvent',{'type':'rawKeyDown','key':key,'code':code,'windowsVirtualKeyCode':vk});call('Input.dispatchKeyEvent',{'type':'keyUp','key':key,'code':code,'windowsVirtualKeyCode':vk});settle()
- assert action('return p._selected.length')==1
- call('Input.dispatchKeyEvent',{'type':'rawKeyDown','key':key,'code':code,'windowsVirtualKeyCode':vk});call('Input.dispatchKeyEvent',{'type':'keyUp','key':key,'code':code,'windowsVirtualKeyCode':vk});settle()
- assert action('return p._selected.length')==2
- call('Input.dispatchKeyEvent',{'type':'rawKeyDown','key':'Escape','code':'Escape','windowsVirtualKeyCode':27});call('Input.dispatchKeyEvent',{'type':'keyUp','key':'Escape','code':'Escape','windowsVirtualKeyCode':27});settle()
- assert not action('return p._isSelecting')
- assert action('return p._selected.length')==2
- assert action('return p.renderRoot.querySelectorAll("[data-record-id][href]").length')==24
- action('p.renderRoot.querySelector("[data-selection-mode]").click();return true');settle()
- first_ids=action('return p._selected.map(x=>x.id)')
- shot('selecting-1440.png')
- check('card-click-selection-does-not-fetch-or-navigate-and-keyboard-space-enter-escape-work')
- action('p.renderRoot.querySelector("[data-page-next]").click();return true'); settle(); ready()
- assert action('return p.routeQuery.page') == '2'
- action('p.renderRoot.querySelector("[data-record-id]").click();return true')
- assert action('return p._selected.length') == 3
- action('p.renderRoot.querySelector(".select-page").click();return true')
- assert action('return p._selected.length') == 26
- action('p.renderRoot.querySelector(".select-page").click();return true')
- assert action('return p._selected.map(x=>x.id)') == first_ids
- check('page-select-and-cancel-only-affect-current-page-with-cross-page-selection-preserved')
- assert action('return p._data.pageSize')==24
- assert '第 2 / ' in action('return p.renderRoot.querySelector(".page-status").textContent')
- # Direct input, reload, browser history and true final-page size.
- action('const f=p.renderRoot.querySelector(".page-jump");f.querySelector("input").value="3";f.requestSubmit();return true');settle();ready()
- page_three=action('return p._data.records.map(x=>x.record_id)')
- assert action('return p._data.page')==3
- call('Page.reload');ready()
- assert action('return p._isSelecting')
- assert action('return p._data.records.map(x=>x.record_id)')==page_three
- action('p.renderRoot.querySelector("[data-page-prev]").click();return true');settle();ready()
- assert action('return p._data.page')==2
- ev('history.back();true');settle();ready()
- assert action('return p._data.page')==3
- pages=action('return p._data.totalPages')
- action('const f=p.renderRoot.querySelector(".page-jump");f.querySelector("input").value=String(p._data.totalPages);f.requestSubmit();return true');settle();ready()
- assert action('return p._data.page')==pages
- assert action('return p._data.records.length')==action('return p._data.totalMatched-(p._data.totalPages-1)*24')
- assert action('return p.renderRoot.querySelector("[data-page-next]").disabled')
- for invalid in ['0','1.5',str(pages+1)]:
- assert not action('const i=p.renderRoot.querySelector(".page-jump input");i.value='+json.dumps(invalid)+';return i.checkValidity()')
- action('const f=p.renderRoot.querySelector(".page-jump");f.querySelector("input").value="1";f.requestSubmit();return true');settle();ready()
- assert action('return p._selected.map(x=>x.id)')==first_ids
- check('24-per-page-direct-input-refresh-history-last-page-and-invalid-page-validation')
- action('const f=p.renderRoot.querySelector(".page-jump");f.querySelector("input").value="3";f.requestSubmit();return true');settle();ready()
- action('const s=p.renderRoot.querySelector("select[aria-label=价格]");s.value="price_asc";s.dispatchEvent(new Event("change"));return true');settle();ready()
- assert action('return p._data.page')==1
- assert action('return p.routeQuery.sort')=='price_asc'
- assert action('return p._selected.map(x=>x.id)')==first_ids
- check('changing-sort-resets-page-with-selected-records-retained')
- mouse_click('[data-selection-mode]')
- assert not action('return p._isSelecting')
- assert action('return p._selected.map(x=>x.id)')==first_ids
- assert action('return !p.renderRoot.querySelector(".select-page")')
- mouse_click('[data-selection-mode]')
- check('finish-selection-restores-browsing-with-selected-materials-retained')
- navigate('/library?type=social&quality=audit')
- action('p.renderRoot.querySelector("[data-record-id]").click();return true')
- navigate('/library?type=documents&quality=audit')
- action('p.renderRoot.querySelector("[data-record-id]").click();return true')
- assert action('return p._selected.map(x=>x.entity)') == ['products','products','social','documents']
- policy = action('return p._selected.find(x=>x.entity==="documents").id')
- assert ':' not in policy
- call('Page.reload');ready()
- assert action('return p._selected.length') == 4
- check('mixed-product-social-policy-selection-and-exact-unprefixed-policy-id-survive-refresh')
- navigate('/library?type=reports')
- ev('new Promise((resolve,reject)=>{let end=Date.now()+20000;function tick(){const p=' + view + ';if(p?.db){resolve(true);return}if(Date.now()>end)reject(Error("workshop database timeout"));else setTimeout(tick,70)}tick()})')
- reports = action('return p.db.artifacts.length')
- report['availableWorkshopReports'] = reports
- if reports:
- action('p.renderRoot.querySelector("[data-record-id]").click();return true')
- assert action('const s=p._selected.find(x=>x.entity==="reports");return !!p.db.artifactById[s.id]')
- check('existing-workshop-reports-use-real-artifact-object-id')
- if reports>24:
- assert action('return p.renderRoot.querySelectorAll(".report-record").length')==24
- action('p.renderRoot.querySelector("[data-page-next]").click();return true');settle();ready()
- assert action('return p.routeQuery.page')=='2'
- assert action('return p.renderRoot.querySelectorAll(".report-record").length')==min(24,reports-24)
- action('p.renderRoot.querySelector(".select-page").click();return true')
- selected_reports=action('return p._selected.filter(x=>x.entity==="reports").length')
- assert selected_reports==min(24,reports-24)+1
- action('p.renderRoot.querySelector(".select-page").click();return true')
- assert action('return p._selected.filter(x=>x.entity==="reports").length')==1
- check('research-reports-use-24-card-pages-and-page-selection-retains-other-pages')
- navigate('/library/products/jd%3A100000348106?panel=reviews&relatedQuality=audit')
- child_ready('reviews')
- child_rows = action('return p.renderRoot.querySelector("library-product-detail")._data.records.length')
- assert child_rows > 0
- action('p.renderRoot.querySelector("library-product-detail").renderRoot.querySelector("[data-select-child]").click();return true')
- assert action('return p._selected.some(x=>x.entity==="reviews" && x.productId==="jd:100000348106")')
- before = action('return p._selected.length')
- action('p.renderRoot.querySelector("library-product-detail").renderRoot.querySelector("[data-select-child-page]").click();return true')
- assert action('return p._selected.length') == before+child_rows-1
- for panel in ['prices','specs']:
- product_id = ev('(async()=>{const {readLibrary}=await import("/src/services/library-service.js");const d=await readLibrary({op:"browse",entity:' + json.dumps(panel) + ',quality:"audit",limit:1,generation:' + json.dumps(generation) + '});return d.records[0].product_id})()')
- navigate('/library/products/' + __import__('urllib.parse',fromlist=['quote']).quote(product_id) + '?panel=' + panel)
- child_ready(panel)
- action('p.renderRoot.querySelector("library-product-detail").renderRoot.querySelector("[data-select-child]").click();return true')
- assert action('return p._selected.some(x=>x.entity===' + json.dumps(panel) + ')')
- check('product-detail-supports-exact-review-price-spec-and-current-page-selection')
- mouse_click('.product-detail-dialog [data-copy-materials]');copied_ready()
- copied = ev('navigator.clipboard.readText()').replace('\r\n','\n')
- assert copied == action('return p._copyText')
- (out/'copied-materials.txt').write_text(copied,encoding='utf-8')
- selected = action('return p._selected')
- rows = [json.loads(line) for line in copied.splitlines() if line.startswith('{')]
- assert len(rows)==len(selected)
- assert any(row.get('database')=='workshop' for row in rows) == bool(reports)
- assert 'generation:'+generation in copied
- assert 'jd:jd:' not in copied and 'source_record_id' in copied
- assert not any(phrase in copied for phrase in ['员工','请返回','请你','查库要求','ksf-voc-library','workshop-read','selectedData','selectionContext'])
- # Independently read every selected VOC record from the same live generation.
- verified = ev('(async()=>{const p=' + view + ';const {readLibrary}=await import("/src/services/library-service.js");const out=[];for(const s of p._selected.filter(x=>x.entity!=="reports")){const d=await readLibrary({op:"detail",entity:s.entity,id:s.id,generation:p._summary.generation});out.push({entity:s.entity,id:s.id,actualId:d.record.id,sourceMatches:!s.sourceRecordId || s.sourceRecordId===d.record.source_record_id});}return out})()')
- assert all(row['id']==row['actualId'] and row['sourceMatches'] for row in verified)
- report['verifiedLocators'] = verified
- check('real-clipboard-has-only-data-locators-and-every-voc-record-reads-back')
- navigate('/library?type=products&quality=audit&category=banmian&platform=jd&brand=ksf')
- action('p.renderRoot.querySelector("[data-preview-materials]").click();return true');copied_ready();settle()
- assert action('return p.renderRoot.querySelector(".material-dialog").open')
- call('Input.dispatchKeyEvent', {'type':'rawKeyDown','key':'Escape','code':'Escape','windowsVirtualKeyCode':27,'nativeVirtualKeyCode':27})
- call('Input.dispatchKeyEvent', {'type':'keyUp','key':'Escape','code':'Escape','windowsVirtualKeyCode':27,'nativeVirtualKeyCode':27})
- settle();assert action('return !p.renderRoot.querySelector(".material-dialog").open')
- call('Browser.resetPermissions')
- for unsanitized in (False,True):
- call('Browser.setPermission',{'origin':args.url,'permission':{'name':'clipboard-write','allowWithoutSanitization':unsanitized},'setting':'denied'})
- action('p.renderRoot.querySelector("[data-copy-materials]").click();return true');copied_ready();settle()
- assert action('return p.renderRoot.querySelector(".material-dialog").open')
- assert action('const t=p.renderRoot.querySelector("[data-material-text]");return t.selectionEnd-t.selectionStart===t.value.length')
- shot('manual-copy-1440.png')
- action('p.renderRoot.querySelector(".material-dialog").close();return true')
- call('Browser.grantPermissions', {'origin':args.url,'permissions':['clipboardReadWrite','clipboardSanitizedWrite']})
- check('native-dialog-keyboard-and-real-permission-denial-manual-copy-fallback')
- for path in ['/library?type=products&quality=audit','/library?type=social&quality=audit','/library?type=documents','/library?type=reports','/library/products/jd%3A100000348106?panel=reviews&relatedQuality=audit']:
- navigate(path)
- if '/products/' in path: child_ready('reviews')
- for width in [1440,1024,768,390,320]:
- call('Emulation.setDeviceMetricsOverride',{'width':width,'height':900,'deviceScaleFactor':1,'mobile':False})
- ev('window.scrollTo(0,0);new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r)))')
- # Responsive columns can change page size and trigger a real data reload.
- ready();settle()
- metrics=ev('({innerWidth,clientWidth:document.documentElement.clientWidth,scrollWidth:document.documentElement.scrollWidth})')
- assert metrics['innerWidth']==width and metrics['scrollWidth']<=metrics['clientWidth'],(path,width,metrics)
- report['layouts'].append({'path':path,'width':width,**metrics})
- if width<=390:
- title_height=action('return p.renderRoot.querySelector(".result-head h3")?.getBoundingClientRect().height || 0')
- assert title_height<=40,(path,width,title_height)
- if path=='/library?type=products&quality=audit' and width==1440:
- compact=action('const cards=[...p.renderRoot.querySelectorAll(".product-choice")];return {maxHeight:Math.max(...cards.map(x=>x.getBoundingClientRect().height)),fullyVisible:cards.filter(x=>x.getBoundingClientRect().bottom<=innerHeight).length,thumbnailWidth:p.renderRoot.querySelector(".product-record library-product-image").getBoundingClientRect().width}')
- assert compact['maxHeight']<=250 and compact['fullyVisible']>=3 and compact['thumbnailWidth']==104,compact
- report['readableCards']=compact
- assert action('return getComputedStyle(p.renderRoot.querySelector(".record-list.cards")).gridTemplateColumns.split(" ").length')==3
- if width in (1440,390,320): shot(('detail' if '/products/' in path else path.split('type=')[1].split('&')[0])+'-'+str(width)+'.png')
- if path=='/library?type=products&quality=audit' and width in (1440,390,320):
- action('p.renderRoot.querySelector(".pagination").scrollIntoView({block:"center"});return true');settle()
- paging=action('const n=p.renderRoot.querySelector(".pagination"),i=n.querySelector("input"),b=n.querySelector("[data-page-go]");const r=n.getBoundingClientRect(),q=b.getBoundingClientRect();return {width:innerWidth,left:r.left,right:r.right,top:r.top,bottom:r.bottom,buttonTop:q.top,buttonBottom:q.bottom,status:n.querySelector(".page-status").textContent,clickable:p.renderRoot.elementFromPoint(q.left+q.width/2,q.top+q.height/2)===b}')
- assert 0<=paging['left'] and paging['right']<=width and paging['buttonBottom']<=900 and paging['clickable'],paging
- report.setdefault('paginationLayouts',[]).append(paging)
- shot('pagination-'+str(width)+'.png')
- mouse_click('[data-close-product]');ready();settle()
- mouse_click('.selection-list summary')
- assert ev('document.documentElement.clientWidth===document.documentElement.scrollWidth')
- for width,height in [(320,568),(390,844),(1440,900)]:
- call('Emulation.setDeviceMetricsOverride',{'width':width,'height':height,'deviceScaleFactor':1,'mobile':False})
- mouse_click('[data-preview-materials]');copied_ready();settle()
- metrics=action('const d=p.renderRoot.querySelector(".material-dialog"),b=p.renderRoot.querySelector("[data-copy-preview]");const r=d.getBoundingClientRect(),q=b.getBoundingClientRect();return {left:r.left,right:r.right,top:r.top,bottom:r.bottom,buttonTop:q.top,buttonBottom:q.bottom,scrollWidth:d.scrollWidth,clientWidth:d.clientWidth}')
- assert 0 <= metrics['left'] and metrics['right'] <= width and metrics['bottom'] <= height and metrics['buttonBottom'] <= height,metrics
- assert metrics['scrollWidth']==metrics['clientWidth'],metrics
- shot('copy-dialog-'+str(width)+'.png')
- action('p.renderRoot.querySelector(".material-dialog").close();return true')
- check('all-five-views-at-five-real-viewport-widths-and-small-phone-copy-dialog-fit')
- # An old comparison link becomes a selection without mounting comparison UI.
- navigate('/library?type=products&compare='+__import__('urllib.parse',fromlist=['quote']).quote(json.dumps(first_ids))+'&comparing=1')
- assert action('return !p.renderRoot.querySelector("library-product-compare") && !location.search.includes("compare")')
- assert action('return p._selected.some(x=>x.id===' + json.dumps(first_ids[0]) + ')')
- check('legacy-comparison-link-normalizes-to-copyable-material-selection')
- old_count=action('return p._selected.length')
- navigate('/library?type=products&selectProducts=%5B%22jd%3A100000283767%22%5D&selectionGeneration=outdated')
- assert action('return p._selected.length')==old_count
- assert '版本已变化' in action('return p._notice')
- check('stale-generation-industry-transfer-rejected-with-visible-explanation')
- action('const s=JSON.parse(sessionStorage.getItem("ksf-library-materials-v1"));s.generation="previous-generation";sessionStorage.setItem("ksf-library-materials-v1",JSON.stringify(s));return true')
- call('Page.reload');ready()
- assert action('return p._selected.length')==0
- assert '旧版本选择已清空' in action('return p._notice')
- assert action('return p.renderRoot.querySelector("[data-copy-materials]").disabled')
- check('persisted-selection-from-old-generation-clears-before-copy')
- report['generation']=generation
- report['consoleExceptions']=[e for e in events if e['method']=='Runtime.exceptionThrown']
- assert not report['consoleExceptions'],report['consoleExceptions']
- report['ok']=True
- except Exception:
- if ws:
- report['failureState'] = action('return {url:location.href,type:p?._type,query:p?.routeQuery,loading:p?._loading,error:p?._error,keys:Object.keys(p?._data||{})}')
- shot('failure.png')
- raise
- finally:
- if ws: ws.close()
- browser.terminate()
- try: browser.wait(timeout=10)
- except subprocess.TimeoutExpired: browser.kill(); browser.wait(timeout=10)
- (out / 'verification.json').write_text(json.dumps(report, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
- print(json.dumps(report, ensure_ascii=False, indent=2))
- if __name__ == '__main__': main()
|