"""Round-4 app-shell acceptance battery (QUIZ-APPSHELL-FIX.md acceptance 1-6). At 320/360/390, EN+FR, province step (0) + interests/vision step (last, tall): A1 #btn-next fully visible (top >= header bottom, bottom <= panel top) at #step-body scroll 0/mid/max A2 #btn-next never overlaps #panel (next.bottom <= panel.top + 1) nor any .opt A3 at max scroll the last .opt is fully visible in the scroll area (lastOpt.bottom <= stepBody.bottom - 4) A4 no document scroll on ANY quiz step — walks all 14 single + 17 couple steps, both languages A5 panel fully visible, 4 chips; sheet expand → scrim on + insight; scrim tap collapses A6 Back + "i / n" counter + progress bar visible at top Usage: python3 audit/appshell_verify.py BASE OUTDIR TAG Prints APPSHELL-VERIFY-OK / -FAIL; writes OUTDIR/verify-TAG.json (+ max-scroll shots). """ import json, os, re, sys from playwright.sync_api import sync_playwright BASE = sys.argv[1].rstrip('/') + '/' OUT = sys.argv[2] TAG = sys.argv[3] os.makedirs(OUT, exist_ok=True) CHROME = '/home/ubuntu/.cache/ms-playwright/chromium-1243/chrome-linux64/chrome' LS = 'retire-planner-v1' SINGLE = { "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"] } COUPLE = dict(SINGLE, household="couple", partner={"age": 43, "income": 62000, "rrsp": 120000, "tfsa": 30000, "nonreg": 15000, "monthlyContribution": 300}) DEVICES = [('iphone14', 390, 844, 3.0), ('pixel', 360, 800, 2.75), ('se', 320, 700, 2.0)] UA = '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' M = """(pos) => { const r = e => { const b = e.getBoundingClientRect(); return {l:b.left, t:b.top, r:b.right, b:b.bottom, w:b.width, h:b.height}; }; const g = s => document.querySelector(s); const sb = g('#step-body'); if (pos !== null) sb.scrollTo({top: pos, behavior: 'instant'}); const next = r(g('#btn-next')), panel = r(g('#panel')), hdr = r(g('.hdr')), sbr = r(sb); const opts = [...document.querySelectorAll('#step-body .opt')].map(e => r(e)); const R = v => Math.round(v * 10) / 10; const ov = (a, b) => Math.max(0, Math.min(a.r, b.r) - Math.max(a.l, b.l)) * Math.max(0, Math.min(a.b, b.b) - Math.max(a.t, b.t)); return { vh: innerHeight, vw: innerWidth, docSH: document.documentElement.scrollHeight, hdrB: R(hdr.b), next: {t: R(next.t), b: R(next.b), l: R(next.l), r: R(next.r)}, panel: {t: R(panel.t), b: R(panel.b)}, sb: {t: R(sbr.t), b: R(sbr.b), scrollTop: R(sb.scrollTop), scrollH: sb.scrollHeight, clientH: sb.clientHeight}, nOpts: opts.length, lastOpt: opts.length ? {t: R(opts[opts.length-1].t), b: R(opts[opts.length-1].b)} : null, // visual overlap only: an .opt is clipped by #step-body's overflow box optOverlaps: opts.map((o, i) => { const c = {l: Math.max(o.l, sbr.l), r: Math.min(o.r, sbr.r), t: Math.max(o.t, sbr.t), b: Math.min(o.b, sbr.b)}; return {i, a: R(ov(next, c))}; }).filter(x => x.a > 1), nextVsPanel: R(next.b - panel.t), // <= 1 required nextTopVsHdr: R(next.t - hdr.b), // >= 0 required nextVsViewport: R(next.b - innerHeight), // <= 0.5 required }; }""" WALK = """(n) => { const R = []; const count = () => document.querySelector('#step-count').textContent.trim(); const doc = () => document.documentElement.scrollHeight - innerHeight; R.push({step: 0, count: count(), docOver: doc()}); for (let i = 1; i < n; i++) { document.getElementById('btn-next').click(); R.push({step: i, count: count(), docOver: doc()}); if (document.querySelector('#scr-results') && !document.querySelector('#scr-results').hidden) { R[R.length-1].leakedToResults = true; break; } } return R; }""" fails, total, data = 0, 0, {} def chk(name, cond, extra=''): global total, fails total += 1 if not cond: fails += 1 print(('PASS ' if cond else 'FAIL ') + name + (f' [{extra}]' if extra != '' else '')) data.setdefault('checks', []).append({'name': name, 'ok': bool(cond), 'extra': str(extra)[:200]}) def set_state(page, a, step, lang): page.evaluate("""([ls, a, step, lang]) => { localStorage.setItem(ls, JSON.stringify({a: a, step: step, screen: 'quiz', lang: lang})); }""", [LS, a, step, lang]) 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 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 lang in ('en', 'fr'): # ---- A1/A2/A3 detailed: province + vision, scroll 0/mid/max ---- for stag, step in (('province', 0), ('vision', 13)): set_state(page, SINGLE, step, lang) page.reload(wait_until='networkidle') page.wait_for_timeout(650) base = page.evaluate(M, 0) span = base['sb']['scrollH'] - base['sb']['clientH'] for pos in sorted(set([0, span // 2, span])): m = page.evaluate(M, pos) m['lastOptVisibleAtMax'] = (m['lastOpt']['b'] <= m['sb']['b'] - 4) and (m['lastOpt']['t'] >= m['sb']['t'] - 0.5) data[f'{dev}:{lang}:{stag}:scroll{pos}'] = m p = f'{dev}/{lang}/{stag}@{pos}' chk(p + ' CTA below header', m['nextTopVsHdr'] >= -0.5, m['next']['t']) chk(p + ' CTA above panel', m['nextVsPanel'] <= 1, f"next.b {m['next']['b']} panel.t {m['panel']['t']}") chk(p + ' CTA in viewport', m['nextVsViewport'] <= 0.5, m['next']['b']) chk(p + ' CTA clears every .opt', len(m['optOverlaps']) == 0, m['optOverlaps']) chk(p + ' doc no scroll', m['docSH'] <= m['vh'] + 1, f"docSH {m['docSH']} vh {m['vh']}") if pos == span: chk(p + ' last .opt fully visible at max scroll', m['lastOptVisibleAtMax'], f"lastOpt.b {m['lastOpt']['b']} sb.b {m['sb']['b']}") page.screenshot(path=os.path.join(OUT, f'{dev}-{lang}-{stag}-maxscroll.png')) # ---- A5 panel/chips/sheet + A6 header controls (province step) ---- set_state(page, SINGLE, 0, lang) page.reload(wait_until='networkidle') page.wait_for_timeout(600) chips = page.evaluate("() => [...document.querySelectorAll('#panel-chips .chip')].map(c => ({k: (c.querySelector('.k')||{}).textContent, v: (c.querySelector('.v')||{}).textContent}))") chk(f'{dev}/{lang} panel has 4 filled chips', len(chips) == 4 and all(c.get('k') and c.get('v') for c in chips), chips) pv = page.evaluate("""() => { const p = document.querySelector('#panel').getBoundingClientRect(); const b = document.getElementById('btn-back').getBoundingClientRect(); const c = document.getElementById('step-count'); const cr = c.getBoundingClientRect(); const pr = document.querySelector('.prog').getBoundingClientRect(); const hdrB = document.querySelector('.hdr').getBoundingClientRect().b || document.querySelector('.hdr').getBoundingClientRect().bottom; return {pb: p.bottom, vh: innerHeight, pt: p.top, back: {t: b.top, b: b.bottom, w: b.width}, cnt: c.textContent.trim(), cntW: cr.width, cntT: cr.top, prog: {h: pr.height, t: pr.top}}; }""") chk(f'{dev}/{lang} panel fully visible', pv['pb'] <= pv['vh'] + 1 and pv['pt'] >= 0, pv) chk(f'{dev}/{lang} Back visible under header', pv['back']['w'] > 0 and pv['back']['t'] >= 0, pv['back']) chk(f'{dev}/{lang} counter visible "i / n"', re.search(r'\d+\s*/\s*\d+', pv['cnt']) and pv['cntW'] > 0, pv['cnt']) chk(f'{dev}/{lang} progress bar visible', pv['prog']['h'] >= 4, pv['prog']) page.click('.panel-h'); page.wait_for_timeout(400) op = page.evaluate("""() => ({open: document.querySelector('#panel').classList.contains('open'), scrim: getComputedStyle(document.querySelector('#scrim')).display, ins: (() => { const i = document.querySelector('#panel-insight'); const s = getComputedStyle(i); return {disp: s.display, txt: i.textContent.trim().length, b: i.getBoundingClientRect().b ?? i.getBoundingClientRect().bottom}; })(), pb: document.querySelector('#panel').getBoundingClientRect().bottom, vh: innerHeight, nb: document.getElementById('btn-next').getBoundingClientRect().b})""") chk(f'{dev}/{lang} sheet opens: scrim on + insight readable', op['open'] and op['scrim'] != 'none' and op['ins']['disp'] != 'none' and op['ins']['txt'] > 10, op) chk(f'{dev}/{lang} sheet open: panel in viewport, CTA still above it', op['pb'] <= op['vh'] + 1, op) page.screenshot(path=os.path.join(OUT, f'{dev}-{lang}-sheet-open.png')) page.click('#scrim'); page.wait_for_timeout(350) cl = page.evaluate("""() => ({open: document.querySelector('#panel').classList.contains('open'), scrim: getComputedStyle(document.querySelector('#scrim')).display})""") chk(f'{dev}/{lang} scrim tap collapses', (not cl['open']) and cl['scrim'] == 'none', cl) # ---- A4 doc-scroll walk over ALL steps (single 14 + couple 17) ---- for mode, a, n in (('single', SINGLE, 14), ('couple', COUPLE, 17)): set_state(page, a, 0, lang) page.reload(wait_until='networkidle') page.wait_for_timeout(550) walk = page.evaluate(WALK, n) data[f'{dev}:{lang}:{mode}:walk'] = walk chk(f'{dev}/{lang} {mode} walk visits {n} steps', len(walk) == n and not any(s.get('leakedToResults') for s in walk), f"visited {len(walk)}, counts {walk[0]['count']}..{walk[-1]['count']}") bad = [s for s in walk if s['docOver'] > 1] chk(f'{dev}/{lang} {mode}: no doc scroll on all steps', len(bad) == 0, bad[:4] if bad else f"max docOver {max(s['docOver'] for s in walk)}") ctx.close() browser.close() with open(os.path.join(OUT, f'verify-{TAG}.json'), 'w') as f: json.dump(data, f, indent=1, ensure_ascii=False, default=str) print(f'checks: {total - fails}/{total}') print('APPSHELL-VERIFY-OK' if fails == 0 else 'APPSHELL-VERIFY-FAIL') sys.exit(0 if fails == 0 else 1)