Add ad-test regression tool (Playwright headless ad-flow validator)

After 8148 ASG account recreation + spot rotation debug session,
formalize tooling for automated ad-pipeline regression checks.

- /home/w4/bin/ad-test — wrapper, available globally
- /home/w4/playwright-tests/ — Playwright + Chromium runtime
- scripts/ad-regression.mjs — versioned copy у репо

Per-URL checks (6):
  1. ad-bundle loaded (window._adConfig set)
  2. popunder SDK fetches
  3. /api/users/<vast_spot> returns <Ad> (catches no-fill)
  4. mode = "vast" after popunder cooldown active
  5. .asg-container injects on play click (right data-spot-id)
  6. console clean (no [ASGB LOADER] errors)

Headless limitation documented: network+DOM checks cover 95% of
regressions without need for full video playback validation.
Use `ad-test --full` or real browser w/ ?debug=1 for visual.

Memory: reference_ad_regression_tool.md added — auto-trigger rule
to run ad-test after spot ID changes / mirror swaps / bundle rebuild.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
goboss
2026-05-02 22:45:31 +00:00
parent 3d040eb668
commit c9890a32fe
4 changed files with 328 additions and 0 deletions

257
scripts/ad-regression.mjs Normal file
View File

@@ -0,0 +1,257 @@
#!/usr/bin/env node
// ad-regression.mjs — automated ad-flow validation for tubev sites
//
// Usage:
// node ad-regression.mjs <url1> [url2] [...] [--full] [--screenshot]
//
// Default: фрешний context, click body для popunder gesture, потім visits
// інших URLs з активним cooldown — перевіряє VAST flow.
//
// Flags:
// --full run full chromium (not headless-shell) — потрібно для VPAID/autoplay
// --screenshot save screenshot per URL у /home/w4/playwright-tests/screenshots/
// --json output as JSON (default: human-readable)
//
// Exit codes:
// 0 = всі passed
// 1 = хоч один FAIL
//
// Each URL перевіряється проти 6 checks:
// 1. ad-bundle.min.js loaded (200)
// 2. popunder SDK loads + asgsl gets timestamps (after click — visit 1 only)
// 3. mode у "vast" на visit 2+
// 4. /api/users/<vast_spot> повертає <Ad> (not empty)
// 5. .asg-container injects після click на pjs_play_btn (з data-spot-id)
// 6. console — no [ASGB LOADER] errors, no [PAGEERR]
import { chromium } from 'playwright';
import { mkdirSync, writeFileSync } from 'fs';
const args = process.argv.slice(2);
const FULL = args.includes('--full');
const SCREENSHOT = args.includes('--screenshot');
const JSON_OUT = args.includes('--json');
const URLS = args.filter(a => !a.startsWith('--'));
if (URLS.length === 0) {
console.error('usage: node ad-regression.mjs <url1> [url2] ... [--full] [--screenshot] [--json]');
process.exit(2);
}
if (SCREENSHOT) mkdirSync('/home/w4/playwright-tests/screenshots', { recursive: true });
const browser = await chromium.launch({
headless: true,
args: FULL ? ['--autoplay-policy=no-user-gesture-required'] : [],
});
const ctx = await browser.newContext({
userAgent: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36',
viewport: { width: 1280, height: 800 },
acceptDownloads: false,
});
const results = [];
const checks = (label, pass, detail) => ({ check: label, pass, detail });
const pageCtx = await ctx.newPage();
// ---------- VISIT 1: prime popunder ----------
const url0 = URLS[0];
const id0 = url0.match(/\/v\/box\/(\d+)/)?.[1] || 'unknown';
console.log(`\n${'='.repeat(70)}\nPRIME visit (popunder gesture): /v/box/${id0}`);
const reqs0 = [];
pageCtx.on('response', r => {
if (r.url().includes('a5.g--o.info')) reqs0.push({ status: r.status(), url: r.url() });
});
const consoleErr0 = [];
pageCtx.on('console', m => { if (m.type() === 'error') consoleErr0.push(m.text()); });
pageCtx.on('pageerror', e => consoleErr0.push(`[PAGEERR] ${e.message}`));
try {
const debugUrl = url0.includes('?') ? `${url0}&debug=1` : `${url0}?debug=1`;
await pageCtx.goto(debugUrl, { waitUntil: 'load', timeout: 30000 });
await pageCtx.waitForTimeout(2000);
// Click body to fire popunder via gesture
await pageCtx.mouse.click(640, 400);
await pageCtx.waitForTimeout(3500);
const s = await pageCtx.evaluate(() => ({
mode: window._adCtx?.state?.currentMode,
_popRr: localStorage.getItem('_popRr'),
asgsl: localStorage.getItem('asgsl'),
}));
const popunderOK = s._popRr && parseInt(s._popRr) > Date.now();
if (!popunderOK) {
// Plant manually (headless limitation, doesn't fail run)
const future = Date.now() + 2 * 3600 * 1000;
await pageCtx.evaluate((t) => {
localStorage.setItem('_popRr', String(t));
document.cookie = `_popRr=${t}; max-age=7200; path=/`;
const ex = localStorage.getItem('asgsl') || '';
if (ex.includes('514208=') && !ex.includes('global_rr')) {
localStorage.setItem('asgsl', ex.replace(/514208=([^|]*)/, `514208=$1,global_rr:${t},n:${t}`));
} else if (!ex.includes('global_rr')) {
localStorage.setItem('asgsl', `514208=global_rr:${t},n:${t}` + (ex ? '|' + ex : ''));
}
}, future);
}
console.log(` popunder gesture: ${popunderOK ? 'AUTO-FIRED' : 'planted manually (headless limitation)'}`);
console.log(` asgsl after: ${(s.asgsl || '').slice(0, 200)}`);
} catch (e) {
console.log(` PRIME visit error: ${e.message}`);
}
// ---------- VISIT each URL: full ad-flow check ----------
for (let i = 0; i < URLS.length; i++) {
const url = URLS[i];
const id = url.match(/\/v\/box\/(\d+)/)?.[1] || `url-${i+1}`;
const debugUrl = url.includes('?') ? `${url}&debug=1` : `${url}?debug=1`;
console.log(`\n${'='.repeat(70)}\n[${i+1}/${URLS.length}] /v/box/${id}`);
const reqs = [];
const consoleErrs = [];
const consoleAll = [];
const reqHandler = r => {
if (r.url().includes('a5.g--o.info')) reqs.push({ status: r.status(), url: r.url(), ts: Date.now() });
};
const conHandler = m => {
consoleAll.push(`[${m.type()}] ${m.text()}`);
if (m.type() === 'error' || /ASGB LOADER|Response is not okay/.test(m.text())) {
consoleErrs.push(m.text());
}
};
const errHandler = e => consoleErrs.push(`[PAGEERR] ${e.message}`);
const page = await ctx.newPage();
page.on('response', reqHandler);
page.on('console', conHandler);
page.on('pageerror', errHandler);
const urlChecks = [];
let initialState = null, finalState = null, vastResp = null;
try {
await page.goto(debugUrl, { waitUntil: 'load', timeout: 30000 });
await page.waitForTimeout(3500);
initialState = await page.evaluate(() => ({
mode: window._adCtx?.state?.currentMode,
initialMode: window._adCtx?.state?.initialMode,
vastLoaded: window._adCtx?.state?.vastLoaded,
_popRr: localStorage.getItem('_popRr'),
_vastRr: localStorage.getItem('_vastRr'),
asgsl: localStorage.getItem('asgsl'),
adConfig: window._adConfig,
pjsPlayBtn: !!document.querySelector('#pjs_play_btn'),
}));
// CHECK 1: ad-bundle loaded
const bundleLoaded = reqs.some(r => r.url.includes('ad-bundle') || r.url.includes('/static/js/'));
// Actually bundle is on same origin, not a5.g--o.info. Check via _adConfig presence.
urlChecks.push(checks('ad-bundle loaded (window._adConfig set)', !!initialState.adConfig,
initialState.adConfig ? `popunder=${initialState.adConfig.popunder.spot}, vast=${initialState.adConfig.vast.spot}` : 'window._adConfig undefined'));
// CHECK 2: popunder SDK loaded (only on visit 1, but check any)
const popSdkLoaded = reqs.some(r => r.url.includes(initialState.adConfig?.popunder?.sdk?.replace('//', '')));
urlChecks.push(checks('popunder SDK loaded (this visit)', popSdkLoaded || initialState.mode === 'vast',
popSdkLoaded ? 'SDK fetched' : `mode=${initialState.mode} → SDK not needed (vast mode)`));
// CHECK 3: VAST chain returns <Ad> (not empty)
const vastSpot = initialState.adConfig?.vast?.spot;
if (vastSpot) {
try {
const vastUrl = `https://a5.g--o.info/api/users/${vastSpot}?v2=1&fill=0&url=${encodeURIComponent(url)}&sid=test-${id}`;
const r = await fetch(vastUrl, { headers: { Origin: new URL(url).origin, Referer: url, 'User-Agent': 'Mozilla/5.0' }});
const body = await r.text();
const hasAd = /<Ad\s+id=/.test(body);
const hasInLine = /<InLine>/.test(body);
const hasWrapper = /<Wrapper>/.test(body);
vastResp = { status: r.status, hasAd, hasInLine, hasWrapper, snippet: body.slice(0, 200) };
urlChecks.push(checks(`VAST endpoint returns <Ad> (spot ${vastSpot})`, hasAd,
hasAd ? `Ad found (Wrapper=${hasWrapper}, InLine=${hasInLine})` : `EMPTY VAST — no-fill response`));
} catch (e) {
urlChecks.push(checks(`VAST endpoint check`, false, `fetch error: ${e.message}`));
}
}
// CHECK 4: mode is "vast" (since popunder cooldown should be active)
urlChecks.push(checks(`mode = "vast" (popunder cooldown active)`, initialState.mode === 'vast',
`initialMode=${initialState.initialMode}, current=${initialState.mode}, vastLoaded=${initialState.vastLoaded}, _popRr=${initialState._popRr ? 'set' : 'null'}`));
// CHECK 5: click play, observe asg-container injection
let containerSeen = false, videoSrcFound = false, videoStarted = false;
if (initialState.mode === 'vast' && initialState.pjsPlayBtn) {
const btn = await page.$('#pjs_play_btn');
await btn.click({ force: true, timeout: 5000 }).catch(e => {});
for (let j = 0; j < 12; j++) {
await page.waitForTimeout(1000);
const o = await page.evaluate(() => {
const c = document.querySelector('.asg-container');
if (!c) return { c: false };
const v = c.querySelector('video');
return {
c: true,
spotId: c.dataset.spotId,
cls: c.className,
videoSrc: v?.currentSrc || null,
vTime: v?.currentTime || 0,
};
});
if (o.c && !containerSeen) {
containerSeen = true;
finalState = { ...o };
}
if (o.videoSrc && !videoSrcFound) videoSrcFound = true;
if (o.vTime > 0 && !videoStarted) { videoStarted = true; break; }
}
}
urlChecks.push(checks(`.asg-container injected on click`, containerSeen,
containerSeen ? `data-spot-id=${finalState?.spotId}, videoSrc=${videoSrcFound ? 'set' : 'pending'}` : 'container never injected (12s timeout)'));
// CHECK 6: console clean
const adsbgErr = consoleErrs.filter(e => /ASGB LOADER|Response is not okay/.test(e));
urlChecks.push(checks(`no ASG console errors`, adsbgErr.length === 0,
adsbgErr.length === 0 ? 'clean' : `errors: ${adsbgErr.slice(0, 3).join('; ')}`));
if (SCREENSHOT) {
const path = `/home/w4/playwright-tests/screenshots/${id}.png`;
await page.screenshot({ path, fullPage: false });
console.log(` 📸 screenshot: ${path}`);
}
} catch (e) {
urlChecks.push(checks('page load', false, `error: ${e.message}`));
}
// Print result
const passed = urlChecks.filter(c => c.pass).length;
const total = urlChecks.length;
const allPass = passed === total;
console.log(` ${allPass ? '✅' : '❌'} ${passed}/${total} checks pass`);
for (const c of urlChecks) {
console.log(` ${c.pass ? '✓' : '✗'} ${c.check}${c.detail}`);
}
results.push({ url, id, allPass, checks: urlChecks, vastResp });
await page.close();
}
await browser.close();
// Summary
console.log(`\n${'='.repeat(70)}\nSUMMARY:`);
const totalUrls = results.length;
const passedUrls = results.filter(r => r.allPass).length;
console.log(` ${passedUrls}/${totalUrls} URLs passed all checks`);
results.forEach(r => console.log(` ${r.allPass ? '✅' : '❌'} /v/box/${r.id}`));
if (JSON_OUT) {
const reportPath = '/home/w4/playwright-tests/last-report.json';
writeFileSync(reportPath, JSON.stringify({ ts: new Date().toISOString(), results }, null, 2));
console.log(`\n JSON report: ${reportPath}`);
}
process.exit(passedUrls === totalUrls ? 0 : 1);