#!/usr/bin/env python3 """PS-AUTO-SERVICE live UI audit — local server (site/) at 390x844, EN + FR. Phases (each = seeded localStorage + reload, plus real keyboard interaction): 1 ps_service prefilled with the auto value + derivation hint, Next enabled, no button 2 real typing of an override (30) → override hint + "Use calculated" button 3 "Use calculated" restores the auto prefill + clears the override in localStorage 4 results card reflects the override (30 years · 51,006.90 / 51 506,90 FR) 5 results card reflects the auto value (35 years cap · 59,508.05 / 59 508,05 FR) 6 FR wording for 1/2/5 7 module OFF parity: 15 steps, no PS text anywhere 8 app-shell geometry at 390px: no horizontal overflow, controls + ps-card in view Writes audit/ps-auto-service/*.png + checks.json, prints PS-AUTO-AUDIT-OK/FAIL. """ import json, os, re, sys from playwright.sync_api import sync_playwright OUT = 'audit/ps-auto-service' os.makedirs(OUT, exist_ok=True) CHROME = '/usr/bin/google-chrome' BASE = os.environ.get('AUDIT_BASE', 'http://127.0.0.1:8801/') LS = 'retire-planner-v1' BASE_A = { "province": "ON", "age": 45, "retireAge": 65, "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, "spendingAnnual": 50000, "cppStartAge": 65, "oasStartAge": 65, "pensionAnnual": 0, "rentalAnnual": 0, "otherAnnual": 0, "psPension": True, "psJoinYear": 2005, "psServiceOverride": None, "psAvgSalary": 108324, "psOccupation": "it", "psItLevel": "IT-03", "psItStep": 3, "realReturnPre": 3.5, "realReturnPost": 2.5, "inflation": 2.0, "lifeExp": 95, "interests": [], } GEO_JS = """() => { const de = document.scrollingElement; const r = (s) => { const e = document.querySelector(s); if (!e) return null; const b = e.getBoundingClientRect(); return {top: Math.round(b.top), bottom: Math.round(b.bottom), left: Math.round(b.left), right: Math.round(b.right), w: Math.round(b.width)}; }; return { iw: innerWidth, docScrollW: de.scrollWidth, f1: r('#f1'), next: r('#btn-next'), uc: r('#ps-usecalc'), card: r('#ps-card') }; }""" results, fails = [], 0 def ok(name, cond, extra=''): global fails results.append({'check': name, 'pass': bool(cond), 'extra': str(extra)[:180]}) if not cond: fails += 1 print((' PASS ' if cond else ' FAIL ') + name + ((' [' + str(extra)[:150] + ']') if extra and not cond else '')) def nb(s): return s.replace('\u00a0', ' ').replace('\u202f', ' ') def seed(page, a, step, screen, lang): page.evaluate("""([ls, st]) => localStorage.setItem(ls, JSON.stringify(st))""", (LS, {'a': a, 'step': step, 'screen': screen, 'lang': lang})) page.reload(wait_until='load') page.wait_for_timeout(250) def geo_checks(tag, g, need_card=False): ok(tag + ': no horizontal overflow at 390px', g['docScrollW'] <= g['iw'] + 1, g) if g['f1']: ok(tag + ': #f1 in viewport', g['f1']['top'] >= 0 and g['f1']['bottom'] <= g['iw'] * 3, g['f1']) if g['next']: ok(tag + ': #btn-next in viewport', 0 <= g['next']['top'] and g['next']['bottom'] <= 844, g['next']) if need_card: ok(tag + ': ps-card fits the viewport', g['card'] and g['card']['right'] <= g['iw'] + 1 and g['card']['left'] >= 0, g['card']) with sync_playwright() as pw: browser = pw.chromium.launch(executable_path=CHROME, args=['--no-sandbox']) page = browser.new_page(viewport={'width': 390, 'height': 844}) page.goto(BASE, wait_until='load') # ---- EN 1: auto prefill, untouched ---- seed(page, dict(BASE_A), 13, 'quiz', 'en') v = page.input_value('#f1') hint = nb(page.inner_text('#step-body .hint')) dis = page.is_disabled('#btn-next') uc_vis = page.is_visible('#ps-svc-reset') ok('EN auto: input prefilled 41 (age 45, joined 2005, retire 65) — untouched', v == '41', v) ok('EN auto: derivation hint', re.search(r'Calculated: joined 2005 at age 24, retiring at 65 . 41 years of service', hint), hint) ok('EN auto: Next enabled + Use-calculated hidden', dis is False and uc_vis is False, f'disabled={dis} uc_visible={uc_vis}') geo_checks('EN-auto-step', page.evaluate(GEO_JS)) page.screenshot(path=OUT + '/en-auto-step.png') # ---- EN 2: real typing → override (hint must LIVE-swap, no re-render in a real browser) ---- page.fill('#f1', '30') page.wait_for_timeout(150) hint2 = nb(page.inner_text('#step-body .hint')) st = page.evaluate("ls => JSON.parse(localStorage.getItem(ls))", LS) ok('EN override (real typing): hint LIVE-swaps to override + auto', re.search(r'Using your value \(30 years\).*gives 41 years', hint2, re.S), hint2) ok('EN override: "Use calculated" button becomes visible', page.is_visible('#ps-svc-reset'), page.evaluate("() => document.querySelector('#ps-svc-reset').style.display")) ok('EN override: persisted psServiceOverride = 30', st['a'].get('psServiceOverride') == 30, st['a'].get('psServiceOverride')) geo_checks('EN-override-step', page.evaluate(GEO_JS)) page.screenshot(path=OUT + '/en-override-step.png') # ---- EN 3: use-calc restores auto ---- page.click('#ps-usecalc') page.wait_for_timeout(250) v3 = page.input_value('#f1') hint3 = nb(page.inner_text('#step-body .hint')) st3 = page.evaluate("ls => JSON.parse(localStorage.getItem(ls))", LS) ok('EN use-calc: prefill back to 41 + auto hint', v3 == '41' and 'Calculated: joined 2005' in hint3, f'{v3} | {hint3[:90]}') ok('EN use-calc: override cleared in localStorage', st3['a'].get('psServiceOverride') is None, st3['a'].get('psServiceOverride')) ok('EN use-calc: button hidden again', page.is_visible('#ps-svc-reset') is False) # ---- EN 4/5: results reflect override vs auto ---- seed(page, dict(BASE_A, psServiceOverride=30), 0, 'results', 'en') card = nb(page.inner_text('#ps-card')) ok('EN results (override 30): 30 years used', re.search(r'30 years \(max 35\)', card), card[:160]) ok('EN results (override 30): from-65 51,006.90', '51,006.90' in card, card[:160]) geo_checks('EN-results', page.evaluate(GEO_JS), need_card=True) page.screenshot(path=OUT + '/en-results-override.png') seed(page, dict(BASE_A), 0, 'results', 'en') card2 = nb(page.inner_text('#ps-card')) ok('EN results (auto): 35 years used (auto 41, cap 35)', re.search(r'35 years \(max 35\)', card2), card2[:160]) ok('EN results (auto): from-65 59,508.05', '59,508.05' in card2, card2[:160]) page.screenshot(path=OUT + '/en-results-auto.png') # ---- FR 6: wording ---- seed(page, dict(BASE_A), 13, 'quiz', 'fr') vf = page.input_value('#f1') hintF = nb(page.inner_text('#step-body .hint')) ok('FR auto: prefilled 41', vf == '41', vf) ok('FR auto: derivation hint', re.search(r'Calcul. : entr.e en poste en 2005 . 24 ans, d.part . 65 ans . 41 ann.es de service', hintF), hintF) page.fill('#f1', '30') page.wait_for_timeout(150) hintF2 = nb(page.inner_text('#step-body .hint')) ucf = page.query_selector('#ps-usecalc') ok('FR override: hint', re.search(r'Votre valeur \(30 ann.es\) est utilis.e.*donne 41 ans', hintF2, re.S), hintF2) ok('FR override: "Utiliser le calcul" visible', page.is_visible('#ps-svc-reset') and 'calcul' in ucf.inner_text().lower(), page.evaluate("""() => { const e = document.querySelector('#ps-svc-reset'); const cs = getComputedStyle(e), b = e.getBoundingClientRect(); return { display: cs.display, vis: cs.visibility, box: [b.width, b.height], lang: document.documentElement.lang }; }""")) page.screenshot(path=OUT + '/fr-override-step.png') seed(page, dict(BASE_A), 0, 'results', 'fr') cardF = nb(page.inner_text('#ps-card')) ok('FR results (auto): 35 années (max 35)', re.search(r'35 ann.es \(max 35\)', cardF), cardF[:160]) ok('FR results (auto): 59 508,05 formatted fr-CA', re.search(r'59\s?508,05', cardF), cardF[:160]) seed(page, dict(BASE_A, psServiceOverride=30), 0, 'results', 'fr') cardFO = nb(page.inner_text('#ps-card')) ok('FR results (override): 30 années + 51 006,90', re.search(r'30 ann.es \(max 35\)', cardFO) and re.search(r'51\s?006,90', cardFO), cardFO[:200]) geo_checks('FR-results', page.evaluate(GEO_JS), need_card=True) page.screenshot(path=OUT + '/fr-results-override.png') # ---- 7: module OFF parity (15 steps, no PS text) ---- seed(page, dict(BASE_A, psPension=False, psJoinYear=None, psServiceOverride=None, psAvgSalary=None, psOccupation=None, psItLevel=None, psItStep=0), 14, 'quiz', 'en') sc = nb(page.inner_text('#step-count')) ok('module OFF: 15 steps (last = 15/15)', re.search(r'15\s*/\s*15|15 of 15|of 15', sc), sc) page.click('#btn-next') page.wait_for_timeout(400) body_all = nb(page.inner_text('body')).lower() # parity: no PS QUIZ content (the static results footer "…pension (PSSA) modelled when enabled" predates this change) ok('module OFF: no PS quiz text on results, card hidden', ('pensionable' not in body_all and 'years of pensionable' not in body_all and page.evaluate("() => document.querySelector('#ps-card').hidden") is True), [w for w in ('pensionable', 'years of pensionable service') if w in body_all]) page.screenshot(path=OUT + '/module-off-results.png') browser.close() with open(OUT + '/checks.json', 'w') as f: json.dump(results, f, indent=1) print(f"\n{len(results)} checks, {fails} failed") print('PS-AUTO-AUDIT-OK' if fails == 0 else 'PS-AUTO-AUDIT-FAIL') sys.exit(1 if fails else 0)