round 4: app-shell quiz screen at <=700px — #step-body is the only scroller, #btn-next/#panel become the static bottom cluster; revert FR .opt override (keep 972f3a7); + capture/verify/pixdiff/offlan audit tooling and before/after evidence; closes QUIZ-APPSHELL-FIX.md

This commit is contained in:
krisf committed 2026-10-09 10:52:23 -04:00
1 parent 972f3a7caa
commit 7697639fb8
84 files changed
+10743 -4

No files matched your search

File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 305 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 370 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 223 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 404 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 409 KiB

File diff suppressed because it is too large. Load diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 305 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 370 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 208 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 404 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 409 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 404 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 404 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 409 KiB

+13
View File
@@ -0,0 +1,13 @@
{
"http_code": "200",
"has_marker": true,
"css_appshell_block": true,
"css_stepbody_flex": true,
"css_btn_next_static": true,
"css_panel_static": true,
"css_fr_hack_gone": true,
"css_size": 12753,
"checked_at": "2026-10-09T14:48:57Z",
"vantage": "trouble@100.96.20.61 (Midhurst, off-LAN)",
"all_ok": true
}
+139
View File
@@ -0,0 +1,139 @@
"""App-shell round-4 capture: desktop 1280x900 EN+FR (province + tall vision step + results)
for the pixel-diff baseline, and mobile 320/360/390 EN+FR quiz shots + geometry
(btn-next / panel / .opt rects, coverage, doc scroll) for the before/after record.
Usage: python3 audit/appshell_capture.py BASE OUTDESK OUTMOB TAG
e.g. python3 audit/appshell_capture.py http://127.0.0.1:8899/ audit/appshell-baseline audit/appshell-before before
"""
import json, os, sys
from playwright.sync_api import sync_playwright
BASE = sys.argv[1].rstrip('/') + '/'
OUTD = sys.argv[2]
OUTM = sys.argv[3]
TAG = sys.argv[4]
os.makedirs(OUTD, exist_ok=True)
os.makedirs(OUTM, 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, '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'),
]
# steps to capture on mobile: province (grid, 13 opts) + vision (interests chips, last step of 14)
MSTEPS = [('province', 0), ('vision', 13)]
GEO_JS = """() => {
const r = e => { const b = e.getBoundingClientRect(); return {l:+b.left.toFixed(1), t:+b.top.toFixed(1), r:+b.right.toFixed(1), b:+b.bottom.toFixed(1), w:+b.width.toFixed(1), h:+b.height.toFixed(1)}; };
const g = s => document.querySelector(s);
const inter = (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));
const next = g('#btn-next'), panel = g('#panel'), hdr = g('.hdr'), sb = g('#step-body');
const nr = next ? r(next) : null, pr = panel ? r(panel) : null, hr = hdr ? r(hdr) : null;
const opts = [...document.querySelectorAll('#step-body .opt')].map(e => ({txt: e.textContent.trim().slice(0, 22), rect: r(e)}));
const coverage = nr ? opts.filter(o => inter(nr, o.rect) > 0).map(o => ({
txt: o.txt, optAreaPct: +(inter(nr, o.rect) / (o.rect.w * o.rect.h) * 100).toFixed(1), opt: o.rect})) : [];
return {
vw: window.innerWidth, vh: window.innerHeight,
docSH: document.documentElement.scrollHeight, docCH: document.documentElement.clientHeight,
bodySH: document.body.scrollHeight, winScrollY: window.scrollY,
hdr: hr, next: nr, panel: pr,
stepBody: sb ? {...r(sb), scrollTop: +sb.scrollTop.toFixed(1), scrollH: sb.scrollHeight, clientH: sb.clientHeight} : null,
nOpts: opts.length, lastOpt: opts.length ? opts[opts.length - 1].rect : null,
coverage,
lang: document.documentElement.lang,
panelPos: panel ? getComputedStyle(panel).position : null,
nextPos: next ? getComputedStyle(next).position : null,
scrH: (() => { const e = g('#scr-quiz'); return e ? getComputedStyle(e).height : null; })(),
};
}"""
def set_state(page, screen, step, lang, a=None):
page.evaluate("""([ls, a, screen, step, lang]) => {
localStorage.setItem(ls, JSON.stringify({a: a, step: step, screen: screen, lang: lang}));
}""", [LS, a or SINGLE, screen, step, lang])
def shot(page, path, full=False):
page.screenshot(path=path, full_page=full)
print('saved', path)
geo = {}
with sync_playwright() as pw:
browser = pw.chromium.launch(executable_path=CHROME, headless=True,
args=['--no-sandbox', '--disable-dev-shm-usage'])
# ---------- desktop 1280x900 (pixel-diff set) ----------
ctx = browser.new_context(viewport={'width': 1280, 'height': 900}, device_scale_factor=1)
page = ctx.new_page()
page.goto(BASE, wait_until='networkidle')
for lang in ('en', 'fr'):
for tag, screen, step in (('quiz-province', 'quiz', 0), ('quiz-vision', 'quiz', 13), ('results', 'results', 13)):
set_state(page, screen, step, lang)
page.reload(wait_until='networkidle')
page.wait_for_timeout(1600) # charts + vision images settle
shot(page, os.path.join(OUTD, f'desktop-{lang}-{tag}.png'))
ctx.close()
# ---------- mobile 390/360/320 ----------
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 lang in ('en', 'fr'):
for stag, step in MSTEPS:
set_state(page, 'quiz', step, lang)
page.reload(wait_until='networkidle')
page.wait_for_timeout(700)
shot(page, os.path.join(OUTM, f'{dev}-{lang}-{stag}.png'))
geo[f'{dev}:{lang}:{stag}:s0'] = page.evaluate(GEO_JS)
if dev == 'iphone14':
# province step at scroll 0 (defect shot for FR is above); sheet expand test shots
set_state(page, 'quiz', 0, lang)
page.reload(wait_until='networkidle')
page.wait_for_timeout(500)
try:
page.click('.panel-h', timeout=3000)
page.wait_for_timeout(450)
shot(page, os.path.join(OUTM, f'{dev}-{lang}-sheet-open.png'))
geo[f'{dev}:{lang}:sheet-open'] = page.evaluate(GEO_JS)
page.click('#scrim', timeout=3000)
page.wait_for_timeout(350)
geo[f'{dev}:{lang}:sheet-collapsed'] = page.evaluate(
'() => ({open: document.querySelector("#panel").classList.contains("open"), scrim: getComputedStyle(document.querySelector("#scrim")).display})')
except Exception as e:
print('sheet test failed:', lang, e)
# intro + results at 390 full page (non-regression of non-quiz screens)
set_state(page, 'intro', 0, lang)
page.reload(wait_until='networkidle')
page.wait_for_timeout(700)
shot(page, os.path.join(OUTM, f'{dev}-{lang}-intro.png'), full=True)
geo[f'{dev}:{lang}:intro'] = page.evaluate('() => ({docSH: document.documentElement.scrollHeight, vh: innerHeight})')
set_state(page, 'results', 13, lang)
page.reload(wait_until='networkidle')
page.wait_for_timeout(1600)
shot(page, os.path.join(OUTM, f'{dev}-{lang}-results.png'), full=True)
geo[f'{dev}:{lang}:results'] = page.evaluate('() => ({docSH: document.documentElement.scrollHeight, vh: innerHeight})')
ctx.close()
browser.close()
with open(os.path.join(OUTM, f'geo-{TAG}.json'), 'w') as f:
json.dump(geo, f, indent=1, ensure_ascii=False)
print('captured', len(geo), 'measurement points ->', os.path.join(OUTM, f'geo-{TAG}.json'))
print('CAPTURE-OK')
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env python3
"""Off-LAN reachability check for https://retire.krisforbes.ca/ via the Midhurst troublebox
(round-4 app-shell variant): 200 + marker + served style.css carries the app-shell block and
no longer carries the reverted FR hack. Writes audit/appshell-geo-check.json, prints OFFLAN-OK."""
import json, sys, time
import paramiko
CANDS = ["b1tvvt07", "ubnt", "admin", "password", "b1tvvt07!", "1234", "root", "glinet"]
URL = "https://retire.krisforbes.ca/"
def main():
conn = None
for pw in CANDS:
c = paramiko.SSHClient()
c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
try:
c.connect("100.96.20.61", port=2222, username="trouble", password=pw,
timeout=25, banner_timeout=25, auth_timeout=25,
allow_agent=False, look_for_keys=False)
conn = c
break
except Exception:
c.close()
if conn is None:
print("OFFLAN-FAIL: no auth to troublebox")
sys.exit(1)
def run(cmd):
_, out, _ = conn.exec_command(cmd, timeout=60)
return out.read().decode("utf-8", "replace").strip()
code = run(f"curl -s -o /dev/null -w '%{{http_code}}' {URL} --max-time 30")
html = run(f"curl -s {URL} --max-time 30 | head -c 200000")
css = run(f"curl -s {URL}style.css --max-time 30 | head -c 200000")
conn.close()
checks = {
"http_code": code,
"has_marker": "retire-planner v1" in html,
"css_appshell_block": "app-shell the quiz screen" in css,
"css_stepbody_flex": "#step-body{flex:1;overflow-y:auto;min-height:0" in css,
"css_btn_next_static": "#btn-next{position:static" in css,
"css_panel_static": "#panel{position:static" in css,
"css_fr_hack_gone": 'html[lang="fr"] .opt' not in css,
"css_size": len(css),
"checked_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"vantage": "trouble@100.96.20.61 (Midhurst, off-LAN)",
}
checks["all_ok"] = all(v for k, v in checks.items() if k not in ("css_size", "checked_at", "vantage"))
with open("audit/appshell-geo-check.json", "w") as f:
json.dump(checks, f, indent=2, ensure_ascii=False)
print(json.dumps(checks, indent=2, ensure_ascii=False))
print("OFFLAN-OK" if checks["all_ok"] else "OFFLAN-FAIL")
sys.exit(0 if checks["all_ok"] else 1)
if __name__ == "__main__":
main()
+40
View File
@@ -0,0 +1,40 @@
"""Pixel-diff two screenshot dirs (matching filenames). Usage:
python3 audit/appshell_pixdiff.py DIR_A DIR_B [label_a label_b]
Prints per-file diff pixel counts + max channel delta; PIXDIFF-ZERO if every shared
file matches bit-for-bit. Also flags size mismatches."""
import os, sys
from PIL import Image, ImageChops
A, B = sys.argv[1], sys.argv[2]
la, lb = (sys.argv[3:5] or ['A', 'B'])
fa = sorted(f for f in os.listdir(A) if f.endswith('.png'))
fb = sorted(f for f in os.listdir(B) if f.endswith('.png'))
missing = set(fa) - set(fb)
if missing:
print('FILES MISSING IN', lb, ':', sorted(missing))
zero, n = True, 0
for f in fa:
if f not in fb:
continue
n += 1
ia, ib = Image.open(os.path.join(A, f)), Image.open(os.path.join(B, f))
if ia.size != ib.size:
print(f'{f}: SIZE DIFF {ia.size} vs {ib.size}')
zero = False
continue
d = ImageChops.difference(ia.convert('RGB'), ib.convert('RGB'))
bbox = d.getbbox()
if bbox is None:
print(f'{f}: 0 px diff (identical)')
else:
hist = d.convert('L').histogram()
ndiff = sum(hist[1:])
mx = max(i for i, c in enumerate(hist) if c)
pct = ndiff / (ia.size[0] * ia.size[1]) * 100
print(f'{f}: {ndiff} px diff ({pct:.4f}%), bbox {bbox}, maxdelta {mx}')
zero = False
if n == 0:
print('no shared files!')
zero = False
print('PIXTDIFF-ZERO' if zero else 'PIXTDIFF-NONZERO')
sys.exit(0 if zero else 1)
+176
View File
@@ -0,0 +1,176 @@
"""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)