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