// Headless smoke test for site/i18n.js + site/app.js — tiny DOM shim, drives the full flow // in BOTH languages (EN run on defaults, FR run with lang:'fr' persisted). // Run: node tests/ui.smoke.js (exits 0 + prints SMOKE-OK) 'use strict'; const fs = require('fs'), path = require('path'); const SITE = path.join(__dirname, '..', 'site'); const LS = 'retire-planner-v1'; /* normalize narrow-nbsp/nbsp → space (fr-CA Intl + frNorm typography) for regex checks */ const N = s => String(s == null ? '' : s).replace(/[\u202f\u00a0]/g, ' '); let registry = {}; function makeEl(id) { const el = { id, _html: '', _l: {}, style: {}, dataset: {}, hidden: false, value: '', textContent: '', disabled: false, get innerHTML() { return this._html; }, set innerHTML(v) { this._html = String(v); }, addEventListener(t, fn) { this._l[t] = fn; }, setAttribute() { }, getAttribute() { return null; }, querySelector() { return makeEl(id + '>q'); }, querySelectorAll() { return []; }, contains() { return false; }, appendChild() { }, remove() { }, classList: { add() { }, remove() { }, toggle() { }, contains() { return false; } }, }; Object.defineProperty(el, 'onclick', { set(f) { this._l.click = f; }, get() { return this._l.click; } }); Object.defineProperty(el, 'oninput', { set(f) { this._l.input = f; }, get() { return this._l.input; } }); return el; } function q(sel) { const id = sel.replace(/^#/, ''); if (sel === '.panel-h') return null; return registry[id] || (registry[id] = makeEl(id)); } let store = {}; let createdEls = []; // shim ext: createElement tracking (validate() appends its .err div this way) function buildShim() { registry = {}; store = {}; createdEls = []; global.document = { title: '', querySelector: q, querySelectorAll: () => [], addEventListener() { }, createElement: (t) => { const e = makeEl('el-' + t); createdEls.push(e); return e; }, getElementById: (id) => registry[id] || (registry[id] = makeEl(id)), // shim ext: app.js scrim/panel sync (Fix 3) + i18n lang buttons documentElement: { style: { setProperty() { } }, setAttribute() { }, lang: 'en' }, // i18n applyI18n + app syncSheetH }; global.localStorage = { getItem: k => store[k] || null, setItem: (k, v) => { store[k] = String(v); }, removeItem: k => { delete store[k]; } }; global.location = { hash: '', pathname: '/index.html', origin: 'http://x' }; global.history = { replaceState() { } }; Object.defineProperty(global, 'navigator', { value: { language: 'en-CA' }, configurable: true }); global.window = { scrollTo() { }, print() { }, prompt() { } }; // prompt stub: Fix 7 share fallback global.confirm = () => true; global.fetch = () => Promise.resolve({ ok: true, json: () => Promise.resolve({}) }); } function load(lang, psState) { buildShim(); global.window.RetireEngine = require(path.join(SITE, 'engine', 'engine.js')); global.window.RetireCanada = require(path.join(SITE, 'engine', 'canada.js')); global.window.RetirePS = require(path.join(SITE, 'engine', 'pspension.js')); // persisted state in the target language: fresh answers, dropped at the quiz start store[LS] = psState ? JSON.stringify(psState) : JSON.stringify({ a: {}, step: 0, screen: 'quiz', lang: lang }); eval(fs.readFileSync(path.join(SITE, 'i18n.js'), 'utf8')); eval(fs.readFileSync(path.join(SITE, 'app.js'), 'utf8')); } let fails = 0, total = 0; function ok(name, cond, extra) { total++; console.log((cond ? ' PASS ' : ' FAIL ') + name + (extra !== undefined ? ' [' + String(extra).slice(0, 80) + ']' : '')); if (!cond) fails++; } const click = id => { const el = q('#' + id); if (!el._l.click) throw new Error('no click handler on #' + id); el._l.click(); }; /* drive the full quiz → results flow for one language, then run per-language checks. */ function run(lang, M, done) { console.log('\n--- ' + lang.toUpperCase() + ' ---'); load(lang); ok('intro wiring: begin button has handler', !!q('#btn-begin')._l.click); click('btn-begin'); ok('step 1 title', M.title.test(q('#step-body')._html), N(q('#step-body')._html).slice(0, 60)); ok('step count', /1 \/ 15/.test(q('#step-count').textContent), q('#step-count').textContent); ok('province step requires a pick (Next disabled)', q('#btn-next').disabled === true); ok('validation message', createdEls.some(e => M.needProvince.test(N(e.textContent)))); // emulate the option click path (shim querySelectorAll patched to hand back one opt) const opt = makeEl('opt'); opt.dataset.v = 'ON'; q('#step-body').querySelectorAll = (sel) => (sel === '.opt' ? [opt] : []); click('btn-begin'); // re-render step with patched querySelectorAll opt._l.click(); ok('province ON selectable', q('#btn-next').disabled === false); // walk all steps (original gating: disabled reflects step validity) let guard = 0, sawCpp = false, sawSeePlan = false; while (guard++ < 40 && !q('#btn-next').disabled) { const h = N(q('#step-body')._html); if (M.cppStep.test(h)) sawCpp = true; if (M.seePlan.test(q('#btn-next').textContent)) { sawSeePlan = true; break; } click('btn-next'); if (q('#scr-results').hidden === false) break; } ok('reached last step (See my plan / Voir mon plan)', sawSeePlan, 'steps=' + guard); ok('CPP start step seen', sawCpp); click('btn-next'); ok('reached results screen', q('#scr-results').hidden === false); const verdict = N(q('#verdict')._html); ok('verdict rendered', M.verdict.test(verdict), verdict.slice(0, 70)); ok('gap cards rendered', /gcard/.test(q('#gap-cards')._html)); ok('G1 chart is inline SVG', / 20); ok('year table header localized', M.tblHeader.test(q('#yr-table')._html)); ok('year summary localized', M.yrSummary.test(N(q('#yr-summary')._html)), N(q('#yr-summary')._html)); const stamp = N(q('#stamp').textContent); ok('stamp has 2026 + CPP max (locale format)', M.stamp2026.test(stamp) && M.stampCpp.test(stamp), stamp.slice(0, 80)); ok('vision card rendered', M.vision.test(q('#vision-card')._html)); ok('document.title localized', M.docTitle.test(N(document.title)), document.title.slice(0, 60)); // G4 async (setTimeout 30ms) + live panel (120ms) setTimeout(() => { ok('G4 scenario bars rendered', / { ok('what-if slider updates spending label (95k)', /95/.test(N(q('#wi-spend-o').textContent)), N(q('#wi-spend-o').textContent)); ok('wi label localized', M.wiCtrb.test(N(q('#wi-ctrb-o').textContent)), N(q('#wi-ctrb-o').textContent)); ok('localStorage saved with lang', JSON.parse(store[LS]).lang === lang); done(); }, 500); }, 250); } const EN = { title: /Where do you live/, docTitle: /Retire Planner/, needProvince: /Pick a province/, seePlan: /See my plan/, cppStep: /Start CPP/, verdict: /money|sustain|runs out/, g1Title: /Will it last/, g2Legend: /From savings/, tblHeader: /Withdraw/, yrSummary: /Year-by-year detail \(\d+ years\)/, stamp2026: /tax rates 2026/, stampCpp: /1,507\.65/, vision: /Retirement Vision/, g4Scen: /Current plan/, panel: /Savings at retirement/, wiCtrb: /\/mo/, }; const FR = { title: /Où habitez-vous/, docTitle: /Planificateur de retraite/, needProvince: /Choisissez une province/, seePlan: /Voir mon plan/, cppStep: /Débuter la RPC/, verdict: /manquez d.argent|dépenser en toute sûreté|tient jusqu/, g1Title: /épargne tiendra-t-elle/, g2Legend: /Épargne/, tblHeader: /Retrait/, yrSummary: /Détail année par année \(\d+ ans\)/, stamp2026: /barèmes 2026/, stampCpp: /1 507,65/, vision: /Vision de retraite/, g4Scen: /Plan actuel/, panel: /Épargne à la retraite/, wiCtrb: /\/mois/, }; /* PS module flow: federal public servant answers → results card + chart series, per language. */ function runPs(lang, M, done) { console.log('\n--- ' + lang.toUpperCase() + ' (PS module) ---'); load(lang, { a: { province: 'ON', age: 60, retireAge: 60, household: 'single', income: 0, rrsp: 100000, tfsa: 30000, nonreg: 20000, monthlyContribution: 0, spendingAnnual: 50000, psPension: true, psJoinYear: 2000, psServiceYears: 30, psAvgSalary: 100000, psOccupation: 'it', psItLevel: 'IT-02', psItStep: 1 }, step: 0, screen: 'results', lang: lang }); setTimeout(() => { const card = q('#ps-card'); ok('PS results card visible', card.hidden === false); const h = N(card._html); ok('PS card title localized', M.psCard.test(h), h.slice(0, 70)); ok('PS card pension at 60 (2%×100k×30 = 60,000)', M.psAt.test(h), (h.match(M.psAt) || [''])[0]); ok('PS card from-65 (lifetime 46,012.50)', M.psFrom.test(h), (h.match(M.psFrom) || [''])[0]); ok('PS card bridge line (13,987.50)', M.psBridge.test(h), (h.match(M.psBridge) || [''])[0]); ok('PS card sources link PSPC canada.ca', /href="https:\/\/www\.canada\.ca/.test(h)); ok('PS card IT agreement source (2024-12-22)', /2024-12-22/.test(h)); ok('PS series in G2 chart', M.psSeries.test(N(q('#g2')._html)), N(q('#g2')._html).slice(0, 40)); ok('PS insight in live panel', M.psInsight.test(N(q('#panel-insight')._html)), N(q('#panel-insight')._html).slice(0, 70)); done(); }, 400); } /* PS module quiz click-through — regression test (2026-10-09): typing into an initially-empty PS number step (join year / service / salary) MUST re-enable Next. Pre-fix, renderStep validated only at render, so the button stayed disabled and a real user was STUCK on the join step. */ function runPsQuiz(lang, done) { console.log('\n--- ' + lang.toUpperCase() + ' (PS quiz click-through) ---'); load(lang); setTimeout(() => { const opt = makeEl('opt'); opt.dataset.v = 'ON'; q('#step-body').querySelectorAll = (sel) => (sel === '.opt' ? [opt] : []); click('btn-begin'); // re-render step 0 with patched querySelectorAll const typed = {}; let guard = 0, joinDisabledBefore = null; while (guard++ < 40) { const st = JSON.parse(store[LS]); const step = st.step; const next = q('#btn-next'); if (step === 0 && !typed.prov) { opt.dataset.v = 'ON'; opt._l.click(); typed.prov = 1; continue; } if (step === 11 && !typed.ps) { typed.ps = 1; opt.dataset.v = 'yes'; opt._l.click(); ok('PS toggle: Yes picked', JSON.parse(store[LS]).a.psPension === true); continue; } if (step === 12 && !typed.join) { typed.join = 1; joinDisabledBefore = next.disabled; const f1 = q('#f1'); f1.value = '2005'; f1._l.input && f1._l.input.call(f1); ok('PS join step starts disabled (empty field)', joinDisabledBefore === true, 'disabled=' + joinDisabledBefore); ok('typing into empty PS step enables Next (regression: validate on input)', q('#btn-next').disabled === false, 'after=' + q('#btn-next').disabled); continue; } if (step === 13 && !typed.svc) { typed.svc = 1; const f1 = q('#f1'); f1.value = '30'; f1._l.input && f1._l.input.call(f1); continue; } if (step === 14 && !typed.sal) { typed.sal = 1; const f1 = q('#f1'); f1.value = '90000'; f1._l.input && f1._l.input.call(f1); continue; } if (step === 15 && !typed.occ) { typed.occ = 1; opt.dataset.v = 'it'; opt._l.click(); continue; } if (step === 16) { const a2 = JSON.parse(store[LS]).a; if (!a2.psItLevel) { opt.dataset.v = 'IT-03'; opt._l.click(); continue; } if (!a2.psItStep) { opt.dataset.v = '3'; opt._l.click(); continue; } } if (next.disabled) break; click('btn-next'); if (q('#scr-results').hidden === false) break; } const a = JSON.parse(store[LS]).a; ok('PS click-through reached results', q('#scr-results').hidden === false, 'guard=' + guard); ok('PS card rendered after click-through', !!q('#ps-card') && q('#ps-card').hidden === false); ok('PS state persisted (join 2005 / service 30 / IT-03 step 3)', a.psPension === true && a.psJoinYear === 2005 && a.psServiceYears === 30 && a.psOccupation === 'it' && a.psItLevel === 'IT-03' && a.psItStep === 3, JSON.stringify({ j: a.psJoinYear, s: a.psServiceYears, o: a.psOccupation, l: a.psItLevel, st: a.psItStep })); ok('IT step pick pre-filled salary (IT-03 s3 = 108,324)', a.psAvgSalary === 108324, a.psAvgSalary); done(); }, 400); } run('en', EN, () => run('fr', FR, () => runPs('en', { psCard: /federal public service pension/, psAt: /60,000/, psFrom: /46,012\.50/, psBridge: /13,987\.50/, psSeries: /Public service pension/, psInsight: /covers about/ }, () => runPs('fr', { psCard: /pension de la fonction publique fédérale/, psAt: /60[\s\u00a0\u202f]000/, psFrom: /46[\s\u00a0\u202f]012,50/, psBridge: /13[\s\u00a0\u202f]987,50/, psSeries: /Pension de la fonction publique/, psInsight: /couvre environ/ }, () => runPsQuiz('en', () => runPsQuiz('fr', () => { ok('EN privacy line present in index.html', /never leave your browser/.test(fs.readFileSync(path.join(SITE, 'index.html'), 'utf8'))); ok('FR privacy string present in i18n.js', /ne quittent jamais votre navigateur/.test(fs.readFileSync(path.join(SITE, 'i18n.js'), 'utf8'))); console.log(fails ? '\nSMOKE-FAIL: ' + fails + ' of ' + total : '\nSMOKE-OK all ui smoke checks passed (' + total + ')'); process.exit(fails ? 1 : 0); })))))) ;