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