Files
retire-planner/audit/bilingual_audit.py
T

194 lines
10 KiB
Python

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