// 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])'))); 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);