Files
linkspin/qa/earn.mjs
T

120 lines
9.8 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 } });
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])')));
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);