"""Browser smoke checks. Run with `python test_browser.py` and installed Chromium + playwright.
Loads the current source files directly into Chromium; no HTTP server is needed.
Uses system Chromium or an installed Playwright Chromium.
"""
from playwright.sync_api import sync_playwright
import os,json,re,shutil
from pathlib import Path
CHROME=os.getenv('CHROMIUM_BINARY') or shutil.which('chromium') or shutil.which('google-chrome')
checks=[]
def assertion(cond):
assert cond,'assertion failed'
def check(name, fn):
try:fn();checks.append((name,True))
except Exception as e:checks.append((name,False,str(e)));print('FAIL',name, str(e)[:220]);raise
with sync_playwright() as p:
browser=p.chromium.launch(**({'executable_path':CHROME} if CHROME else {}),headless=True,args=['--no-sandbox','--disable-dev-shm-usage'])
context=browser.new_context(viewport={'width':1480,'height':1100},accept_downloads=True)
page=context.new_page();errors=[]
page.on('pageerror',lambda err:errors.append(str(err)))
base=Path(__file__).resolve().parent
html=(base/'index.html').read_text()
html=re.sub(r'', '',html)
html=re.sub(r'', '', html)
page.set_content(html,wait_until='load')
for script in ('engine.js','demo-data.js','app.js'):page.add_script_tag(content=(base/script).read_text())
check('Title and layout',lambda: (assertion(page.title().startswith('ImpactLab')),assertion(page.locator('#indicator option').count()==8)))
check('Synthetic source visible',lambda:assertion('SYNTHETIC DEMO' in page.locator('#sourceBanner').inner_text()))
check('Overview metrics rendered',lambda:assertion('DiD contrast' in page.locator('#view').inner_text()))
check('Default DiD is calculated',lambda:assertion(page.evaluate('window.__impactState.did.ok') is True))
check('Synthetic control has convex weights',lambda:assertion(page.evaluate('window.__impactState.synth.ok') is True))
check('Demo 3000 rows',lambda:assertion(page.evaluate('window.__impactState.rows.length')==3000))
page.locator('[data-tab="trends"]').click()
check('Trends tab renders chart',lambda:assertion(page.locator('#view svg').count()>=1))
page.locator('[data-tab="did"]').click()
check('DiD tab renders interval',lambda:assertion('Year-resampling range' in page.locator('#view').inner_text()))
page.locator('[data-tab="synthetic"]').click()
check('Synthetic tab visualizes donors',lambda:assertion('Donor weights' in page.locator('#view').inner_text()))
page.locator('[data-tab="placebos"]').click()
check('Placebo analyses show diagnostics',lambda:assertion('unit placebos' in page.locator('#view').inner_text()))
page.locator('[data-tab="quality"]').click()
check('Coverage audit reports countries',lambda:assertion('Panel completeness' in page.locator('#view').inner_text()))
page.locator('[data-tab="protocol"]').click()
page.locator('#policyName').fill('Hypothetical education reform (not verified)')
page.locator('#policyUrl').fill('https://example.org/report')
page.locator('#researchNotes').fill('This is an exploratory research note. No causal claim.')
page.locator('[data-tab="export"]').click()
check('Research report contains notes and provenance',lambda:assertion('This is an exploratory research note.' in page.locator('#reportPreview').inner_text() and 'SYNTHETIC DEMO' in page.locator('#reportPreview').inner_text()))
with page.expect_download() as dl:page.locator('#exportCsv').click()
check('CSV downloads',lambda:assertion(dl.value.suggested_filename.endswith('.csv')))
with page.expect_download() as dl2:page.locator('#exportMd').click()
check('Markdown downloads',lambda:assertion(dl2.value.suggested_filename.endswith('.md')))
with page.expect_download() as dl3:page.locator('#exportJson').click()
sess=json.loads(dl3.value.path().read_text())
check('JSON session saves exact provenance',lambda:assertion(sess['source']=='demo' and sess['notes'].startswith('This is an exploratory') and len(sess['rows'])==3000))
page.locator('#startYear').fill('2018');page.locator('#runBtn').click()
check('Invalid short design yields visible warning',lambda:assertion(not page.locator('#errorPanel').is_hidden()))
page.locator('#startYear').fill('2003');page.locator('#runBtn').click()
check('Warning clears after valid design',lambda:assertion(page.locator('#errorPanel').is_hidden()))
# Fake network errors must preserve earlier results, not mislabel as live.
page.route('https://api.worldbank.org/**',lambda route:route.fulfill(status=503,body='unavailable'))
page.locator('#liveBtn').click();page.wait_for_timeout(500)
check('Live fetch failure preserves synthetic data',lambda:assertion(page.evaluate('window.__impactState.source')=='demo' and not page.locator('#errorPanel').is_hidden()))
page.unroute('https://api.worldbank.org/**')
def fake_response(route):
u=route.request.url
data=[{'countryiso3code':code,'date':str(y),'value':70+(i%4)+.1*(y-2000)} for i,code in enumerate(['CHL','ARG','PER','URY']) for y in range(2000,2025)]
route.fulfill(status=200,headers={'Access-Control-Allow-Origin':'*','Content-Type':'application/json'},body=json.dumps([{'page':1,'pages':1,'total':len(data)},data]))
page.route('https://api.worldbank.org/**',fake_response)
page.locator('#liveBtn').click();page.wait_for_timeout(750)
check('World Bank response adapter switches source to LIVE',lambda:assertion(page.evaluate('window.__impactState.source')=='worldbank'))
check('Loaded record count matches mock response',lambda:assertion(page.evaluate('window.__impactState.rows.length')==100))
check('UI labels live data',lambda:assertion('WORLD BANK LIVE' in page.locator('#sourceBanner').inner_text()))
page.locator('#demoBtn').click()
check('Demo restores previous defaults',lambda:assertion(page.evaluate('window.__impactState.source')=='demo' and page.evaluate('window.__impactState.rows.length')==3000))
# Import JSON must be flagged imported; do not automatically trust source.
f=page.locator('[data-tab="export"]');f.click();page.locator('#importJson').set_input_files({'name':'session.json','mimeType':'application/json','buffer':json.dumps(sess).encode()});page.wait_for_timeout(150)
check('Session restores as UNVERIFIED IMPORTED',lambda:assertion(page.evaluate('window.__impactState.source')=='imported'))
# malicious URL should never become link
page.locator('#policyUrl').fill('javascript:alert(1)');page.locator('[data-tab="protocol"]').click()
check('Unsafe URL excluded',lambda:assertion('javascript:alert' not in page.locator('#view').inner_html()))
page.locator('#demoBtn').click()
# Desktop and mobile screenshots
page.screenshot(path=str(base/'ImpactLab_LATAM_DESKTOP_PREVIEW.png'),full_page=True)
mobile=browser.new_context(viewport={'width':390,'height':844},device_scale_factor=1,accept_downloads=True)
mob=mobile.new_page();mob.set_content(html,wait_until='load');[mob.add_script_tag(content=(base/script).read_text()) for script in ('engine.js','demo-data.js','app.js')]
check('Mobile interface renders',lambda:assertion(mob.locator('#view .metric').count()>=4))
check('Mobile layout has no horizontal overflow',lambda:assertion(mob.evaluate('document.documentElement.scrollWidth <= innerWidth+2')))
mob.screenshot(path=str(base/'ImpactLab_LATAM_MOBILE_PREVIEW.png'),full_page=True)
check('Desktop runtime errors zero',lambda:assertion(len(errors)==0))
browser.close()
for item in checks:print('PASS' if item[1] else 'FAIL',item[0])
print('TOTAL',len(checks),'PASS',sum(1 for x in checks if x[1]),'FAIL',sum(1 for x in checks if not x[1]))