Files

127 lines
6.6 KiB
Python

"""Mobile UX audit for https://retire.krisforbes.ca — screenshots at phone viewports.
Sets localStorage answers, reloads, captures key screens. Output: scratch/mobile-audit/*.png
"""
import json, os, sys
from playwright.sync_api import sync_playwright
OUT = "/home/ubuntu/projects/retire-planner/audit/mobile-audit-run"
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'
DEFAULTS = {
"province": None, "age": 45, "household": "single",
"partner": {"age": 45, "income": 0, "rrsp": 0, "tfsa": 0, "nonreg": 0, "monthlyContribution": 0},
"income": 60000, "rrsp": 100000, "tfsa": 30000, "nonreg": 20000,
"monthlyContribution": 500, "employerMatchMonthly": 0,
"retireAge": 65, "spendingAnnual": 50000,
"cppStartAge": 65, "oasStartAge": 65,
"pensionAnnual": 0, "rentalAnnual": 0, "otherAnnual": 0,
"realReturnPre": 3.5, "realReturnPost": 2.5, "inflation": 2.0, "lifeExp": 95,
"interests": []
}
ANSWERS = dict(DEFAULTS)
ANSWERS.update({
"province": "ON", "age": 45, "income": 85000, "rrsp": 250000, "tfsa": 60000,
"nonreg": 40000, "monthlyContribution": 800, "retireAge": 65, "spendingAnnual": 60000,
"pensionAnnual": 12000, "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'),
]
# (device, tag, screen, step, extra_action)
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', 'quiz-province','quiz', 0, None),
('pixel', 'results-charts','results', 0, 'charts'),
('se', 'quiz-income', 'quiz', 3, None),
('se', 'results-charts','results', 0, 'charts'),
]
def set_state(page, screen, step):
page.evaluate("""([ls, a, screen, step]) => {
const st = {a: a, step: step, screen: screen};
localStorage.setItem(ls, JSON.stringify(st));
}""", [LS, ANSWERS, screen, step])
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=True,
has_touch=True, 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]:
set_state(page, screen, step)
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:', e)
elif action == 'charts':
el = page.locator('#g1')
if el.count():
el.scroll_into_view_if_needed()
page.wait_for_timeout(600) # let G4 async render
elif action == 'table':
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))
print('saved', path)
# also measure key geometry on the widest phone for the report
if dev == 'iphone14':
set_state(page, 'quiz', 3)
page.reload(wait_until='networkidle')
page.wait_for_timeout(500)
geo = page.evaluate("""() => {
const r = s => { const e = document.querySelector(s); if (!e) return null; const b = e.getBoundingClientRect(); return {w: Math.round(b.width), h: Math.round(b.height), top: Math.round(b.top + window.scrollY), bottom: Math.round(b.bottom + window.scrollY)}; };
return {
docH: document.documentElement.scrollHeight,
innerH: window.innerHeight,
panel: r('.panel'), panelH: r('.panel-h'), chips: r('#panel-chips'),
stepBody: r('#step-body'), btnNext: r('#btn-next'), btnBack: r('#btn-back'),
hdr: r('.hdr'), g1: r('#g1'), tblWrap: r('.tbl-wrap'),
minTouch: Math.min(...[...document.querySelectorAll('.ghost,.opt,.cta')].map(e => Math.round(e.getBoundingClientRect().height)))
};
}""")
print('GEO iphone14 quiz:', json.dumps(geo))
set_state(page, 'results', 0)
page.reload(wait_until='networkidle')
page.wait_for_timeout(1200)
geo2 = page.evaluate("""() => {
const r = s => { const e = document.querySelector(s); if (!e) return null; const b = e.getBoundingClientRect(); return {w: Math.round(b.width), h: Math.round(b.height)}; };
const svg = document.querySelector('#g1 svg');
const txt = svg ? svg.querySelector('text') : null;
return {
g1: r('#g1'), g1svg: svg ? {w: Math.round(svg.getBoundingClientRect().width), h: Math.round(svg.getBoundingClientRect().height)} : null,
firstSvgTextPx: txt ? Math.round(640 / svg.getBoundingClientRect().width * 11) : null,
tblScrollW: (() => { const t = document.querySelector('.tbl-wrap'); return t ? {clientW: t.clientWidth, scrollW: t.scrollWidth} : null; })(),
visionImg: r('.vision img')
};
}""")
print('GEO iphone14 results:', json.dumps(geo2))
ctx.close()
browser.close()
print('AUDIT-DONE')