Files
instantadpay/qa/earn.mjs
T
martbost 3f9dc3cc94 A session pop-up that says what InstantAdPay actually is
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>
2026-09-24 15:54:33 -05:00

121 lines
10 KiB
JavaScript

// 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 } });
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();
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,.iap-welcome').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])')));
if (await page.$('#earnStartBtn:not([hidden])')) { await page.click('#earnStartBtn'); await page.waitForTimeout(900); log('view-after-complete says:', (await page.textContent('#earnAdBox')).trim()); }
else log('view button hidden after the set (done screen with claim), as designed since 2026-09-12');
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);