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:
+147
@@ -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);
|
||||
Reference in New Issue
Block a user