Files
retire-planner/audit/ps_audit.py
T

115 lines
5.8 KiB
Python

#!/usr/bin/env python3
"""PS pension module UI audit — https://retire.krisforbes.ca in EN + FR at 320/360/390 + 1280.
Drives localStorage state (PS answers), captures screenshots + geometry at the PS quiz steps
and the results card. Checks:
- no horizontal document overflow (scrollWidth <= innerWidth + 1)
- #step-body is the scroller; #btn-next + #panel bottom cluster fully in viewport
- #ps-card table fits the viewport; sources links present
Writes audit/ps-pension/geo.json + *.png, prints PS-AUDIT-OK/FAIL.
"""
import json, os, sys
from playwright.sync_api import sync_playwright
OUT = 'audit/ps-pension'
os.makedirs(OUT, exist_ok=True)
CHROME = '/usr/bin/google-chrome'
BASE = 'https://retire.krisforbes.ca/'
LS = 'retire-planner-v1'
ANS = {
"province": "ON", "age": 60, "retireAge": 60, "household": "single",
"partner": {"age": 60, "income": 0, "rrsp": 0, "tfsa": 0, "nonreg": 0, "monthlyContribution": 0},
"income": 0, "rrsp": 100000, "tfsa": 30000, "nonreg": 20000,
"monthlyContribution": 0, "employerMatchMonthly": 0,
"spendingAnnual": 50000, "cppStartAge": 65, "oasStartAge": 65,
"pensionAnnual": 0, "rentalAnnual": 0, "otherAnnual": 0,
"psPension": True, "psJoinYear": 2000, "psServiceYears": 30, "psAvgSalary": 100000,
"psOccupation": "it", "psItLevel": "IT-02", "psItStep": 1,
"realReturnPre": 3.5, "realReturnPost": 2.5, "inflation": 2.0, "lifeExp": 95,
"interests": ["dock", "travel"],
}
STEPS = { # visible step index (single household, module ON): 20 steps
'ps-toggle': 11, 'ps-join': 12, 'ps-service': 13, 'ps-salary': 14,
'ps-occ': 15, 'ps-it': 16, 'extra-note': 17,
}
DEVICES = [
('iphone14', 390, 844, 3.0),
('pixel', 360, 800, 2.75),
('se', 320, 700, 2.0),
('desktop', 1280, 900, 1.0),
]
GEO_JS = """() => {
const de = document.documentElement;
const out = {
docOverflowX: de.scrollWidth - window.innerWidth,
stepBodyScroll: (() => { const s = document.getElementById('step-body'); return s ? (s.scrollHeight > s.clientHeight) : null; })(),
btnNextInView: (() => { const b = document.getElementById('btn-next'); if (!b) return null; const r = b.getBoundingClientRect(); return r.bottom <= window.innerHeight + 1 && r.top >= -1; })(),
};
const card = document.getElementById('ps-card');
if (card && !card.hidden) {
const r = card.getBoundingClientRect();
const t = card.querySelector('table');
out.psCard = { visible: getComputedStyle(card).display !== 'none', width: Math.round(r.width),
fits: (t ? t.scrollWidth : 0) <= window.innerWidth + 1,
links: card.querySelectorAll('a').length };
}
return out;
}"""
def main():
results, fails = [], 0
with sync_playwright() as pw:
browser = pw.chromium.launch(executable_path=CHROME)
for lang in ('en', 'fr'):
for name, w, h, dpr in DEVICES:
ctx = browser.new_context(viewport={'width': w, 'height': h}, device_scale_factor=dpr,
is_mobile=(w < 800), has_touch=(w < 800))
page = ctx.new_page()
page.goto(BASE, wait_until='domcontentloaded')
page.evaluate("([ls, st]) => localStorage.setItem(ls, JSON.stringify(st))",
[LS, {"a": ANS, "step": 0, "screen": "quiz", "lang": lang}])
for shot, idx in STEPS.items():
page.evaluate("([ls, st]) => localStorage.setItem(ls, JSON.stringify(st))",
[LS, {"a": ANS, "step": idx, "screen": "quiz", "lang": lang}])
page.reload(wait_until='domcontentloaded')
page.wait_for_timeout(350)
geo = page.evaluate(GEO_JS)
png = f"{OUT}/{lang}-{name}-{shot}.png"
page.screenshot(path=png)
bad = [k for k in ('docOverflowX',) if geo[k] > 1]
if geo.get('btnNextInView') is False: bad.append('btnNext')
rec = {"lang": lang, "device": name, "shot": shot, "geo": geo, "ok": not bad, "bad": bad}
results.append(rec); fails += 1 if bad else 0
if not bad: print(f" ok {lang} {name:9s} {shot}")
else: print(f" FAIL {lang} {name:9s} {shot} {bad} {geo}")
# results screen with PS card
page.evaluate("([ls, st]) => localStorage.setItem(ls, JSON.stringify(st))",
[LS, {"a": ANS, "step": 0, "screen": "results", "lang": lang}])
page.reload(wait_until='domcontentloaded')
page.wait_for_timeout(900)
geo = page.evaluate(GEO_JS)
page.evaluate("() => { const c = document.getElementById('ps-card'); if (c) c.scrollIntoView(); }")
page.wait_for_timeout(200)
page.screenshot(path=f"{OUT}/{lang}-{name}-results-pscard.png")
bad = []
if geo.get('docOverflowX', 0) > 1: bad.append('overflowX')
pc = geo.get('psCard')
if not pc or not pc.get('visible'): bad.append('psCardHidden')
elif not pc.get('fits'): bad.append('psCardWide')
elif pc.get('links', 0) < 5: bad.append('links<5')
rec = {"lang": lang, "device": name, "shot": "results-pscard", "geo": geo, "ok": not bad, "bad": bad}
results.append(rec); fails += 1 if bad else 0
if not bad: print(f" ok {lang} {name:9s} results-pscard")
else: print(f" FAIL {lang} {name:9s} results-pscard {bad} {geo}")
ctx.close()
browser.close()
with open(f'{OUT}/geo.json', 'w') as f:
json.dump(results, f, indent=1)
print(f"{len(results)} shots, {fails} failures")
print("PS-AUDIT-OK" if fails == 0 else "PS-AUDIT-FAIL")
sys.exit(1 if fails else 0)
if __name__ == '__main__':
main()