| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235 |
- """Read-only acceptance: real local data plus explicitly labelled browser response fixtures."""
- import argparse
- import base64
- import json
- from pathlib import Path
- import subprocess
- import tempfile
- import time
- from urllib.request import urlopen
- from urllib.parse import quote
- import websocket
- ROOT = Path(__file__).resolve().parents[1]
- WORK = ROOT / 'docs/industry-voc-ui-20261106'
- SHOTS = WORK / 'screenshots'
- SHOTS.mkdir(parents=True, exist_ok=True)
- parser = argparse.ArgumentParser()
- parser.add_argument('--url', default='http://127.0.0.1:4181')
- args = parser.parse_args()
- profile = Path(tempfile.mkdtemp(prefix='compare-browser-', dir=ROOT / 'work/library-preview'))
- 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)
- ws = None
- serial = 0
- events = []
- 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(p for p in json.load(urlopen(f'http://127.0.0.1:{port}/json/list', timeout=5)) if p['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):
- result = call('Runtime.evaluate', dict(expression=expression, returnByValue=True, awaitPromise=True))
- assert 'exceptionDetails' not in result, result
- return result['result'].get('value')
- def wait(expression):
- evaluate('new Promise((resolve,reject)=>{const end=Date.now()+25000;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()})')
- V = 'document.querySelector("kf-app")?.renderRoot.querySelector("view-library")'
- C = V + '?.renderRoot.querySelector("library-product-compare")'
- def action(body): return evaluate('(()=>{const v=' + V + ';' + body + '})()')
- def compare(body): return evaluate('(()=>{const c=' + C + ';' + body + '})()')
- def ready(): wait(V + '?._summary && !' + V + '._loading && (' + V + '._data || ' + V + '._detail || ' + V + '._type==="reports")')
- def compare_ready(): wait(C + ' && !' + C + '._loading')
- def click(selector): action('v.renderRoot.querySelector(' + json.dumps(selector) + ').click();')
- def select(label, value):
- action('const s=[...v.renderRoot.querySelectorAll("select")].find(s=>s.getAttribute("aria-label")===' + json.dumps(label) + ');s.value=' + json.dumps(value) + ';s.dispatchEvent(new Event("change",{bubbles:true}));')
- ready()
- def go(path):
- call('Page.navigate', dict(url=args.url + path))
- ready()
- def shot(name):
- data = call('Page.captureScreenshot', dict(format='png', captureBeyondViewport=False))
- (SHOTS / (name + '.png')).write_bytes(base64.b64decode(data['data']))
- def escape():
- 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('!' + C)
- def open_compare():
- click('#open-compare')
- compare_ready()
- def selected_count(n): wait(V + '._compareIds.length===' + str(n))
- call('Page.enable'); call('Runtime.enable'); call('Network.enable')
- call('Emulation.setDeviceMetricsOverride', dict(width=1440, height=1100, deviceScaleFactor=1, mobile=False))
- go('/library')
- info = evaluate('fetch("/__dev/info").then(r=>r.json())')
- assert Path(info['root']).resolve() == ROOT
- assert action('return v.renderRoot.querySelector("select[aria-label=店铺]").disabled;')
- select('平台', 'jd')
- assert action('return !v.renderRoot.querySelector("select[aria-label=店铺]").disabled;')
- assert action('return v._data.facets.shopId.truncated && v.renderRoot.querySelector(".product-filters").textContent.includes("前100家");')
- select('店铺', '1000084104')
- scope = action('return {total:v._data.totalMatched,platform:v.routeQuery.platform,shop:v.routeQuery.shopId,brand:v.routeQuery.brand||null,rows:v._data.records.map(r=>({id:r.record_id,platform:r.platform,shop:r.shop_id}))};')
- assert scope['total'] > 0 and all(r['platform'] == 'jd' and r['shop'] == scope['shop'] for r in scope['rows']), scope
- assert scope['brand'] is None
- select('价格', 'price_asc')
- prices = action('return v._data.records.flatMap(r=>r.priceRange?[r.priceRange.minMinor]:[]);')
- assert prices == sorted(prices)
- click('.pagination button:last-child'); ready()
- assert action('return !!v.routeQuery.cursor && v.routeQuery.shopId==="1000084104" && v.routeQuery.sort==="price_asc";')
- select('平台', 'tmall')
- assert action('return !v.routeQuery.shopId && !v.routeQuery.cursor;')
- go('/library?type=products&platform=jd&shopId=1000084104&sort=reviews_desc')
- assert action('return v.renderRoot.querySelector("select[aria-label=店铺]").value;') == '1000084104'
- assert action('return !v.renderRoot.querySelector("a button,a input");')
- # Selection does not reload the list or fetch full details for unselected cards.
- evaluate('window.__compareCalls=[];window.__compareFetch=window.fetch;window.fetch=async function(url,options){if(String(url).includes("/api/library")&&options?.body)window.__compareCalls.push(JSON.parse(options.body));return window.__compareFetch.call(this,url,options);};')
- click('.compare-toggle'); selected_count(1)
- assert action('return v.renderRoot.querySelector("#open-compare").disabled;')
- action('v.renderRoot.querySelectorAll(".compare-toggle")[1].click();'); selected_count(2)
- assert evaluate('window.__compareCalls.length') == 0
- assert not action('return !!v.recordId;')
- open_compare()
- assert compare('return c._items.every(i=>i.detail && i.detail.generation===c._summary.generation);')
- assert compare('return c.renderRoot.activeElement?.classList.contains("close");')
- escape()
- wait(V + '.renderRoot.activeElement?.id==="open-compare"')
- action('v.renderRoot.querySelectorAll(".compare-toggle")[2].click();'); selected_count(3)
- click('.pagination button:last-child'); ready()
- click('.compare-toggle'); selected_count(4)
- action('v.renderRoot.querySelectorAll(".compare-toggle")[1].click();')
- assert action('return v._compareIds.length===4 && v._notice.includes("最多对比 4 件");')
- click('[aria-label=关闭提示]')
- ids = action('return v._compareIds;')
- checks = ['shop-platform-context', 'shop-truncation-disclosed', 'shop-filter-real-readback', 'no-inferred-brand', 'sort-and-page-preserve-shop', 'platform-change-clears-shop-and-cursor', 'shop-deep-link', 'valid-card-html', 'selection-no-detail-prefetch', 'two-product-comparison', 'esc-and-focus-return', 'four-cross-page-products', 'fifth-rejected']
- # Desktop and phone: compare table scrolls inside dialog, never at document level.
- 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<768))
- evaluate('scrollTo(0,0);new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r)))')
- assert evaluate('document.documentElement.scrollWidth<=innerWidth')
- shot('shop-list-' + str(width))
- open_compare()
- layout = compare('const d=c.renderRoot.querySelector("dialog"),s=c.renderRoot.querySelector(".scroll"),r=d.getBoundingClientRect();return {width:innerWidth,documentWidth:document.documentElement.scrollWidth,left:r.left,right:r.right,dialogWidth:d.clientWidth,dialogScroll:d.scrollWidth,tableWidth:s.scrollWidth,tableViewport:s.clientWidth,columns:c._items.length};')
- assert layout['columns'] == 4 and layout['documentWidth'] <= width and layout['dialogScroll'] <= layout['dialogWidth'], layout
- assert layout['left'] >= 0 and layout['right'] <= width, layout
- if width < 768: assert layout['tableWidth'] > layout['tableViewport'], layout
- shot('compare-' + str(width))
- for _ in range(8):
- call('Input.dispatchKeyEvent', dict(type='keyDown', key='Tab', code='Tab', windowsVirtualKeyCode=9))
- call('Input.dispatchKeyEvent', dict(type='keyUp', key='Tab', code='Tab', windowsVirtualKeyCode=9))
- assert compare('return c.renderRoot.querySelector("dialog").contains(c.renderRoot.activeElement);')
- layouts.append(layout)
- escape()
- checks.extend(['four-responsive-widths', 'local-table-horizontal-scroll', 'dialog-keyboard-focus'])
- call('Emulation.setDeviceMetricsOverride', dict(width=1440, height=1100, deviceScaleFactor=1, mobile=False))
- open_compare()
- deep_url = evaluate('location.href')
- call('Page.reload'); ready(); compare_ready()
- assert action('return v._compareIds;') == ids
- assert compare('return c._items.every(i=>i.detail);')
- # Drill down to an exact original review and keep the catalog context.
- compare('c.renderRoot.querySelector(".themes summary").click();')
- evidence = compare('return c.renderRoot.querySelector(".themes details[open] a").href;')
- compare('c.renderRoot.querySelector(".themes details[open] a").click();')
- ready()
- P = V + '.renderRoot.querySelector("library-product-detail")'
- wait(P + '?.panel==="reviews" && !' + P + '._loading && ' + P + '._data')
- assert evaluate('location.href') == evidence
- assert evaluate(P + '._data.records.length') == 1
- assert evaluate(P + '._data.records[0].record_id===' + P + '.query.relatedReviewId')
- click('.breadcrumb a'); ready()
- assert action('return v._compareIds;') == ids
- click('[aria-label="列表视图"]')
- assert action('return v.renderRoot.querySelectorAll(".list .product-choice").length;') > 0
- open_compare()
- compare('c.renderRoot.querySelector("thead .icon-button").click();'); selected_count(3); compare_ready()
- assert compare('return c._items.length;') == 3
- escape()
- click('.compare-actions .text-button'); selected_count(0)
- assert not action('return !!v.renderRoot.querySelector(".compare-bar");')
- checks.extend(['refresh-open-comparison', 'precise-original-review-link', 'return-catalog-retains-selection', 'list-view-selection', 'remove-column', 'clear-selection'])
- # Isolated fixtures alter browser responses only, leaving the real database untouched.
- go('/library?type=products&compare=' + quote(json.dumps(ids[:2])))
- evaluate('''window.__fixture=null;window.__originalFetch=window.fetch;window.fetch=async function(url,options){
- const q=String(url).includes('/api/library')&&options?.body?JSON.parse(options.body):null,f=window.__fixture;
- if(q?.op==='detail'&&q.entity==='products'&&f){
- if(f.mode==='loading')await new Promise(r=>setTimeout(r,700));
- if(q.id===f.id&&['partial','stale'].includes(f.mode))return new Response(JSON.stringify({ok:false,error:{code:f.mode==='stale'?'LIBRARY_VERSION_CHANGED':'LIBRARY_READ_FAILED'}}),{status:503,headers:{'content-type':'application/json'}});
- if(f.mode==='empty'){
- const r=await window.__originalFetch(url,options),d=await r.json();d.record.specs=[];d.record.overview.prices=[];d.record.overview.themes=[];
- d.record.overview.reviews={collected:q.id===f.id?4:0,eligible:0,distinctTexts:0};return new Response(JSON.stringify(d),{status:200,headers:{'content-type':'application/json'}});
- }
- }return window.__originalFetch(url,options);
- };''')
- evaluate('window.__fixture=' + json.dumps(dict(mode='partial', id=ids[1])))
- open_compare()
- assert compare('return !!c._items[0].detail && !!c._items[1].error && !c._items[1].detail;')
- shot('fixture-partial-failure')
- evaluate('window.__fixture=null')
- compare('c._load();'); compare_ready()
- assert compare('return c._items.every(i=>i.detail);')
- evaluate('window.__fixture=' + json.dumps(dict(mode='stale', id=ids[1])))
- compare('c._load();'); compare_ready()
- assert compare('return c._stale && !c._items.some(i=>i.detail) && !!c.renderRoot.querySelector("[role=alert]");')
- evaluate('window.__fixture=null')
- compare('c.renderRoot.querySelector(".state button").click();'); compare_ready()
- assert compare('return !c._stale && c._items.every(i=>i.detail);')
- evaluate('window.__fixture=' + json.dumps(dict(mode='empty', id=ids[1])))
- compare('c._load();'); compare_ready()
- assert compare('return c.renderRoot.textContent.includes("暂未收录评价") && c.renderRoot.textContent.includes("尚无可分析评价") && c.renderRoot.textContent.includes("暂未收录已知金额的价格") && c.renderRoot.textContent.includes("暂未收录规格原文");')
- shot('fixture-empty-fields')
- evaluate('window.__fixture={mode:"loading"}')
- compare('c._load();')
- assert compare('return c._loading && !!c.renderRoot.querySelector("[aria-busy=true]");')
- compare_ready()
- evaluate('window.__fixture=null')
- escape()
- fixtures = ['partial-failure-preserves-success', 'retry-recovers-real-data', 'generation-conflict-invalidates-all', 'fresh-summary-recovers-all', 'zero-reviews-vs-no-eligible-reviews', 'missing-price-and-spec', 'loading-state']
- # Real cross-platform identities keep their own details, irrespective of catalog filters.
- cross_ids = ['jd:2848752', 'taobao:1040511334739']
- go('/library?type=products&platform=jd&compare=' + quote(json.dumps(cross_ids)))
- open_compare()
- assert compare('return c._items.map(i=>i.detail.record.id);') == cross_ids
- assert compare('return c._items[1].detail.record.marketplace;') == 'tmall'
- escape()
- select('平台', 'tmall')
- assert action('return v._compareIds;') == cross_ids
- open_compare()
- assert compare('return c._items.map(i=>i.detail.record.id);') == cross_ids
- escape()
- checks.append('real-cross-platform-products-retain-composite-identities')
- # Regressions: other library areas, ordinary product links, orphan shop URL.
- for entity in ['social', 'documents', 'reports']:
- go('/library?type=' + entity)
- assert action('return v._type;') == entity
- go('/library?shopId=unscoped&cursor=old')
- assert action('return !v.routeQuery.shopId && !v.routeQuery.cursor;')
- click('a[data-record-id]'); ready()
- assert action('return v.recordEntity==="products" && !!v._detail.record;')
- checks.extend(['other-library-areas', 'orphan-shop-link-normalized', 'normal-product-link'])
- exceptions = [e for e in events if e.get('method') == 'Runtime.exceptionThrown']
- assert not exceptions, exceptions
- result = dict(ok=True,base=args.url,checks=checks,fixtures=fixtures,scope=scope,selectedIds=ids,layouts=layouts,comparisonDeepLink=deep_url,consoleExceptions=0,productionWrites=0)
- (WORK / 'browser-acceptance.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()
|