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