Files
vtube/scripts/ad-regression.mjs
goboss c9890a32fe 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>
2026-05-02 22:45:31 +00:00

258 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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);