From 8b25361eefeb6b8eb16b46472e46f7e70377e896 Mon Sep 17 00:00:00 2001 From: martbost Date: Wed, 9 Sep 2026 11:32:13 -0500 Subject: [PATCH] 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 --- .gitignore | 1 + package.json | 2 +- qa/earn.mjs | 118 +++++++++++++++++++++++++++++++++++++++++ qa/run.sh | 41 ++++++++++++++ qa/walk.mjs | 147 +++++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 308 insertions(+), 1 deletion(-) create mode 100644 qa/earn.mjs create mode 100644 qa/run.sh create mode 100644 qa/walk.mjs diff --git a/.gitignore b/.gitignore index 7af0d92..720089c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ node_modules/ data/ *.log +qa/out/ diff --git a/package.json b/package.json index 1db159f..2a2ea6b 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "private": true, "description": "InstantAdPay membership advertising site - immutable on-chain settlement, transparent ledger.", "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"}, "dependencies": {"mysql2": "^3.11.0", "qrcode": "^1.5.4"} } diff --git a/qa/earn.mjs b/qa/earn.mjs new file mode 100644 index 0000000..559237f --- /dev/null +++ b/qa/earn.mjs @@ -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: '

This is a QA solo ad body with enough characters to pass validation for the inbox test run.

', 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); diff --git a/qa/run.sh b/qa/run.sh new file mode 100644 index 0000000..7b8ed68 --- /dev/null +++ b/qa/run.sh @@ -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 diff --git a/qa/walk.mjs b/qa/walk.mjs new file mode 100644 index 0000000..cb8bf0b --- /dev/null +++ b/qa/walk.mjs @@ -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);