===== INTEGRATION TEST ===== 'use strict'; const fs = require('fs'); const path = require('path'); const puppeteer = require( '/var/www/vhosts/aiwebpageseo.com/site-audit/node_modules/puppeteer' ); const SITE = process.env.SITE; const BASE = process.env.BASE; const STAGE = process.env.STAGE; const axe = fs.readFileSync( path.join(SITE, 'public/a11y-axe/axe.min.js'), 'utf8' ); const spacing = fs.readFileSync( path.join(STAGE, 'public/a11y-axe/text-spacing.js'), 'utf8' ); const runner = fs.readFileSync( path.join(STAGE, 'public/a11y-axe/run.js'), 'utf8' ); const chrome = '/var/www/vhosts/aiwebpageseo.com/.cache/puppeteer/chrome/linux-148.0.7778.97/chrome-linux64/chrome'; function fixture(restrictive) { const css = restrictive ? '' : 'p{word-spacing:0.2em;letter-spacing:0.15em}'; const paragraphStyle = restrictive ? ' style="word-spacing:1px!important;letter-spacing:1px!important"' : ''; return ` Accessibility integration test

Accessibility integration test

Several words are present here to test text spacing.

`; } async function runCase(browser, name, restrictive) { const page = await browser.newPage(); try { await page.setViewport({ width: 1280, height: 900 }); await page.setContent(''); const result = await page.evaluate( async ({ html, axe, spacing, runner }) => { return await new Promise((resolve, reject) => { const frame = document.createElement('iframe'); frame.style.cssText = 'width:1280px;height:900px;border:0'; const timeout = setTimeout(() => { window.removeEventListener('message', onMessage); frame.remove(); reject(new Error('ACT/axe runner timed out')); }, 25000); function onMessage(event) { if ( event.source !== frame.contentWindow || !event.data || event.data.a11yAxe !== 1 ) { return; } clearTimeout(timeout); window.removeEventListener('message', onMessage); frame.remove(); resolve(event.data); } window.addEventListener('message', onMessage); const doc = new DOMParser().parseFromString( html, 'text/html' ); const scripts = [ axe, 'var module={exports:{}};' + 'var exports=module.exports;\n' + spacing + '\nwindow.a11yTextSpacing=module.exports;', runner ]; for (const code of scripts) { const script = doc.createElement('script'); script.textContent = String(code).replace( /<\/script/gi, '<\\/script' ); doc.body.appendChild(script); } frame.srcdoc = '' + doc.documentElement.outerHTML; document.body.appendChild(frame); }); }, { html: fixture(restrictive), axe, spacing, runner } ); if (!result.ok) { throw new Error( name + ': axe runner failed: ' + result.error ); } const act = result.actResults || []; const violations = result.violations || []; if (act.length !== 2) { throw new Error( name + ': expected two ACT results, got ' + act.length ); } if (act.some(x => x.outcome === 'error')) { throw new Error( name + ': ACT execution error: ' + JSON.stringify(act) ); } for (const id of ['9e45ec', '24afc2']) { const entry = act.find(x => x.id === id); const acceptable = restrictive ? ['failed'] : ['passed', 'inapplicable']; if (!entry || !acceptable.includes(entry.outcome)) { throw new Error( name + ': ACT ' + id + ' expected ' + acceptable.join(' or ') + ', got ' + JSON.stringify(entry) ); } } const actFailures = violations.filter(v => /^act-(9e45ec|24afc2)$/.test(v.id) ); if (actFailures.length !== (restrictive ? 2 : 0)) { throw new Error( name + ': unexpected ACT violation count: ' + actFailures.length ); } const axeViolations = violations.filter(v => !v.id.startsWith('act-') ); if (!axeViolations.some(v => v.id === 'image-alt')) { // image-alt is not necessarily enabled in this site's // selected axe rule set. Report its absence, but do // not treat it as an integration failure. console.log( 'INFO: image-alt not included in selected axe rules' ); } const summary = { name, passed: true, act, actFailures: actFailures.map(v => v.id), axeViolations: axeViolations.map(v => v.id), axeVersion: result.version }; console.log('\nPASS: ' + name); console.log(JSON.stringify(summary, null, 2)); return summary; } finally { await page.close(); } } (async () => { const browser = await puppeteer.launch({ executablePath: chrome, headless: true, args: [ '--no-sandbox', '--disable-dev-shm-usage' ] }); try { const results = []; results.push( await runCase(browser, 'restrictive', true) ); results.push( await runCase(browser, 'normal', false) ); const output = path.join( BASE, 'regression/staged-integration-results.json' ); fs.writeFileSync( output, JSON.stringify(results, null, 2) ); console.log('\n=== STAGED INTEGRATION RESULTS ==='); console.log('Restrictive fixture: PASS'); console.log('Normal fixture: PASS'); console.log('ACT validators executed: YES'); console.log('Axe runner executed: YES'); console.log('Saved: ' + output); console.log('Production changed: NO'); console.log('PM2 restarted: NO'); } finally { await browser.close(); } })().catch(error => { console.error('\nTEST FAILED:'); console.error(error.stack || error); process.exitCode = 1; }); ===== PRODUCTION RENDERER ===== async function toolReport(html, url, builtByScripts, keyboard, fetchCss, inner) { await acquire(); let page = null, timer = null; try { const browser = await getBrowser(); page = await browser.newPage(); await page.setViewport({ width: 1280, height: 900 }); await page.setRequestInterception(true); page.on('request', req => { const u = req.url(); if (u.startsWith(AXE_URL)) { const f = AXE_FILES[u.slice(AXE_URL.length).split(/[?#]/)[0]]; if (!f) return req.abort().catch(() => {}); return fs.promises.readFile(f).then(body => req.respond({ status: 200, contentType: 'application/javascript', body })).catch(() => req.abort().catch(() => {})); } if (/^(file|data|about|blob):/i.test(u)) { // the only file Chrome may open is the tool page itself if (/^file:/i.test(u) && u.split('#')[0].split('?')[0] !== TOOL) return req.abort().catch(() => {}); return req.continue().catch(() => {}); } let host; try { const p = new URL(u); if (!/^https?:$/.test(p.protocol)) return req.abort().catch(() => {}); host = p.hostname; } catch (e) { return req.abort().catch(() => {}); } if (BLOCK_HOSTS.test(host)) return req.abort().catch(() => {}); publicHost(host).then(ok => (ok ? req.continue() : req.abort())).catch(() => req.abort().catch(() => {})); }); await page.exposeFunction('__a11yFetch', async (u, ref) => { try { const p = new URL(u); if (!/^https?:$/.test(p.protocol) || !(await publicHost(p.hostname))) return { ok: false, html: '', error: 'blocked' }; return await fetchCss(u, ref || null); } catch (e) { return { ok: false, html: '', error: String(e && e.message || e) }; } }); await page.goto(TOOL, { waitUntil: 'domcontentloaded', timeout: 20000 }); await page.addStyleTag({ path: TOOL_CSS }).catch(() => {}); await page.addScriptTag({ path: TOOL_JS }); // axe-core and its runner, from this server's own copy, for the tool page's extra WCAG checks (see a11yAxe). let axeSrc = null; try { axeSrc = { axe: await fs.promises.readFile(AXE_FILES['axe.min.js'], 'utf8'), run: await fs.promises.readFile(AXE_FILES['run.js'], 'utf8'), spacing: await fs.promises.readFile(path.join(__dirname, '../public/a11y-axe/text-spacing.js'), 'utf8') }; } catch (e) { axeSrc = null; } const run = page.evaluate(async (h, base, byScripts, axeSrc, kb, inner) => { if (axeSrc) window.__a11yAxeSrc = axeSrc; window.a11yKeyboard = kb || null; window.a11yKeyboardP = null; window.a11yInner = inner || null; window.a11yInnerFixed = true; window.fetchRawHtml = (u, r) => window.__a11yFetch(u, r || null); window.a11yBuiltByScripts = byScripts; window.a11yLastReport = null; document.getElementById('a11y-input').value = h; checkA11y(base); const until = Date.now() + 58000; while (!window.a11yLastReport && Date.now() < until) await new Promise(r => setTimeout(r, 100)); return window.a11yLastReport; }, html, url || '', builtByScripts, axeSrc, keyboard, inner || null); const report = await Promise.race([run, new Promise((_, rej) => { timer = setTimeout(() => rej(new Error('rendered accessibility check timed out')), TIMEOUT_MS); })]); if (!report || !Array.isArray(report.issues)) throw new Error('rendered accessibility check returned no report'); report.rendered = true; return report; } finally { if (timer) clearTimeout(timer); if (page) await page.close().catch(() => {}); release(); } } // The two inner pages, each loaded as visitors see it and given the full report on its own. A page that cannot be // loaded that way (error status, bot-check, almost empty, a different site after redirects) is left out. // onProgress (optional) is called while they run, so a caller streaming results can show the audit is still working. async function innerReports(homeUrl, urls, fetchCss, onProgress) { const pages = []; let homeHost = ''; try { homeHost = new URL(homeUrl).hostname.replace(/^www\./, ''); } catch (e) { return pages; } const tick = onProgress ? setInterval(() => { try { onProgress(); } catch (e) {} }, 20000) : null; try { // up to 4 menu links are tried, until 2 pages are checked; a link that ends up on a login page after its redirects // (HSBC's "log on" link lands on /auth/?returnUrl=) is not a page to audit const SKIPRE = /\/(cart|basket|checkout|login|log-in|logon|signin|sign-in|logout|auth|oauth|sso|account|my-account|register|signup|sign-up|search|wp-admin|wp-login\.php|admin)(\/|\?|$)|[?&](returnurl|redirect|redirect_uri)=/i; for (const u of (urls || []).slice(0, 4)) { if (pages.length >= 2) break; try { if (new URL(u).hostname.replace(/^www\./, '') !== homeHost) continue; const r = await renderWithScripts(u, { keyboard: true }); if (!r.ok || (r.status && (r.status < 200 || r.status >= 300)) || nearlyEmpty(r.html) || botCheck(r.html)) continue; if (new URL(r.finalUrl || u).hostname.replace(/^www\./, '') !== homeHost || SKIPRE.test(r.finalUrl || '')) continue; const rep = await toolReport(r.html, r.finalUrl || u, true, r.keyboard || null, fetchCss, null); pages.push({ url: r.finalUrl || u, issues: rep.issues, errorCount: rep.errorCount, warnCount: rep.warnCount, site: r.site || null }); } catch (e) {} } } finally { if (tick) clearInterval(tick); } return pages; } async function runRenderedAccessibilityCheck(html, url, fetchCss, onProgress) { if (!html || typeof html !== 'string') throw new Error('html required'); // Standalone SVG is not an HTML page. Do not run HTML-only checks. if (/^\s*(?:<\?xml[^>]*>\s*)?(?:]*>\s*)?= 200 && r.status < 300)) && !nearlyEmpty(r.html) && !botCheck(r.html)) { html = r.html; url = r.finalUrl || url; builtByScripts = true; keyboard = r.keyboard || null; site = r.site || null; } } // two inner pages of the site, checked first so the home page's report can add what they found let inner = null; if (site && site.inner && site.inner.length) { const pages = await innerReports(url, site.inner, fetchCss, onProgress).catch(() => []); inner = { home: site, pages: pages, tried: Math.min(2, site.inner.length) }; } return toolReport(html, url, builtByScripts, keyboard, fetchCss, inner); } // The keyboard check alone, for the Accessibility page tool (/api/a11ykeyboard). null when the page could not be // loaded as visitors see it (error status, bot-check, almost empty): a check of the wrong page is not given. async function keyboardCheck(url) { const r = await renderWithScripts(url, { keyboard: true }); if (!r.ok || (r.status && (r.status < 200 || r.status >= 300)) || nearlyEmpty(r.html) || botCheck(r.html)) return null; if (r.keyboard) r.keyboard.site = r.site || null; return r.keyboard || null; } module.exports = { runRenderedAccessibilityCheck, renderWithScripts, keyboardCheck, innerReports, nearlyEmpty, botCheck }; ===== REPORT FINALIZATION REFERENCES ===== 1095: const r = { ok: !!e.data.ok, error: e.data.error, version: e.data.version, violations: Array.isArray(e.data.violations) ? e.data.violations : [], actResults: Array.isArray(e.data.actResults) ? e.data.actResults : [] }; 1100: r.actResults = r.actResults.map(v => 1294: .then(c => checkA11yReport(c)) 1295: .catch(() => checkA11yReport({ status: 'skipped', reason: 'the page could not be rendered for measuring' })); 1305:function checkA11yReport(contrast) { 1843: const actSpacing = Array.isArray(a11yAx.actResults) 1844: ? a11yAx.actResults : []; 1987: window.a11yLastReport = { issues: issues.map(i => Object.assign({}, i)), passes: passes.slice(), score: score, errorCount: errorCount, warnCount: warnCount, passCount: passes.length, notes: (typeof notes !== 'undefined' ? notes.slice() : []), coverage: a11yCoverage, contrast: contrast ? contrast.status : null };