| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340 |
- """Real-browser acceptance for product decisions, independent evidence and live interaction."""
- import argparse
- import base64
- from datetime import datetime, timezone
- import json
- from pathlib import Path
- import subprocess
- import sys
- 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/industry-decision-20261107-r1/browser')
- args = parser.parse_args()
- out = (ROOT / args.out).resolve()
- out.mkdir(parents=True, exist_ok=True)
- profile = Path(tempfile.mkdtemp(prefix='innovation-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-industry")'
- def action(code): return ev('(()=>{const p=' + view + ';' + code + '})()')
- def ready(tab='insights'):
- ev('new Promise((resolve,reject)=>{let end=Date.now()+30000;function tick(){const p=' + view +
- ';if(p && p._tab===' + json.dumps(tab) + ' && !p._loading && p._data){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, tab='insights'):
- call('Page.navigate', {'url': args.url + path})
- ready(tab)
- def change(field, value):
- action('const s=p.renderRoot.querySelector(' + json.dumps('select[data-filter=' + field + ']') + ');s.value=' + json.dumps(value) + ';s.dispatchEvent(new Event("change"));return true')
- ev('new Promise(r=>setTimeout(r,80))')
- ready()
- def shot(name):
- (out / name).write_bytes(base64.b64decode(call('Page.captureScreenshot', {'format': 'png', 'captureBeyondViewport': False})['data']))
- def check(name):
- report['checks'].append(name)
- (out/'in-progress.json').write_text(json.dumps(report,ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
- print('PASS '+name,flush=True)
- def settle(): ev('new Promise(r=>setTimeout(r,120))')
- def click(selector, reload=True):
- point=action('const b=p.renderRoot.querySelector('+json.dumps(selector)+');if(!b)throw Error("missing selector");b.scrollIntoView({block:"center"});const r=b.getBoundingClientRect();return {x:r.x+r.width/2,y:r.y+r.height/2}')
- for kind in ['mousePressed','mouseReleased']:
- call('Input.dispatchMouseEvent',{'type':kind,**point,'button':'left','clickCount':1})
- settle()
- if reload:ready()
- def data():return action('return p._data')
- def dimensions():return action('return p._data.choices')
- def search(text):
- action('p.renderRoot.querySelector("[data-w-search]").value='+json.dumps(text)+';p.renderRoot.querySelector(".ws-product-search").requestSubmit();return true')
- settle();ready()
- def layout(section,width,height):
- action('p.renderRoot.querySelector("#workshop-'+section+'").scrollIntoView({block:"start"});return true');settle()
- metrics=ev('({innerWidth,clientWidth:document.documentElement.clientWidth,scrollWidth:document.documentElement.scrollWidth})')
- assert metrics['innerWidth']==width,metrics
- assert metrics['scrollWidth']<=metrics['clientWidth'],metrics
- bad=action('return [...p.renderRoot.querySelectorAll(".workshop-analysis *")].filter(e=>{const r=e.getBoundingClientRect();return r.width>0&&r.height>0&&(r.left < -1 || r.right>document.documentElement.clientWidth+1)}).slice(0,8).map(e=>({tag:e.tagName,cls:e.className,text:e.textContent.slice(0,80)}))')
- assert not bad,bad
- report['layouts'].append({'section':section,'width':width,'height':height,**metrics,'outOfBounds':len(bad)})
- # Independent readback of snapshot identifiers, raw values and provenance.
- snapshot=json.loads((ROOT/'work/industry-monitoring-20261107-r1/snapshot.json').read_text(encoding='utf-8'))
- entities=snapshot['entities'];products={p['record_id']:p for p in entities['products']};reviews={r['record_id']:r for r in entities['reviews']}
- sys.path.insert(0,str(ROOT/'server/voc-library'))
- from industry_research import analysis_eligible
- def verify_scope(result, selected):
- selected=set(selected);rs=[r for r in reviews.values() if r['product_id'] in selected]
- s=result['scope']
- assert s['products']==len(selected)
- assert s['reviewRows']==len(rs)
- assert s['reviewedProducts']==len({r['product_id'] for r in rs})
- assert sum(b['products'] for b in result['brands'])==len(selected)
- assert sum(b['reviewRows'] for b in result['brands'])==len(rs)
- assert s['eligibleRows']==sum(analysis_eligible(r,True) for r in rs)
- for p in result['products']['rows']:
- assert p['productId'] in selected and p['title']==products[p['productId']]['title']
- assert p['reviewRows']==sum(r['product_id']==p['productId'] for r in rs)
- for r in result['reviewEvidence']['rows']:
- original=reviews[r['reviewId']]
- assert r['productId'] in selected and r['productId']==original['product_id']
- assert r['content']==original.get('content','') and r['appendContent']==original.get('append_content','')
- assert r['sourceRecordId']==original.get('source_record_id')
- assert r['analysisEligible']==analysis_eligible(original,True)
- for group in [*result['motives'],*result['audiences'],*result['scenarios']]:
- for r in group['examples']:
- original=reviews[r['reviewId']]
- for hit in r['hits']:
- assert hit['quote'] in original.get('content' if hit['field']=='main' else 'append_content','')
- return result
- call('Runtime.enable');call('Page.enable');call('Network.enable')
- call('Emulation.setFocusEmulationEnabled',{'enabled':True})
- call('Emulation.setDeviceMetricsOverride',{'width':1440,'height':900,'deviceScaleFactor':1,'mobile':False})
- navigate('/industry');settle()
- base={pid for pid,p in products.items() if p['category']=='banmian'}
- baseline=verify_scope(data(),base);report['scope']=baseline['scope']
- assert baseline['generation']==snapshot['generation']
- decisions=baseline['decisions'];report['decisions']={k:decisions[k] for k in ['headline','reason','eligibleUnits']}
- assert decisions['headline']=='优先研究:调味更可控'
- assert decisions['eligibleUnits']==47
- lead=next(o for o in decisions['directions'] if o['id']=='seasoning')
- assert (lead['supportUnits'],lead['products'],lead['qualifiedUnits'])==(3,3,0)
- assert decisions['strengths'][0]['units']==66
- assert action('return p.renderRoot.querySelectorAll("[data-decision-card]").length')==3
- assert not action('return p.renderRoot.querySelector("#workshop-products")')
- assert action('return p.renderRoot.querySelector(".wd-hero").getBoundingClientRect().bottom')<900
- shot('summary-1440.png')
- for o in decisions['directions']:
- for r in [*o['evidence'],*o['benefits'],*o['adjustments'],*[r for g in o['segments'] for r in g['examples']]]:
- original=reviews[r['reviewId']]
- assert r['sourceRecordId']==original.get('source_record_id')
- assert r['analysisEligible']==analysis_eligible(original,True)
- assert r['content']==str(original.get('content') or '')[:10000]
- assert r['appendContent']==str(original.get('append_content') or '')[:10000]
- for hit in r['hits']:
- assert hit['quote'] in original.get('content' if hit['field']=='main' else 'append_content','')
- for row in o['representatives']:
- assert row['title']==products[row['productId']]['title']
- check('result-first-real-scope-three-directions-and-exact-decision-quotes-identifiers-provenance-verified')
- def request_count():
- return sum(e['method']=='Network.requestWillBeSent' and '/api/industry' in e['params']['request']['url'] for e in events)
- count=request_count()
- click('[data-decision-lead]')
- assert action('return p.routeQuery.w_view')=='directions'
- assert action('return p.routeQuery.w_focus')=='seasoning'
- assert action('return p.renderRoot.querySelectorAll(".wd-quote").length')>=3
- assert action('return p.renderRoot.querySelector(".wd-test").textContent.includes("看到结果后怎么决策")')
- ev('scrollTo(0,document.documentElement.scrollHeight)');settle()
- assert abs(action('return p.renderRoot.querySelector(".wd-nav").getBoundingClientRect().top'))<2
- click('[data-decision-view="market"]')
- assert action('return p.renderRoot.querySelectorAll(".wd-brand-row").length')==len(baseline['brands'])
- action('const s=p.renderRoot.querySelector("[data-competition-theme]");s.value="health";s.dispatchEvent(new Event("change"));return true')
- settle();ready()
- assert action('return p.routeQuery.w_market_theme')=='health'
- assert action('return p.renderRoot.querySelector("[data-competition-theme]").value')=='health'
- click('[data-decision-view="directions"]')
- assert action('return p.routeQuery.w_focus')=='seasoning'
- click('[data-decision-view="evidence"]');click('[data-data-kind="products"]')
- assert action('return p.renderRoot.querySelectorAll(".ws-product").length')==12
- click('[data-decision-view="summary"]')
- assert request_count()==count,(count,request_count())
- check('result-navigation-direction-and-brand-theme-switch-in-place-without-requerying-data-or-losing-selected-direction')
- click('[data-decision-lead]');call('Page.reload');ready();settle()
- assert action('return p.routeQuery.w_focus')=='seasoning'
- assert action('return p.renderRoot.querySelector("[data-direction-select=seasoning]").getAttribute("aria-pressed")')=='true'
- click('[data-direction-select="value"]')
- ev('history.back()');settle();ready()
- assert action('return p.routeQuery.w_focus')=='seasoning'
- ev('history.forward()');settle();ready()
- assert action('return p.routeQuery.w_focus')=='value'
- click('[data-direction-evidence="value"]')
- assert data()['decisions']==decisions and data()['scope']==baseline['scope']
- assert action('return p.routeQuery.w_view')=='evidence'
- assert action('return p.renderRoot.querySelector("[data-w-review-theme]").value')=='value'
- check('selected-direction-survives-refresh-history-and-drills-to-own-originals-with-full-scope-conclusions-unchanged')
- click('[data-decision-view="summary"]')
- click('[data-decision-strength]')
- assert action('return p.routeQuery.w_theme')=='texture'
- assert action('return p.routeQuery.w_kind')=='benefit'
- assert data()['reviewEvidence']['total']==66
- assert all(any(t['id']=='texture' and any(h['kind']=='benefit' for h in t['hits']) for t in r['themes']) for r in data()['reviewEvidence']['rows'])
- click('[data-w-reviews-next]')
- assert data()['reviewEvidence']['page']==2 and data()['decisions']==decisions
- click('[data-w-reviews-prev]')
- click('[data-data-kind="products"]')
- click('[data-w-products-next]')
- assert data()['products']['page']==2 and data()['decisions']==decisions
- assert 55<action('return p.renderRoot.querySelector("#workshop-products").getBoundingClientRect().top')<85
- click('[data-w-products-prev]')
- pid=data()['products']['rows'][0]['productId']
- click('[data-w-product-reviews="'+pid+'"]')
- assert all(r['productId']==pid for r in data()['reviewEvidence']['rows'])
- assert data()['decisions']==decisions
- check('strengths-products-and-independent-page-controls-open-their-own-evidence-without-changing-summary')
- click('[data-w-reset]')
- action('p.renderRoot.querySelector(".wd-extra-filters").open=true;return true')
- click('[data-w-choice="format:bag"]')
- import re
- bag={pid for pid in base if re.search('袋|连包',products[pid]['title'])}
- verify_scope(data(),bag)
- click('[data-w-choice="format:bowl"]')
- packaging={pid for pid in base if re.search('袋|连包|碗|桶',products[pid]['title'])}
- verify_scope(data(),packaging)
- click('[data-w-choice="flavor:spicy"]')
- combined={pid for pid in packaging if re.search('麻辣|香辣|火鸡|超辣|小龙虾|红油',products[pid]['title'])}
- verify_scope(data(),combined)
- assert dimensions()['format']==['bag','bowl']
- call('Page.reload');ready();settle()
- assert dimensions()['format']==['bag','bowl'] and dimensions()['flavor']==['spicy']
- assert action('return p.renderRoot.querySelector(".wd-extra-filters").open')
- click('[data-w-choice="format:all"]');click('[data-w-choice="flavor:all"]')
- assert action('return p.renderRoot.querySelector(".wd-extra-filters").open')
- click('[data-w-choice="audience:office"]')
- office=data();assert 0<office['scope']['products']<len(base)
- click('[data-w-choice="scenario:lunch"]')
- assert 0<data()['scope']['products']<=office['scope']['products']
- assert all('audience' in p['matchEvidence'] and 'scenario' in p['matchEvidence'] for p in data()['products']['rows'])
- click('[data-w-reset]')
- change('brand','ksf')
- ksf={pid for pid in base if products[pid]['brand']=='ksf'}
- verify_scope(data(),ksf)
- change('shopId','1000084104')
- assert action('return p._filters.platform')=='jd'
- primary={pid for pid in ksf if products[pid]['shop_id']=='1000084104' and products[pid]['platform']=='jd'}
- verify_scope(data(),primary)
- change('platform','tmall');assert not action('return p.routeQuery.shopId')
- verify_scope(data(),{pid for pid in ksf if products[pid]['platform']=='tmall'})
- click('[data-w-reset]')
- check('real-multiselects-reset-refresh-audience-scenario-brand-shop-and-platform-update-all-analysis-by-exact-product-scope')
- click('[data-decision-view="evidence"]');click('[data-data-kind="products"]')
- search('根本不存在的商品XYZ')
- assert data()['scope']['products']==0 and data()['decisions']['directions']==[]
- assert data()['decisions']['headline']=='当前条件没有匹配商品'
- click('[data-decision-view="summary"]')
- assert action('return p.renderRoot.querySelectorAll("[data-decision-card]").length')==0
- assert action('return p.renderRoot.querySelector(".wd-hero").textContent.includes("没有匹配商品")')
- click('[data-w-reset]')
- check('empty-matching-range-has-no-invented-direction-and-explicit-empty-result')
- call('Browser.grantPermissions',{'origin':args.url,'permissions':['clipboardReadWrite','clipboardSanitizedWrite']})
- click('[data-copy-agent]',False)
- text=ev('navigator.clipboard.readText()')
- assert snapshot['generation'] in text and '预期业务作用(待验证)' in text and '决策条件:' in text
- assert '评价覆盖:39/837件' in text and '去重可读依据:47组' in text
- refs=[json.loads(line) for line in text.splitlines() if line.startswith('{') and '"database"' in line]
- for r in refs:
- assert any(row['record_id']==r['id'] for row in entities[r['entity']]),r
- if r['entity']=='reviews' and 'source_record_id' in r:
- assert r['source_record_id']==reviews[r['id']].get('source_record_id'),r
- for o in data()['decisions']['directions']:
- assert o['name'] in text and o['test']['decisionRule'] in text
- for r in o['evidence']:
- assert any(ref['id']==r['reviewId'] for ref in refs)
- report['copy']={'bytes':len(text.encode()),'references':len(refs),'exactReferencesVerified':len(refs),'generation':snapshot['generation']}
- check('real-clipboard-decision-hypotheses-tests-and-database-references-all-verified-against-original-snapshot')
- click('[data-decision-lead]')
- rid=action('return p._data.decisions.directions.find(r=>r.id==="seasoning").evidence[0].reviewId')
- click('.wd-quote footer a',False)
- ev('new Promise((resolve,reject)=>{const end=Date.now()+30000;function tick(){const p=document.querySelector("kf-app")?.renderRoot?.querySelector("view-library");if(p?._detail&&!p._loading)resolve(true);else if(p?._error)reject(Error(p._error));else if(Date.now()>end)reject(Error("library timeout"));else setTimeout(tick,70)}tick()})')
- assert '/library/reviews/'+rid in ev('decodeURIComponent(location.pathname)')
- ev('history.back()');settle();ready()
- assert action('return p.routeQuery.w_focus')=='seasoning'
- check('decision-quote-opens-actual-library-original-with-generation-guard-and-history-restores-analysis')
- for tab,field in [('reviews','corpus'),('listings','inventory')]:
- navigate('/industry?tab='+tab+'&category=banmian',tab)
- assert action('return !!p._data.'+field)
- check('existing-inventory-and-review-maintenance-remain-available')
- navigate('/industry');settle()
- for width,height in [(1440,900),(1024,900),(768,1024),(390,844),(320,568)]:
- call('Emulation.setDeviceMetricsOverride',{'width':width,'height':height,'deviceScaleFactor':1,'mobile':False});settle()
- for name,selector in [('summary','[data-decision-view="summary"]'),('directions','[data-decision-view="directions"]'),('competition','[data-decision-view="market"]'),('people','[data-market-view="people"]'),('trend','[data-market-view="trend"]'),('reviews','[data-decision-view="evidence"]'),('products','[data-data-kind="products"]')]:
- click(selector)
- if name=='reviews':click('[data-data-kind="reviews"]')
- if name=='competition':click('[data-market-view="competition"]')
- metrics=ev('({innerWidth,clientWidth:document.documentElement.clientWidth,scrollWidth:document.documentElement.scrollWidth})')
- assert metrics['innerWidth']==width,metrics
- assert metrics['scrollWidth']<=metrics['clientWidth'],metrics
- bad=action('return [...p.renderRoot.querySelectorAll(".workshop-analysis *")].filter(e=>{const r=e.getBoundingClientRect();return r.width>0&&r.height>0&&(r.left < -1 || r.right>document.documentElement.clientWidth+1)}).slice(0,8).map(e=>({tag:e.tagName,cls:e.className,text:e.textContent.slice(0,80)}))')
- assert not bad,bad
- report['layouts'].append({'section':name,'width':width,'height':height,**metrics,'outOfBounds':0})
- if width in (1440,390,320):shot(name+'-'+str(width)+'.png')
- action('p.renderRoot.querySelector(".copy-menu").open=true;return true')
- click('[data-preview-agent]',False)
- assert action('return p.renderRoot.querySelector(".agent-copy-dialog").matches(":modal")')
- d=action('const x=p.renderRoot.querySelector(".agent-copy-dialog"),r=x.getBoundingClientRect();return {width:r.width,right:r.right,bottom:r.bottom,top:r.top,scrollWidth:x.scrollWidth,clientWidth:x.clientWidth}')
- assert d['right']<=width and d['top']>=0 and d['bottom']<=height and d['scrollWidth']<=d['clientWidth'],d
- report.setdefault('copyDialogs',[]).append({'viewport':width,**d})
- if width==390:shot('copy-390.png')
- click('.copy-dialog-head>button',False)
- action('p.renderRoot.querySelector(".copy-menu").open=false;return true')
- check('seven-result-views-and-native-copy-dialog-fit-five-real-viewports-with-no-horizontal-overflow')
- # Native keyboard activation works through the rendered button.
- action('p.renderRoot.querySelector("[data-decision-view=summary]").focus();return true')
- call('Input.dispatchKeyEvent',{'type':'keyDown','key':'Enter','code':'Enter','windowsVirtualKeyCode':13,'text':'\r'})
- call('Input.dispatchKeyEvent',{'type':'keyUp','key':'Enter','code':'Enter','windowsVirtualKeyCode':13})
- settle();ready()
- assert action('return p.routeQuery.w_view')=='summary'
- assert action('return p.renderRoot.querySelector(".p-head h2").textContent')=='产品创新决策'
- assert not action('return /NaN|undefined|jd:jd:/.test(p.renderRoot.querySelector(".wd-analysis").textContent)')
- errors=[e for e in events if e['method']=='Runtime.exceptionThrown']
- assert not errors,errors
- report['javascriptExceptions']=0
- check('keyboard-activation-no-bad-values-or-uncaught-exceptions-no-response-overrides-and-no-paid-or-production-writes')
- report['ok']=True
- except Exception:
- if ws: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({'ok':report['ok'],'checks':report['checks'],'layouts':len(report['layouts']),'copyDialogs':len(report.get('copyDialogs',[])),'receipt':str(out/'verification.json')},ensure_ascii=False,indent=2))
- if __name__=='__main__':main()
|