Bilingual EN/FR: i18n.js dict+t(), lang state+payload, header toggle, FR copy, Intl formatters; i18n.test.js + bilingual smoke; FR mobile audit evidence + BILINGUAL-REPORT.md
No files matched your search
|
After Width: | Height: | Size: 299 KiB |
|
After Width: | Height: | Size: 137 KiB |
|
After Width: | Height: | Size: 187 KiB |
|
After Width: | Height: | Size: 155 KiB |
|
After Width: | Height: | Size: 134 KiB |
|
After Width: | Height: | Size: 236 KiB |
|
After Width: | Height: | Size: 327 KiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 166 KiB |
|
After Width: | Height: | Size: 196 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 305 KiB |
|
After Width: | Height: | Size: 142 KiB |
|
After Width: | Height: | Size: 193 KiB |
|
After Width: | Height: | Size: 161 KiB |
|
After Width: | Height: | Size: 139 KiB |
|
After Width: | Height: | Size: 240 KiB |
|
After Width: | Height: | Size: 332 KiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 171 KiB |
|
After Width: | Height: | Size: 201 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 304 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 409 KiB |
|
After Width: | Height: | Size: 370 KiB |
|
After Width: | Height: | Size: 150 KiB |
|
After Width: | Height: | Size: 198 KiB |
|
After Width: | Height: | Size: 169 KiB |
|
After Width: | Height: | Size: 145 KiB |
|
After Width: | Height: | Size: 237 KiB |
|
After Width: | Height: | Size: 370 KiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 346 KiB |
|
After Width: | Height: | Size: 175 KiB |
|
After Width: | Height: | Size: 198 KiB |
|
After Width: | Height: | Size: 303 KiB |
|
After Width: | Height: | Size: 245 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 109 KiB |
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 155 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"http_code": "200",
|
||||
"has_marker": true,
|
||||
"has_lang_toggle": true,
|
||||
"has_i18n_script": true,
|
||||
"i18n_js_fr_strings": true,
|
||||
"i18n_js_size": 28788,
|
||||
"checked_at": "2026-10-09T14:01:51Z",
|
||||
"vantage": "trouble@100.96.20.61 (Midhurst, off-LAN)",
|
||||
"all_ok": true
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
"""Bilingual FR mobile audit for https://retire.krisforbes.ca — FR screens at 320/360/390
|
||||
plus desktop 1280 (FR) and EN desktop (regression evidence). Sets localStorage
|
||||
{a, step, screen, lang:'fr'}, captures screenshots and programmatic geometry:
|
||||
- document horizontal overflow (scrollWidth vs innerWidth)
|
||||
- every chart <text> getBBox() inside the 640x270 viewBox
|
||||
- panel/chips/CTA visibility, table header cells visible + font size
|
||||
Output: audit/bilingual-fr/*.png + audit/bilingual-fr/geo.json + FR-AUDIT-OK/FAIL.
|
||||
"""
|
||||
import json, os, sys
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
OUT = sys.argv[1] if len(sys.argv) > 1 else 'audit/bilingual-fr'
|
||||
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'
|
||||
|
||||
ANSWERS = {
|
||||
"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"]
|
||||
}
|
||||
|
||||
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'),
|
||||
('desktop', 1280, 900, 1.0, 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36'),
|
||||
]
|
||||
|
||||
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', 'intro', 'intro', 0, None),
|
||||
('pixel', 'quiz-province', 'quiz', 0, None),
|
||||
('pixel', 'results-charts', 'results', 0, 'charts'),
|
||||
('pixel', 'results-table', 'results', 0, 'table'),
|
||||
('se', 'intro', 'intro', 0, None),
|
||||
('se', 'quiz-province', 'quiz', 0, None),
|
||||
('se', 'quiz-income', 'quiz', 3, None),
|
||||
('se', 'quiz-sheet-open', 'quiz', 3, 'open_sheet'),
|
||||
('se', 'results-top', 'results', 0, None),
|
||||
('se', 'results-charts', 'results', 0, 'charts'),
|
||||
('se', 'results-table', 'results', 0, 'table'),
|
||||
('desktop', 'intro-fr', 'intro', 0, None),
|
||||
('desktop', 'results-fr-top', 'results', 0, None),
|
||||
('desktop', 'results-fr-charts', 'results', 0, 'charts'),
|
||||
('desktop', 'results-en-top', 'results', 0, 'en_lang'),
|
||||
]
|
||||
|
||||
GEO_JS = """() => {
|
||||
const vw = window.innerWidth, vh = window.innerHeight;
|
||||
const docSW = document.documentElement.scrollWidth, bodySW = document.body.scrollWidth;
|
||||
const vis = (e) => { if (!e) return false; const b = e.getBoundingClientRect();
|
||||
return b.width > 0 && b.height > 0 && b.bottom > 0 && b.top < vh; };
|
||||
const g = s => document.querySelector(s);
|
||||
// every chart <text> inside the viewBox (0..640 x 0..270)
|
||||
const svgProblems = [];
|
||||
let g1first = null;
|
||||
document.querySelectorAll('#g1 svg, #g2 svg, #g3 svg, #g4 svg').forEach(svg => {
|
||||
const rect = svg.getBoundingClientRect();
|
||||
const scale = rect.width / 640;
|
||||
svg.querySelectorAll('text').forEach(tx => {
|
||||
const b = tx.getBBox();
|
||||
if (b.x < -0.5 || b.y < -0.5 || b.x + b.width > 640.5 || b.y + b.height > 270.5) {
|
||||
svgProblems.push({id: svg.closest('[id]').id, txt: tx.textContent.slice(0, 30), x: Math.round(b.x), w: Math.round(b.width)});
|
||||
}
|
||||
const id = svg.closest('[id]').id;
|
||||
if (id === 'g1' && !g1first) {
|
||||
g1first = {fontSize: parseFloat(tx.getAttribute('font-size') || 11), renderedPx: +(rect.width / 640 * (parseFloat(tx.getAttribute('font-size') || 11))).toFixed(1)};
|
||||
}
|
||||
});
|
||||
});
|
||||
const ths = [...document.querySelectorAll('#yr-table th')];
|
||||
return {
|
||||
vw, docSW, bodySW,
|
||||
docOverflow: Math.max(docSW, bodySW) > vw + 1,
|
||||
g1first, svgTextOutsideViewBox: svgProblems,
|
||||
panelVisible: vis(g('.panel')), chipsVisible: vis(g('#panel-chips')),
|
||||
chipFontPx: (() => { const k = g('#panel-chips .chip .k'); return k ? parseFloat(getComputedStyle(k).fontSize) : null; })(),
|
||||
panelH: (() => { const p = g('.panel'); return p ? {h: Math.round(p.getBoundingClientRect().height), maxVh: +(p.getBoundingClientRect().height / vh * 100).toFixed(1)} : null; })(),
|
||||
btnNextVisible: vis(g('#btn-next')),
|
||||
btnShareVisible: vis(g('#btn-share')),
|
||||
btnShareOnPage: (() => { const e = g('#btn-share'); if (!e) return false; const b = e.getBoundingClientRect(); return b.width > 0 && b.height > 0; })(),
|
||||
ctaOnPage: (() => { const e = g('#btn-begin'); if (!e) return false; const b = e.getBoundingClientRect(); return b.width > 0 && b.height > 0; })(),
|
||||
thCount: ths.length,
|
||||
thHidden: ths.filter(t => getComputedStyle(t).display === 'none' || !t.offsetWidth).length,
|
||||
thFontPx: ths.length ? parseFloat(getComputedStyle(ths[0]).fontSize) : null,
|
||||
tbl: (() => { const t = g('.tbl-wrap'); return t ? {clientW: t.clientWidth, scrollW: t.scrollWidth} : null; })(),
|
||||
hdr: (() => { const h = g('.hdr'); return h ? Math.round(h.getBoundingClientRect().height) : null; })(),
|
||||
langBtnVisible: vis(g('#lang-fr')),
|
||||
frTextSample: (document.body.innerText.match(/ RPC|REER|CELI|retraite/g) || []).length
|
||||
};
|
||||
}"""
|
||||
|
||||
|
||||
def set_state(page, screen, step, lang):
|
||||
page.evaluate("""([ls, a, screen, step, lang]) => {
|
||||
localStorage.setItem(ls, JSON.stringify({a: a, step: step, screen: screen, lang: lang}));
|
||||
}""", [LS, ANSWERS, screen, step, lang])
|
||||
|
||||
|
||||
results = {}
|
||||
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=(dev != 'desktop'),
|
||||
has_touch=(dev != 'desktop'), 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]:
|
||||
lang = 'en' if action == 'en_lang' else 'fr'
|
||||
set_state(page, screen, step, lang)
|
||||
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:', tag, e)
|
||||
elif action == 'charts':
|
||||
el = page.locator('#g1')
|
||||
if el.count():
|
||||
el.scroll_into_view_if_needed()
|
||||
page.wait_for_timeout(900) # let G4 async render
|
||||
elif action == 'table':
|
||||
page.evaluate("() => { const d = document.querySelector('details.yearly'); if (d) d.open = true; }")
|
||||
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 and dev != 'desktop'))
|
||||
print('saved', path)
|
||||
# geometry per device on the FR results screen (full app rendered)
|
||||
key = f'{dev}:{tag}'
|
||||
results[key] = page.evaluate(GEO_JS)
|
||||
print(key, json.dumps({k: results[key][k] for k in
|
||||
('docOverflow', 'docSW', 'vw', 'thCount', 'thHidden', 'thFontPx')}))
|
||||
ctx.close()
|
||||
browser.close()
|
||||
|
||||
# ---- evaluate FR constraints ----
|
||||
checks = []
|
||||
def chk(name, cond, extra=''):
|
||||
checks.append((name, bool(cond), extra))
|
||||
print(('PASS ' if cond else 'FAIL ') + name + (' [' + str(extra)[:90] + ']' if extra != '' else ''))
|
||||
|
||||
for key, g in results.items():
|
||||
dev = key.split(':')[0]
|
||||
chk(key + ' no doc overflow', not g['docOverflow'], f"{g['docSW']}>{g['vw']}")
|
||||
chk(key + ' svg text inside viewBox', len(g['svgTextOutsideViewBox']) == 0, g['svgTextOutsideViewBox'][:2])
|
||||
if dev == 'desktop':
|
||||
continue
|
||||
if 'results-table' in key:
|
||||
# baseline Fix 5: ≤560px viewport hides 2 of 11 columns by design (non-reg, GIS)
|
||||
exp_hidden = 2 if g['vw'] <= 560 else 0
|
||||
chk(key + ' table headers visible', g['thCount'] == 11 and g['thHidden'] == exp_hidden,
|
||||
f"{g['thCount']-g['thHidden']}/{g['thCount']} visible @{g['thFontPx']}px ({exp_hidden} hidden by design)")
|
||||
chk(key + ' table scrolls (not clipped)', g['tbl'] and g['tbl']['scrollW'] > g['vw'], g['tbl'])
|
||||
chk(key + ' share CTA on page', g['btnShareOnPage'])
|
||||
if 'results' in key and 'results-table' not in key:
|
||||
chk(key + ' share CTA rendered on page', g['btnShareOnPage'])
|
||||
if 'quiz' in key:
|
||||
chk(key + ' Next CTA visible', g['btnNextVisible'])
|
||||
chk(key + ' panel visible', g['panelVisible'] and g['chipsVisible'], f"chip {g['chipFontPx']}px panel {g['panelH']}")
|
||||
chk(key + ' lang toggle visible', g['langBtnVisible'])
|
||||
if 'intro' in key:
|
||||
chk(key + ' intro CTA rendered', g['ctaOnPage'])
|
||||
|
||||
ok = all(c[1] for c in checks)
|
||||
with open(os.path.join(OUT, 'geo.json'), 'w') as f:
|
||||
json.dump({'geo': results, 'checks': [{'name': n, 'ok': o, 'extra': str(e)} for n, o, e in checks]},
|
||||
f, indent=2, ensure_ascii=False)
|
||||
print('checks:', sum(1 for c in checks if c[1]), '/', len(checks))
|
||||
print('FR-AUDIT-OK' if ok else 'FR-AUDIT-FAIL')
|
||||
sys.exit(0 if ok else 1)
|
||||
@@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Off-LAN reachability check for https://retire.krisforbes.ca/ via the Midhurst troublebox.
|
||||
SSH to trouble@100.96.20.61:2222, curl the site from there (outside the LAN), verify 200 +
|
||||
served bilingual markers. Writes audit/bilingual-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")
|
||||
i18n = run(f"curl -s {URL}i18n.js --max-time 30 | head -c 200000")
|
||||
conn.close()
|
||||
|
||||
checks = {
|
||||
"http_code": code,
|
||||
"has_marker": "retire-planner v1" in html,
|
||||
"has_lang_toggle": "lang-toggle" in html,
|
||||
"has_i18n_script": "i18n.js" in html,
|
||||
"i18n_js_fr_strings": ("Planificateur" in i18n) and ("D\u00e9penses" in i18n or "RPC" in i18n),
|
||||
"i18n_js_size": len(i18n),
|
||||
"checked_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||
"vantage": "trouble@100.96.20.61 (Midhurst, off-LAN)",
|
||||
}
|
||||
checks["all_ok"] = (code == "200" and checks["has_marker"] and checks["has_lang_toggle"]
|
||||
and checks["has_i18n_script"] and checks["i18n_js_fr_strings"])
|
||||
with open("audit/bilingual-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()
|
||||