QA harness: qa/run.sh + walk.mjs (public + member/admin) + earn.mjs (earning flows)

Headless Playwright checks that boot a throwaway local copy for anything that
signs in or writes; npm run qa / qa:public / qa:member / qa:earn.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-09-09 11:32:13 -05:00
parent 26b98c65c5
commit 8b25361eef
5 changed files with 308 additions and 1 deletions
+1
View File
@@ -1,3 +1,4 @@
node_modules/ node_modules/
data/ data/
*.log *.log
qa/out/
+1 -1
View File
@@ -4,7 +4,7 @@
"private": true, "private": true,
"description": "InstantAdPay membership advertising site - immutable on-chain settlement, transparent ledger.", "description": "InstantAdPay membership advertising site - immutable on-chain settlement, transparent ledger.",
"main": "server.js", "main": "server.js",
"scripts": {"start": "node server.js", "dev": "node --watch server.js"}, "scripts": {"start": "node server.js", "dev": "node --watch server.js", "qa": "bash qa/run.sh all", "qa:public": "bash qa/run.sh public", "qa:member": "bash qa/run.sh member", "qa:earn": "bash qa/run.sh earn"},
"engines": {"node": ">=20"}, "engines": {"node": ">=20"},
"dependencies": {"mysql2": "^3.11.0", "qrcode": "^1.5.4"} "dependencies": {"mysql2": "^3.11.0", "qrcode": "^1.5.4"}
} }
+118
View File
@@ -0,0 +1,118 @@
// InstantAdPay QA harness: earning flows, driven end to end on a LOCAL copy.
// node qa/earn.mjs
// Seeds house ads through the admin API (needs ADMIN_PASSWORD of the local server), signs in a fresh
// member, then runs: Watch ads x5 (incl. one wrong captcha pick) + daily claim, Watch videos, Verified
// visits, Inbox solo read + claim. Reports credited amounts and any trip-ups (e.g. a check button
// covered by the overlay's close button). Dwell timers run for real, so allow ~2 minutes.
// Env: LOCAL (default http://127.0.0.1:8797), ADMIN_PASSWORD (default localtest), OUT, PW
// Exit code 1 when a flow that had inventory failed to credit.
import { pathToFileURL } from 'node:url';
import fs from 'node:fs';
const PW = process.env.PW || 'D:/Projects/MarketingAgent/qa-tester/node_modules/playwright';
const { chromium } = (await import(pathToFileURL(PW + '/index.js').href)).default;
const B = process.env.LOCAL || 'http://127.0.0.1:8797';
const OUT = process.env.OUT || 'qa/out';
const ADMIN = process.env.ADMIN_PASSWORD || 'localtest';
fs.mkdirSync(OUT, { recursive: true });
const CAP = { rocket: '🚀', 'lightning bolt': '⚡', key: '🔑', target: '🎯', wave: '🌊', flame: '🔥', diamond: '💎', magnet: '🧲', bell: '🔔', moon: '🌙' };
const lines = []; const log = (...a) => { const s = a.join(' '); console.log(s); lines.push(s); };
const problems = [];
const browser = await chromium.launch();
const ctx = await browser.newContext({ viewport: { width: 1280, height: 950 } });
const page = await ctx.newPage();
page.on('dialog', d => d.accept());
const api = async (p, body) => (await page.request.fetch(B + p, body ? { method: 'POST', data: body } : {})).json();
// seeds: targets must be public and frameable (rmcircle.team sends frame-ancestors *)
const seeds = [
{ type: 'text', name: 'QA text 1', targetUrl: 'https://rmcircle.team/', title: 'Text one', body: 'Body one', budget: 1000 },
{ type: 'text', name: 'QA text 2', targetUrl: 'https://rmcircle.team/how-pay-works', title: 'Text two', body: 'Body two', budget: 1000 },
{ type: 'banner', name: 'QA banner', targetUrl: 'https://rmcircle.team/start', imageUrl: 'https://rmcircle.team/banners/rmc-728x90-v1.png', size: '728x90', budget: 1000 },
{ type: 'video', name: 'QA video', targetUrl: 'https://rmcircle.team/', videoUrl: process.env.QA_VIDEO_URL || 'https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.webm', videoW: 960, videoH: 540, watchSecs: 10, title: 'QA clip', budget: 1000 },
{ type: 'visits', name: 'QA visits', targetUrl: 'https://rmcircle.team/contract', title: 'Visit the contract page', count: 20 },
{ type: 'solo', name: 'QA solo', targetUrl: 'https://rmcircle.team/contract', title: 'QA solo subject line', body: '<p>This is a QA solo ad body with enough characters to pass validation for the inbox test run.</p>', ctaLabel: 'See it', budget: 100 }
];
for (const sd of seeds) {
const r = await (await page.request.post(B + '/api/admin/campaigns', { headers: { Authorization: 'Bearer ' + ADMIN }, data: sd })).json();
log('seed', sd.type, r.ok ? '#' + r.campaign.id + ' ' + r.campaign.status : 'FAIL ' + r.error);
if (!r.ok) problems.push('seed ' + sd.type + ': ' + r.error);
}
await page.goto(B + '/my', { waitUntil: 'networkidle' });
await page.fill('#mcEmail', 'qa-earn@example.com'); await page.click('#mcSendBtn'); await page.waitForSelector('#mcVerifyBtn:not([hidden])'); await page.click('#mcVerifyBtn'); await page.waitForTimeout(2000);
if (await page.$('#onboardModal:not([hidden])')) { await page.fill('#obUsername', 'qaearner'); await page.click('#obSave'); await page.waitForTimeout(1000); }
await page.evaluate(() => { document.querySelectorAll('.modal-back,.lgate').forEach(m => m.hidden = true); });
const start = await api('/api/my/earn'); log('start:', JSON.stringify(start));
// Watch ads
await page.click('.bo-menu [data-pane="earn"]'); await page.waitForTimeout(800);
await page.click('.subtabs [data-earn="watch"]'); await page.waitForTimeout(500);
let credited = 0;
for (let i = 0; i < 5; i++) {
await page.click('#earnStartBtn'); await page.waitForTimeout(1200);
const fr = page.frames().find(f => f.url().includes('/view/'));
if (!fr) { log('AD ' + (i + 1) + ': viewer did not open; box says:', (await page.textContent('#earnAdBox')).trim()); problems.push('watch: viewer did not open'); break; }
const t0 = Date.now();
try { await fr.waitForSelector('#vCheck.on', { timeout: 30000 }); } catch (e) { log('AD ' + (i + 1) + ': check never appeared:', await fr.textContent('#vMsg')); problems.push('watch: check never appeared'); break; }
const secs = ((Date.now() - t0) / 1000).toFixed(1);
if (i === 1) { // trip-up: wrong pick first
const name = ((await fr.textContent('#vPrompt')).match(/Click the (.+):/) || [])[1];
for (const o of await fr.$$('#vOpts button')) { if ((await o.textContent()) !== CAP[name]) { await o.click(); break; } }
await fr.waitForTimeout(700); log(' wrong pick handled:', (await fr.textContent('#vMsg')).trim());
}
const name2 = ((await fr.textContent('#vPrompt')).match(/Click the (.+):/) || [])[1];
const hit = await fr.evaluate(want => { const b = [...document.querySelectorAll('#vOpts button')].find(x => x.textContent === want); if (!b) return null; const r = b.getBoundingClientRect(); const top = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2); const covered = !!(top && top !== b && !b.contains(top)); b.click(); return { covered, by: covered ? top.tagName + '#' + top.id : '' }; }, CAP[name2]);
if (hit && hit.covered) { log(' TRIP-UP: correct answer button covered by', hit.by); problems.push('watch: answer button covered by ' + hit.by); }
await fr.waitForTimeout(900);
const timer = await fr.textContent('#vTimer'); if (/credited/.test(timer)) credited++;
log('AD ' + (i + 1) + ': check after ' + secs + 's | ' + timer + ' | ' + (await fr.textContent('#vMsg')).trim());
await page.evaluate(() => { const o = document.getElementById('adOverlay'); if (o) o.querySelector('button').click(); }); await page.waitForTimeout(700);
}
await page.waitForTimeout(800);
log('after set:', await page.textContent('#earnProgress'), '| claim visible:', !!(await page.$('#earnClaimBtn:not([hidden])')));
await page.click('#earnStartBtn'); await page.waitForTimeout(900); log('view-after-complete says:', (await page.textContent('#earnAdBox')).trim());
if (await page.$('#earnClaimBtn:not([hidden])')) { await page.click('#earnClaimBtn'); await page.waitForTimeout(1000); log('claimed; balance:', await page.textContent('#earnBalance')); }
else if (credited === 5) problems.push('watch: 5 views credited but claim button not shown');
// Watch videos
await page.click('.subtabs [data-earn="videos"]'); await page.waitForTimeout(800);
await page.click('#vidStartBtn'); await page.waitForTimeout(2500);
const v1 = await page.evaluate(() => { const p = document.getElementById('vidPlayer'); return { paused: p.paused, t: p.currentTime, timer: document.getElementById('vidTimer').textContent }; });
log('video after 2.5s:', JSON.stringify(v1));
await page.waitForTimeout(23000);
const v2 = await page.evaluate(() => { const p = document.getElementById('vidPlayer'); return { t: p.currentTime, timer: document.getElementById('vidTimer').textContent, progress: document.getElementById('vidProgress').textContent }; });
log('video after 25s:', JSON.stringify(v2), v2.t < 10 ? '(clip stalled in headless; verify on live with a real video)' : '');
// Verified visits
await page.click('.subtabs [data-earn="visits"]'); await page.waitForTimeout(800);
await page.click('#vsStartBtn'); await page.waitForTimeout(800);
log('visit loaded:', (await page.textContent('#vsBox')).trim().slice(0, 80));
let popup = null;
if (await page.$('#vsVisit:not([hidden])')) {
[popup] = await Promise.all([ctx.waitForEvent('page', { timeout: 5000 }).catch(() => null), page.click('#vsVisit')]);
await page.waitForTimeout(10500);
const vp = (await page.textContent('#vsPrompt')) || ''; const vname = (vp.match(/Click the (.+):/) || [])[1];
if (vname) { for (const b of await page.$$('#vsOpts button')) { if ((await b.textContent()) === CAP[vname]) { await b.click(); break; } } await page.waitForTimeout(900); }
const hint = (await page.textContent('#vsHint')).trim(); log('visit result:', hint, '|', await page.textContent('#vsProgress'));
if (!/credit/.test(hint)) problems.push('visits: not credited: ' + hint);
if (popup) await popup.close();
} else { log('visits: nothing served'); problems.push('visits: nothing served'); }
// Inbox
await page.click('.subtabs [data-earn="inbox"]'); await page.waitForTimeout(1200);
const rows = await page.$$('#ibList .ib-row'); log('inbox rows:', rows.length);
if (rows.length) {
await rows[0].click(); await page.waitForTimeout(900);
const [p2] = await Promise.all([ctx.waitForEvent('page', { timeout: 5000 }).catch(() => null), page.click('#ibVisit')]); if (p2) await p2.close();
await page.bringToFront(); await page.waitForTimeout(12000);
const dis = await page.$eval('#ibClaimBtn', b => b.disabled); log('after 12s: claim btn =', await page.textContent('#ibClaimBtn'), '| disabled =', dis);
if (!dis) { await page.click('#ibClaimBtn'); await page.waitForTimeout(900); log('inbox claim:', (await page.textContent('#ibHint')).trim()); }
else problems.push('inbox: claim still disabled after dwell');
} else problems.push('inbox: no solo delivered');
const fin = await api('/api/my/earn'); log('FINAL:', JSON.stringify(fin));
await page.screenshot({ path: OUT + '/earn-final.png' }).catch(() => {});
await browser.close();
log('===== EARN FLOWS ' + new Date().toISOString() + ' =====');
log(problems.length ? 'PROBLEMS: ' + problems.join(' | ') : 'ALL EARN FLOWS OK (video needs a real clip on live)');
fs.writeFileSync(OUT + '/earn-report.txt', lines.join('\n') + '\n');
process.exit(problems.length ? 1 : 0);
+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/env bash
# InstantAdPay QA harness runner. From the site dir:
# bash qa/run.sh public # live public pages only (no local server)
# bash qa/run.sh member # local copy: member + admin walk
# bash qa/run.sh earn # local copy: earning flows end to end (~2-3 min)
# bash qa/run.sh all # public + member + earn
# Each local run boots server.js on its own port with a throwaway data dir (JSON store, no DB, no
# mail key, devCode sign-in), so nothing touches production data. Reports land in qa/out/.
set -u
cd "$(dirname "$0")/.."
MODE="${1:-all}"
OUT="qa/out"; mkdir -p "$OUT"
TMP="${TMPDIR:-${TEMP:-/tmp}}/iap-qa-$$"
status=0
start_local() { # $1 = port
local port="$1"
rm -rf "$TMP-$port"; mkdir -p "$TMP-$port/uploads"
(PORT="$port" DATA_DIR="$TMP-$port" ADMIN_EMAIL="${ADMIN_EMAIL:-martybostick@gmail.com}" ADMIN_PASSWORD=localtest node server.js > "$OUT/server-$port.log" 2>&1 &)
for i in $(seq 1 20); do curl -s -m 2 "http://127.0.0.1:$port/api/config" >/dev/null && return 0; sleep 1; done
echo "local server on :$port did not come up"; return 1
}
stop_local() { # $1 = port
for p in $(netstat -ano 2>/dev/null | grep ":$1 " | awk '{print $5}' | sort -u); do taskkill //F //PID "$p" >/dev/null 2>&1; done
rm -rf "$TMP-$1"
}
if [ "$MODE" = "public" ]; then
node qa/walk.mjs public || status=1
elif [ "$MODE" = "member" ]; then
start_local 8796 && { LOCAL=http://127.0.0.1:8796 node qa/walk.mjs member || status=1; }; stop_local 8796
elif [ "$MODE" = "earn" ]; then
start_local 8797 && { LOCAL=http://127.0.0.1:8797 node qa/earn.mjs || status=1; }; stop_local 8797
else
node qa/walk.mjs public || status=1
start_local 8796 && { LOCAL=http://127.0.0.1:8796 node qa/walk.mjs member || status=1; }; stop_local 8796
start_local 8797 && { LOCAL=http://127.0.0.1:8797 node qa/earn.mjs || status=1; }; stop_local 8797
fi
echo
echo "reports: $OUT/walk-report.txt $OUT/earn-report.txt (screenshots alongside)"
exit $status
+147
View File
@@ -0,0 +1,147 @@
// InstantAdPay QA harness: site walk.
// node qa/walk.mjs public -> live public pages (no sign-in): errors, failed requests, broken images, dead links, mobile
// node qa/walk.mjs member -> local copy: sign in, every member pane + sub-tab, every admin pane, forms
// node qa/walk.mjs all -> both
// Env: LIVE (default https://instantadpay.com), LOCAL (default http://127.0.0.1:8796), OUT (report dir),
// PW (playwright package dir; default D:/Projects/MarketingAgent/qa-tester/node_modules/playwright)
// Exit code 1 when any [bug] finding remains after noise filtering.
import { pathToFileURL } from 'node:url';
import fs from 'node:fs';
const PW = process.env.PW || 'D:/Projects/MarketingAgent/qa-tester/node_modules/playwright';
const { chromium } = (await import(pathToFileURL(PW + '/index.js').href)).default;
const MODE = process.argv[2] || 'all';
const LIVE = process.env.LIVE || 'https://instantadpay.com';
const LOCAL = process.env.LOCAL || 'http://127.0.0.1:8796';
const OUT = process.env.OUT || 'qa/out';
fs.mkdirSync(OUT, { recursive: true });
const findings = [];
const note = (sev, where, what) => findings.push({ sev, where, what });
const NOISE = /walletconnect|reown|web3modal|coingecko|fonts\.|\/api\/feed\/live|\/api\/auth\/logout/;
function watch(page, base) {
const bag = { console: [], failed: [], status: [] };
page.on('pageerror', e => bag.console.push('pageerror: ' + e.message));
page.on('console', m => {
if (m.type() !== 'error') return;
const loc = (m.location() && m.location().url) || '';
if (loc && !loc.startsWith(base)) return; // third-party or framed page, not ours
if (/status of (400|401|404)/.test(m.text())) return; // expected API answers surface as console noise
bag.console.push(m.text());
});
page.on('requestfailed', r => { const u = r.url(); if (u.startsWith(base) && !NOISE.test(u)) bag.failed.push(u + ' ' + (r.failure() && r.failure().errorText)); });
page.on('response', r => { const st = r.status(); const u = r.url(); if (st >= 500 && u.startsWith(base)) bag.status.push(st + ' ' + u); });
return bag;
}
function flush(bag, label) {
for (const c of bag.console) note('bug', label, 'console: ' + c.slice(0, 200));
for (const f of bag.failed) note('bug', label, 'request failed: ' + f.slice(0, 200));
for (const s of bag.status) note('bug', label, 'HTTP ' + s.slice(0, 200));
bag.console.length = bag.failed.length = bag.status.length = 0;
}
async function domChecks(page, label) {
const r = await page.evaluate(() => {
const vis = el => el.offsetParent !== null;
const brokenImgs = [...document.images].filter(i => i.complete && i.naturalWidth === 0 && i.src && vis(i)).map(i => i.src);
const unfilled = [...document.querySelectorAll('body *')].filter(el => el.children.length === 0 && (el.textContent || '').trim() === '…' && vis(el)).length;
const overflow = document.documentElement.scrollWidth > document.documentElement.clientWidth + 2;
return { brokenImgs, unfilled, overflow, title: document.title };
});
if (r.brokenImgs.length) note('bug', label, 'broken images: ' + r.brokenImgs.slice(0, 3).join(', '));
if (r.unfilled) note('warn', label, r.unfilled + ' element(s) still showing the loading ellipsis');
if (r.overflow) note('warn', label, 'page scrolls horizontally');
return r;
}
const hide = page => page.evaluate(() => { document.querySelectorAll('.modal-back,.lgate').forEach(m => m.hidden = true); });
const browser = await chromium.launch();
if (MODE === 'public' || MODE === 'all') {
const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } });
const page = await ctx.newPage(); const bag = watch(page, LIVE);
const PUBLIC = ['/', '/ledger', '/contract', '/terms', '/privacy', '/disclaimer', '/wall/martbost', '/join/martbost',
'/join/martbost?v=instant', '/join/martbost?v=adspend', '/join/martbost?v=free', '/join/martbost?v=ledger', '/join/martbost?v=two',
'/admin', '/my', '/shorts', '/nope-404'];
for (const p of PUBLIC) {
const label = 'LIVE ' + p;
try {
const resp = await page.goto(LIVE + p, { waitUntil: 'domcontentloaded', timeout: 45000 });
await page.waitForTimeout(2500);
const st = resp ? resp.status() : 0;
if (p === '/nope-404') { if (st !== 404) note('warn', label, 'expected 404, got ' + st); }
else if (st >= 400) note('bug', label, 'page HTTP ' + st);
const d = await domChecks(page, label);
const hrefs = await page.evaluate(() => [...new Set([...document.querySelectorAll('a[href]')].map(a => a.href).filter(h => h.startsWith(location.origin) && !h.includes('#') && !h.includes('/api/')))]);
for (const h of hrefs.slice(0, 40)) {
try { const r = await page.request.head(h, { timeout: 15000 }); if (r.status() >= 400) note('bug', label, 'dead link ' + h + ' -> ' + r.status()); }
catch (e) { note('warn', label, 'link check failed ' + h); }
}
flush(bag, label); console.log('ok', label, '|', d.title);
} catch (e) { note('bug', label, 'navigation failed: ' + e.message.slice(0, 160)); flush(bag, label); }
}
const m = await browser.newContext({ viewport: { width: 390, height: 844 }, isMobile: true });
const mp = await m.newPage(); const mbag = watch(mp, LIVE);
for (const p of ['/', '/join/martbost?v=instant', '/wall/martbost', '/my']) {
await mp.goto(LIVE + p, { waitUntil: 'domcontentloaded', timeout: 45000 }).catch(e => note('bug', 'LIVE mobile ' + p, e.message));
await mp.waitForTimeout(2000); await domChecks(mp, 'LIVE mobile ' + p); flush(mbag, 'LIVE mobile ' + p);
await mp.screenshot({ path: OUT + '/mobile' + p.replace(/[^a-z0-9]+/gi, '-') + '.png' }).catch(() => {});
}
await ctx.close(); await m.close();
}
if (MODE === 'member' || MODE === 'all') {
const ctx = await browser.newContext({ viewport: { width: 1280, height: 950 } });
const page = await ctx.newPage(); const bag = watch(page, LOCAL);
page.on('dialog', d => d.accept());
const L = 'LOCAL ';
await page.goto(LOCAL + '/my', { waitUntil: 'networkidle' });
await page.fill('#mcEmail', 'qa-walk@example.com'); await page.click('#mcSendBtn');
await page.waitForSelector('#mcVerifyBtn:not([hidden])'); await page.click('#mcVerifyBtn'); await page.waitForTimeout(2000);
if (await page.$('#onboardModal:not([hidden])')) { await page.fill('#obUsername', 'qawalker'); await page.click('#obSave'); await page.waitForTimeout(1200); }
await hide(page); flush(bag, L + 'sign-in');
const PANES = ['overview', 'line', 'buy', 'campaigns', 'earn', 'earnings', 'promo', 'training', 'wallet', 'profile'];
for (const pn of PANES) {
const label = L + 'my#' + pn;
await page.click('.bo-menu [data-pane="' + pn + '"]'); await page.waitForTimeout(1200); await hide(page);
const vis = await page.evaluate(id => { const el = document.getElementById('pane-' + id); return el && !el.hidden && el.offsetHeight > 40; }, pn);
if (!vis) note('bug', label, 'pane did not render');
await domChecks(page, label);
const subs = await page.$$('#pane-' + pn + ' .subtabs [data-earn], #pane-' + pn + ' .promo-pills [data-promo]');
for (const s of subs) { try { await s.click(); await page.waitForTimeout(500); } catch (e) {} }
if (subs.length) await domChecks(page, label + ' (sub-tabs)');
flush(bag, label);
await page.screenshot({ path: OUT + '/my-' + pn + '.png' }).catch(() => {});
console.log('ok', label, 'subtabs:', subs.length);
}
await page.click('.bo-menu [data-pane="promo"]'); await page.waitForTimeout(800);
if (!(await page.$$('#promoPosts .promo-block')).length) note('bug', L + 'promo', 'no post cards rendered');
const chat = await page.$('#chatMenuBtn'); if (chat) { await chat.click(); await page.waitForTimeout(800); await domChecks(page, L + 'messages'); flush(bag, L + 'messages'); }
const lo = await page.$('#logoutLink'); if (lo) { await lo.click(); await page.waitForTimeout(1000); }
if (!(await page.$('#authArea:not([hidden])'))) note('bug', L + 'logout', 'auth card not shown after log out');
flush(bag, L + 'logout');
// admin
await page.goto(LOCAL + '/admin', { waitUntil: 'networkidle' });
await page.fill('#adEmail', process.env.ADMIN_EMAIL || 'martybostick@gmail.com'); await page.click('#adSend'); await page.waitForSelector('#adVerify:not([hidden])'); await page.click('#adVerify'); await page.waitForTimeout(1200);
for (const pn of ['overview', 'house', 'campaigns', 'members', 'reports', 'settings']) {
const label = L + 'admin#' + pn;
await page.click('.bo-menu [data-pane="' + pn + '"]'); await page.waitForTimeout(1200);
const vis = await page.evaluate(id => { const el = document.getElementById('pane-' + id); return el && !el.hidden && el.offsetHeight > 40; }, pn);
if (!vis) note('bug', label, 'pane did not render');
await domChecks(page, label); flush(bag, label);
await page.screenshot({ path: OUT + '/admin-' + pn + '.png' }).catch(() => {});
console.log('ok', label);
}
await page.click('.bo-menu [data-pane="house"]'); await page.waitForTimeout(500);
for (const t of ['banner', 'text', 'login', 'solo', 'video', 'featured', 'visits']) { await page.selectOption('#hType', t); await page.waitForTimeout(120); }
await page.selectOption('#hType', 'text'); await page.click('#hCreate'); await page.waitForTimeout(800);
if (!(await page.$('#hErr:not([hidden])'))) note('warn', L + 'admin house form', 'empty submit showed no validation message');
flush(bag, L + 'admin house form');
await ctx.close();
}
await browser.close();
const bugs = findings.filter(f => f.sev === 'bug'), warns = findings.filter(f => f.sev === 'warn');
const lines = ['===== QA WALK (' + MODE + ') ' + new Date().toISOString() + ' =====', 'bugs: ' + bugs.length + ' | warnings: ' + warns.length,
...findings.map(f => '[' + f.sev + '] ' + f.where + ' :: ' + f.what)];
console.log('\n' + lines.join('\n'));
fs.writeFileSync(OUT + '/walk-report.txt', lines.join('\n') + '\n');
process.exit(bugs.length ? 1 : 0);