"""Bilingual FR mobile audit for https://retire.krisforbes.ca — FR screens at 320/360/390 plus desktop 1280 (FR) and EN desktop (regression evidence). Sets localStorage {a, step, screen, lang:'fr'}, captures screenshots and programmatic geometry: - document horizontal overflow (scrollWidth vs innerWidth) - every chart getBBox() inside the 640x270 viewBox - panel/chips/CTA visibility, table header cells visible + font size Output: audit/bilingual-fr/*.png + audit/bilingual-fr/geo.json + FR-AUDIT-OK/FAIL. """ import json, os, sys from playwright.sync_api import sync_playwright OUT = sys.argv[1] if len(sys.argv) > 1 else 'audit/bilingual-fr' os.makedirs(OUT, exist_ok=True) CHROME = '/home/ubuntu/.cache/ms-playwright/chromium-1243/chrome-linux64/chrome' BASE = 'https://retire.krisforbes.ca/' LS = 'retire-planner-v1' ANSWERS = { "province": "ON", "age": 45, "household": "single", "partner": {"age": 45, "income": 0, "rrsp": 0, "tfsa": 0, "nonreg": 0, "monthlyContribution": 0}, "income": 85000, "rrsp": 250000, "tfsa": 60000, "nonreg": 40000, "monthlyContribution": 800, "employerMatchMonthly": 0, "retireAge": 65, "spendingAnnual": 60000, "cppStartAge": 65, "oasStartAge": 65, "pensionAnnual": 12000, "rentalAnnual": 0, "otherAnnual": 0, "realReturnPre": 3.5, "realReturnPost": 2.5, "inflation": 2.0, "lifeExp": 95, "interests": ["dock", "travel"] } DEVICES = [ ('iphone14', 390, 844, 3.0, 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1'), ('pixel', 360, 800, 2.75, 'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Mobile Safari/537.36'), ('se', 320, 700, 2.0, 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1'), ('desktop', 1280, 900, 1.0, 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36'), ] SHOTS = [ ('iphone14', 'intro', 'intro', 0, None), ('iphone14', 'quiz-province', 'quiz', 0, None), ('iphone14', 'quiz-income', 'quiz', 3, None), ('iphone14', 'quiz-slider', 'quiz', 7, None), ('iphone14', 'quiz-sheet-open', 'quiz', 3, 'open_sheet'), ('iphone14', 'results-top', 'results', 0, None), ('iphone14', 'results-charts', 'results', 0, 'charts'), ('iphone14', 'results-table', 'results', 0, 'table'), ('pixel', 'intro', 'intro', 0, None), ('pixel', 'quiz-province', 'quiz', 0, None), ('pixel', 'results-charts', 'results', 0, 'charts'), ('pixel', 'results-table', 'results', 0, 'table'), ('se', 'intro', 'intro', 0, None), ('se', 'quiz-province', 'quiz', 0, None), ('se', 'quiz-income', 'quiz', 3, None), ('se', 'quiz-sheet-open', 'quiz', 3, 'open_sheet'), ('se', 'results-top', 'results', 0, None), ('se', 'results-charts', 'results', 0, 'charts'), ('se', 'results-table', 'results', 0, 'table'), ('desktop', 'intro-fr', 'intro', 0, None), ('desktop', 'results-fr-top', 'results', 0, None), ('desktop', 'results-fr-charts', 'results', 0, 'charts'), ('desktop', 'results-en-top', 'results', 0, 'en_lang'), ] GEO_JS = """() => { const vw = window.innerWidth, vh = window.innerHeight; const docSW = document.documentElement.scrollWidth, bodySW = document.body.scrollWidth; const vis = (e) => { if (!e) return false; const b = e.getBoundingClientRect(); return b.width > 0 && b.height > 0 && b.bottom > 0 && b.top < vh; }; const g = s => document.querySelector(s); // every chart inside the viewBox (0..640 x 0..270) const svgProblems = []; let g1first = null; document.querySelectorAll('#g1 svg, #g2 svg, #g3 svg, #g4 svg').forEach(svg => { const rect = svg.getBoundingClientRect(); const scale = rect.width / 640; svg.querySelectorAll('text').forEach(tx => { const b = tx.getBBox(); if (b.x < -0.5 || b.y < -0.5 || b.x + b.width > 640.5 || b.y + b.height > 270.5) { svgProblems.push({id: svg.closest('[id]').id, txt: tx.textContent.slice(0, 30), x: Math.round(b.x), w: Math.round(b.width)}); } const id = svg.closest('[id]').id; if (id === 'g1' && !g1first) { g1first = {fontSize: parseFloat(tx.getAttribute('font-size') || 11), renderedPx: +(rect.width / 640 * (parseFloat(tx.getAttribute('font-size') || 11))).toFixed(1)}; } }); }); const ths = [...document.querySelectorAll('#yr-table th')]; return { vw, docSW, bodySW, docOverflow: Math.max(docSW, bodySW) > vw + 1, g1first, svgTextOutsideViewBox: svgProblems, panelVisible: vis(g('.panel')), chipsVisible: vis(g('#panel-chips')), chipFontPx: (() => { const k = g('#panel-chips .chip .k'); return k ? parseFloat(getComputedStyle(k).fontSize) : null; })(), panelH: (() => { const p = g('.panel'); return p ? {h: Math.round(p.getBoundingClientRect().height), maxVh: +(p.getBoundingClientRect().height / vh * 100).toFixed(1)} : null; })(), btnNextVisible: vis(g('#btn-next')), btnShareVisible: vis(g('#btn-share')), btnShareOnPage: (() => { const e = g('#btn-share'); if (!e) return false; const b = e.getBoundingClientRect(); return b.width > 0 && b.height > 0; })(), ctaOnPage: (() => { const e = g('#btn-begin'); if (!e) return false; const b = e.getBoundingClientRect(); return b.width > 0 && b.height > 0; })(), thCount: ths.length, thHidden: ths.filter(t => getComputedStyle(t).display === 'none' || !t.offsetWidth).length, thFontPx: ths.length ? parseFloat(getComputedStyle(ths[0]).fontSize) : null, tbl: (() => { const t = g('.tbl-wrap'); return t ? {clientW: t.clientWidth, scrollW: t.scrollWidth} : null; })(), hdr: (() => { const h = g('.hdr'); return h ? Math.round(h.getBoundingClientRect().height) : null; })(), langBtnVisible: vis(g('#lang-fr')), frTextSample: (document.body.innerText.match(/ RPC|REER|CELI|retraite/g) || []).length }; }""" def set_state(page, screen, step, lang): page.evaluate("""([ls, a, screen, step, lang]) => { localStorage.setItem(ls, JSON.stringify({a: a, step: step, screen: screen, lang: lang})); }""", [LS, ANSWERS, screen, step, lang]) results = {} with sync_playwright() as pw: browser = pw.chromium.launch(executable_path=CHROME, headless=True, args=['--no-sandbox', '--disable-dev-shm-usage']) for dev, w, h, dsf, ua in DEVICES: ctx = browser.new_context(viewport={'width': w, 'height': h}, device_scale_factor=dsf, is_mobile=(dev != 'desktop'), has_touch=(dev != 'desktop'), user_agent=ua) page = ctx.new_page() page.goto(BASE, wait_until='networkidle') for d, tag, screen, step, action in [s for s in SHOTS if s[0] == dev]: lang = 'en' if action == 'en_lang' else 'fr' set_state(page, screen, step, lang) page.reload(wait_until='networkidle') page.wait_for_timeout(700) if action == 'open_sheet': try: page.click('.panel-h', timeout=3000) page.wait_for_timeout(400) except Exception as e: print('sheet open failed:', tag, e) elif action == 'charts': el = page.locator('#g1') if el.count(): el.scroll_into_view_if_needed() page.wait_for_timeout(900) # let G4 async render elif action == 'table': page.evaluate("() => { const d = document.querySelector('details.yearly'); if (d) d.open = true; }") el = page.locator('#yr-table') if el.count(): el.scroll_into_view_if_needed() page.wait_for_timeout(300) page.wait_for_timeout(300) path = os.path.join(OUT, f'{dev}-{tag}.png') page.screenshot(path=path, full_page=(action is None and dev != 'desktop')) print('saved', path) # geometry per device on the FR results screen (full app rendered) key = f'{dev}:{tag}' results[key] = page.evaluate(GEO_JS) print(key, json.dumps({k: results[key][k] for k in ('docOverflow', 'docSW', 'vw', 'thCount', 'thHidden', 'thFontPx')})) ctx.close() browser.close() # ---- evaluate FR constraints ---- checks = [] def chk(name, cond, extra=''): checks.append((name, bool(cond), extra)) print(('PASS ' if cond else 'FAIL ') + name + (' [' + str(extra)[:90] + ']' if extra != '' else '')) for key, g in results.items(): dev = key.split(':')[0] chk(key + ' no doc overflow', not g['docOverflow'], f"{g['docSW']}>{g['vw']}") chk(key + ' svg text inside viewBox', len(g['svgTextOutsideViewBox']) == 0, g['svgTextOutsideViewBox'][:2]) if dev == 'desktop': continue if 'results-table' in key: # baseline Fix 5: ≤560px viewport hides 2 of 11 columns by design (non-reg, GIS) exp_hidden = 2 if g['vw'] <= 560 else 0 chk(key + ' table headers visible', g['thCount'] == 11 and g['thHidden'] == exp_hidden, f"{g['thCount']-g['thHidden']}/{g['thCount']} visible @{g['thFontPx']}px ({exp_hidden} hidden by design)") chk(key + ' table scrolls (not clipped)', g['tbl'] and g['tbl']['scrollW'] > g['vw'], g['tbl']) chk(key + ' share CTA on page', g['btnShareOnPage']) if 'results' in key and 'results-table' not in key: chk(key + ' share CTA rendered on page', g['btnShareOnPage']) if 'quiz' in key: chk(key + ' Next CTA visible', g['btnNextVisible']) chk(key + ' panel visible', g['panelVisible'] and g['chipsVisible'], f"chip {g['chipFontPx']}px panel {g['panelH']}") chk(key + ' lang toggle visible', g['langBtnVisible']) if 'intro' in key: chk(key + ' intro CTA rendered', g['ctaOnPage']) ok = all(c[1] for c in checks) with open(os.path.join(OUT, 'geo.json'), 'w') as f: json.dump({'geo': results, 'checks': [{'name': n, 'ok': o, 'extra': str(e)} for n, o, e in checks]}, f, indent=2, ensure_ascii=False) print('checks:', sum(1 for c in checks if c[1]), '/', len(checks)) print('FR-AUDIT-OK' if ok else 'FR-AUDIT-FAIL') sys.exit(0 if ok else 1)