3f9dc3cc94
The dashboard card was too easy to scroll past, so this is a real overlay: once per browser session, on every page that loads common.js, dismissed with the X, Escape or the backdrop. It leads with the mechanic rather than the promotion, because the ask was to remind people what the site is about: the ad spend in your line pays you, in the same transaction. Five Dollar Friday rides underneath as the reason to act, and the copy is driven by the live /api/friday, so it reads "tomorrow", "today" or nothing at all without anyone editing text on a Friday morning. Not shown on /join or /admin: an overlay in the middle of signing up costs sign-ups, and staff do not need reminding what the product is. QA: the overlay appears after an async fetch, so hiding it at load time missed it and every click in the harness timed out. The harness now pre-sets the same sessionStorage flag a real dismissal uses, before page scripts run. Green again, 0 bugs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
171 lines
13 KiB
JavaScript
171 lines
13 KiB
JavaScript
// 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,.iap-welcome').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 } });
|
|
await ctx.addInitScript(() => { try { sessionStorage.setItem('iap.welcome.v1', '1'); } catch (e) {} }); // the session pop-up would block every click in an automated run
|
|
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 });
|
|
await m.addInitScript(() => { try { sessionStorage.setItem('iap.welcome.v1', '1'); } catch (e) {} }); // the session pop-up would block every click in an automated run
|
|
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 } });
|
|
await ctx.addInitScript(() => { try { sessionStorage.setItem('iap.welcome.v1', '1'); } catch (e) {} }); // the session pop-up would block every click in an automated run
|
|
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', 'pipeline', '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');
|
|
// viral links: the builder renders a ?ref= link; any page + ?ref=<member> redirects clean and sets the sponsor cookie; an unknown ref sets nothing
|
|
await page.click('.promo-pills [data-promo="viral"]'); await page.waitForTimeout(1200);
|
|
const vl = await page.$eval('#viralLink', e => e.textContent).catch(() => '');
|
|
if (!/^https:\/\/instantadpay\.com\/.*[?&]ref=[a-z0-9_]+$/i.test(vl)) note('bug', L + 'promo/viral', 'builder link not rendered: ' + vl);
|
|
const me = await page.evaluate(() => fetch('/api/me').then(r => r.json()));
|
|
const tok = me.username || me.refCode;
|
|
// served in place (no redirect: Facebook drops the name otherwise), cookie on the response, og:url carries the ref
|
|
const rr = await page.request.get(LOCAL + '/?ref=' + tok + '&x=1', { maxRedirects: 0 });
|
|
const sc = (rr.headersArray().filter(h => h.name.toLowerCase() === 'set-cookie').map(h => h.value)).join('; ');
|
|
const rb = await rr.text();
|
|
if (rr.status() !== 200 || !/<title>/.test(rb)) note('bug', L + 'viral/inplace', 'expected the page at the decorated address, got ' + rr.status());
|
|
if (!new RegExp('iap\\.sponsor=' + tok + ';').test(sc) || !/iap\.angle=page;/.test(sc)) note('bug', L + 'viral/cookie', 'sponsor/angle cookie not set: ' + sc);
|
|
if (!new RegExp('property="og:url" content="[^"]*[?&]ref=' + tok + '"').test(rb)) note('bug', L + 'viral/og', 'og:url does not carry the ref');
|
|
const ru = await page.request.get(LOCAL + '/?ref=nobody_zz9', { maxRedirects: 0 });
|
|
const su = (ru.headersArray().filter(h => h.name.toLowerCase() === 'set-cookie').map(h => h.value)).join('; ');
|
|
const ub = await ru.text();
|
|
if (ru.status() !== 200 || /iap\.sponsor=/.test(su) || /ref=nobody_zz9/.test(ub)) note('bug', L + 'viral/unknown', 'unknown ref must serve the plain page with no cookie and a clean og:url: ' + ru.status() + ' ' + su);
|
|
const rj = await page.request.get(LOCAL + '/join/' + tok + '?ref=' + tok, { maxRedirects: 0 });
|
|
if (rj.status() !== 200) note('bug', L + 'viral/join', '/join keeps its own ?ref handling, got ' + rj.status());
|
|
console.log('ok viral links: builder + redirect + cookie');
|
|
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', 'traffic', 'blog', 'releases', 'pnl', '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);
|