| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247 |
- """Real-browser acceptance for the five-step innovation research planner, with real database readback."""
- 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/industry-planner-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):
- call('Page.navigate', {'url': args.url + path})
- ready()
- 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)
- call('Runtime.enable'); call('Page.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})
- call('Network.enable')
- def settle(): ev('new Promise(r=>setTimeout(r,140))')
- def choose(key, value):
- action('p.renderRoot.querySelector('+json.dumps('[data-plan-choice="'+key+':'+value+'"]')+').click();return true')
- settle()
- def next_step():
- action('p.renderRoot.querySelector("[data-plan-next]").click();return true');settle()
- 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,hit:p.renderRoot.elementFromPoint(r.x+r.width/2,r.y+r.height/2)===b}')
- assert point['hit'], point
- 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()
- navigate('/industry')
- assert action('return p._planProgress.completed')==1
- assert action('return p._filters.category')=='banmian'
- assert action('return p.renderRoot.querySelector("[data-plan-next]").disabled')
- assert action('return p.renderRoot.querySelectorAll("[data-plan-step]")[4].disabled')
- assert action('return !p.renderRoot.querySelector(".research-analysis").open && !p.renderRoot.querySelector("details.maintenance").open')
- report['initialScope']=action('return p._data.scope')
- check('default-category-single-choice-and-guided-step-gating')
- shot('planner-initial-1440.png')
- choose('category','soup_noodle');ready()
- assert action('return p._filters.category')=='soup_noodle'
- assert action('return p._data.scope.products')!=report['initialScope']['products']
- assert action('return p.renderRoot.querySelectorAll("[data-plan-group=category] input:checked").length')==1
- choose('category','banmian');ready()
- assert action('return p._data.scope.products')==report['initialScope']['products']
- check('category-switch-reads-real-different-data-and-preserves-single-selection')
- choose('goal','new');choose('goal','upgrade')
- assert action('return p._plan.goal')==['upgrade']
- action('p.renderRoot.querySelector(".plan-scope-more").open=true;const e=p.renderRoot.querySelector("[data-plan-filter=platform]");e.value="jd";e.dispatchEvent(new Event("change"));return true');settle();ready()
- assert action('return p._filters.platform')=='jd'
- action('const e=p.renderRoot.querySelector("[data-plan-filter=brand]");e.value="ksf";e.dispatchEvent(new Event("change"));return true');settle();ready()
- assert action('return p._filters.brand')=='ksf'
- assert action('return p._plan.goal')==['upgrade']
- action('const e=p.renderRoot.querySelector("[data-plan-filter=brand]");e.value="";e.dispatchEvent(new Event("change"));return true');settle();ready()
- action('const e=p.renderRoot.querySelector("[data-plan-filter=platform]");e.value="";e.dispatchEvent(new Event("change"));return true');settle();ready()
- check('optional-platform-brand-scope-updates-real-counts-with-research-intent-retained')
- next_step(); assert action('return p._planStep')==1
- choose('audience','tentative');choose('audience','office');choose('audience','student')
- assert action('return p._plan.audience')==['office','student']
- choose('audience','tentative');assert action('return p._plan.audience')==['tentative']
- choose('audience','office');choose('audience','student')
- choose('scenario','lunch');choose('scenario','night')
- assert action('return p._plan.scenario')==['lunch','night']
- shot('planner-audience-1440.png')
- check('multiple-audiences-scenarios-and-tentative-exclusivity')
- next_step();assert action('return p._planStep')==2
- choose('format','bag');choose('format','cup');choose('preparation','soak')
- action('p.renderRoot.querySelector('+json.dumps('[data-plan-choice="format:cup"]')+').focus();return true')
- call('Input.dispatchKeyEvent',{'type':'rawKeyDown','key':'ArrowLeft','code':'ArrowLeft','windowsVirtualKeyCode':37});call('Input.dispatchKeyEvent',{'type':'keyUp','key':'ArrowLeft','code':'ArrowLeft','windowsVirtualKeyCode':37});settle()
- assert action('return p._plan.format')==['bag']
- call('Input.dispatchKeyEvent',{'type':'rawKeyDown','key':'ArrowRight','code':'ArrowRight','windowsVirtualKeyCode':39});call('Input.dispatchKeyEvent',{'type':'keyUp','key':'ArrowRight','code':'ArrowRight','windowsVirtualKeyCode':39});settle()
- assert action('return p._plan.format')==['cup']
- choose('flavor','spicy');choose('flavor','local');choose('position','balanced')
- assert action('return p._plan.format')==['cup']
- assert action('return p._plan.flavor')==['spicy','local']
- check('product-format-preparation-position-are-single-and-flavor-is-multiple')
- next_step();assert action('return p._planStep')==3
- choose('directions','seasoning');choose('directions','health');choose('directions','tentative')
- assert action('return p._plan.directions')==['tentative']
- choose('directions','seasoning');choose('directions','value')
- assert action('return p._plan.directions')==['seasoning','value']
- assert action('return p.renderRoot.querySelectorAll(".plan-evidence-count").length')==7
- shot('planner-directions-1440.png')
- next_step();assert action('return p._planStep')==4
- assert action('return p._planProgress.ready')
- assert not action('return p._promptReady')
- check('seven-innovation-themes-with-separate-real-evidence-counts-and-review-step')
- mouse_click('[data-generate-prompt]')
- assert action('return p._promptReady')
- expected=action('return p._prepareInsights()')
- assert all(x in expected for x in ['目标人群:上班族、在校学生','食用场景:工作日简餐、夜宵加餐','包装形态:杯装','风味偏好:麻辣香辣、地域特色','不是已经证实','database','source_record_id','generation:','"entity":"reviews"','"entity":"prices"'])
- assert not any(x in expected for x in ['员工','jd:jd:','undefined'])
- mouse_click('[data-copy-result]')
- copied=ev('navigator.clipboard.readText()').replace('\r\n','\n')
- assert copied==expected
- (out/'copied-innovation-prompt.txt').write_text(copied,encoding='utf-8')
- report['generatedPlan']=action('return p._plan')
- report['generation']=action('return p._data.generation')
- check('real-mouse-generation-and-clipboard-match-selected-intent-and-database-locators')
- refs=[json.loads(line) for line in copied.splitlines() if line.startswith('{')]
- verified=ev('(async()=>{const {readLibrary}=await import("/src/services/library-service.js");const refs='+json.dumps(refs)+';const out=[];for(const s of refs){const d=await readLibrary({op:"detail",entity:s.entity,id:s.id,generation:'+json.dumps(report['generation'])+'});out.push({entity:s.entity,id:s.id,actualId:d.record.id,sourceMatches:!s.source_record_id||s.source_record_id===d.record.source_record_id});}return out})()')
- assert all(r['id']==r['actualId'] and r['sourceMatches'] for r in verified)
- report['verifiedLocators']=verified
- check('every-prompt-database-id-and-provided-source-read-back-in-exact-generation')
- # Editing invalidates the generated result; refresh restores explicit choices.
- action('p.renderRoot.querySelectorAll("[data-edit-step]")[2].click();return true');settle()
- choose('format','bag')
- assert not action('return p._promptReady')
- assert action('return p._plan.audience')==['office','student']
- call('Page.reload');ready()
- assert action('return p._plan.format')==['bag']
- assert action('return p._planStep')==2
- assert not action('return p._promptReady')
- next_step();next_step();mouse_click('[data-generate-prompt]')
- assert '包装形态:袋装' in action('return p._prepareInsights()')
- check('editing-invalidates-prompt-and-refresh-retains-complete-research-choices')
- # Inspect every step at exact desktop, tablet and phone widths.
- for width,height in [(1440,1050),(1024,900),(768,1024),(390,844),(320,568)]:
- call('Emulation.setDeviceMetricsOverride',{'width':width,'height':height,'deviceScaleFactor':1,'mobile':False})
- for step in range(5):
- action('p._goPlanStep('+str(step)+');return true');settle()
- m=ev('({innerWidth,clientWidth:document.documentElement.clientWidth,scrollWidth:document.documentElement.scrollWidth})')
- assert m['innerWidth']==width and m['scrollWidth']==m['clientWidth'],(width,step,m)
- overflow=action('return [...p.renderRoot.querySelectorAll(".research-planner *")].filter(e=>{const r=e.getBoundingClientRect();return r.width>1&&r.height>1&&(r.left < -1||r.right>document.documentElement.clientWidth+1)}).map(e=>({tag:e.tagName,cls:e.className}))')
- assert not overflow,(width,step,overflow)
- report['layouts'].append({'width':width,'step':step,**m})
- if width in [1440,390,320] and step in [0,3,4]:
- if step==0:ev('scrollTo(0,0);true');settle()
- shot('planner-step-'+str(step)+'-'+str(width)+'.png')
- action('p._goPlanStep(4);return true');settle()
- # Clipboard preview is usable at real small-phone height.
- action('p.renderRoot.querySelector(".plan-result-actions [data-preview-agent]").click();return true');settle()
- m=action('const d=p.renderRoot.querySelector(".agent-copy-dialog"),b=p.renderRoot.querySelector("[data-copy-preview]"),r=d.getBoundingClientRect(),q=b.getBoundingClientRect();return {left:r.left,right:r.right,top:r.top,bottom:r.bottom,buttonBottom:q.bottom,scrollWidth:d.scrollWidth,clientWidth:d.clientWidth}')
- assert 0<=m['left'] and m['right']<=width and 0<=m['top'] and m['bottom']<=height and m['buttonBottom']<=height and m['scrollWidth']==m['clientWidth'],(width,m)
- if width in [390,320]:shot('planner-prompt-dialog-'+str(width)+'.png')
- action('p.renderRoot.querySelector(".agent-copy-dialog").close();return true')
- check('five-steps-across-five-exact-viewports-without-overflow-and-visible-copy-actions')
- # Native permission denial uses the manual-copy fallback, no runtime patch.
- call('Browser.resetPermissions')
- for unsanitized in (False,True):
- call('Browser.setPermission',{'permission':{'name':'clipboard-write','allowWithoutSanitization':unsanitized},'setting':'denied','origin':args.url})
- action('p.renderRoot.querySelector("[data-copy-result]").click();return true');settle()
- assert action('return p.renderRoot.querySelector(".agent-copy-dialog").open')
- assert action('const t=p.renderRoot.querySelector("[data-agent-text]");return t.selectionStart===0&&t.selectionEnd===t.value.length')
- action('p.renderRoot.querySelector(".agent-copy-dialog").close();return true')
- call('Browser.grantPermissions',{'origin':args.url,'permissions':['clipboardReadWrite','clipboardSanitizedWrite']})
- check('actual-clipboard-denial-opens-full-selected-manual-copy-fallback')
- # Real evidence and original maintenance views remain usable.
- action('p._analysisOpen=true;return true');settle()
- action('p.renderRoot.querySelector("[data-evidence=seasoning]").click();return true');settle();ready()
- assert action('return p._data.evidence.theme')=='seasoning'
- assert action('return p.renderRoot.querySelectorAll(".evidence-item").length')>0
- action('p.renderRoot.querySelector("[data-close-evidence]").click();return true');settle();ready()
- check('original-evidence-opens-and-reads-actual-main-and-append-content')
- for tab in ['overview','listings','reviews','policies','rankings','plan','contract']:
- call('Page.navigate',{'url':args.url+'/industry?tab='+tab});ready(tab)
- check('seven-existing-data-and-maintenance-tabs-still-load')
- navigate('/industry')
- action('p._goPlanStep(0);return true');settle()
- choose('category','tentative');ready()
- assert not action('return p._filters.category')
- assert action('return p._data.scope.products')>report['initialScope']['products']
- assert not action('return p._promptReady')
- assert action('return p._plan.audience')==['office','student']
- for i in range(4):next_step()
- mouse_click('[data-generate-prompt]')
- assert '研究品类:暂定' in action('return p._prepareInsights()')
- assert '商品范围 filters:{}' in action('return p._prepareInsights()')
- check('tentative-category-uses-broad-true-scope-and-invalidates-old-generated-result')
- report['consoleExceptions']=[e['params']['exceptionDetails'].get('text') 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,error:p?._error,step:p?._planStep,plan:p?._plan,progress:p?._planProgress}')
- shot('failure.png')
- raise
- finally:
- (out/'verification.json').write_text(json.dumps(report,ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
- if ws:ws.close()
- browser.terminate()
- try:browser.wait(timeout=10)
- except subprocess.TimeoutExpired:browser.kill()
- if __name__=='__main__':
- main()
|