diff --git a/docs/ADS.md b/docs/ADS.md index cb71b13..37f7d60 100644 --- a/docs/ADS.md +++ b/docs/ADS.md @@ -6,6 +6,43 @@ 🟢 **Working knowledge** — Skeleton наповнюємо. +## ⚙️ Automated regression test — `ad-test` + +Швидкий smoke перевірити що ad flow не поламано після зміни. + +```bash +# default — 3 URLs з 8148 (швидкий smoke) +ad-test + +# конкретні URLs +ad-test "https://www.xn--3dsq7teoyo9d.com/v/box/123/foo" \ + "https://t1.atube.sex/v/box/456/bar" + +# з screenshots + JSON report +ad-test --screenshot --json "https://..." "https://..." +``` + +**Що перевіряє per URL:** +1. ✓ ad-bundle loaded (`window._adConfig` set, spot IDs читаються) +2. ✓ popunder SDK loads +3. ✓ `/api/users/` повертає `` (не empty no-fill) +4. ✓ mode = "vast" після popunder cooldown +5. ✓ `.asg-container` injects на click `pjs_play_btn` (правильний `data-spot-id`) +6. ✓ console clean (no `[ASGB LOADER]` errors, no `pageerror`) + +**Exit code:** 0 = всі pass, 1 = ≥1 fail. Можна chain у CI / git hook. + +**Реалізація:** +- Wrapper: `/home/w4/bin/ad-test` +- Script: `/home/w4/playwright-tests/ad-regression.mjs` (Playwright + Chromium headless) +- Repo copy (versioned): `scripts/ad-regression.mjs` + `scripts/ad-test.sh` + +**Обмеження headless:** +- Real video playback не verifies (autoplay restrictions, VPAID не fully supported у chromium-headless-shell) +- Network-level VAST chain validation (через `//` детекцію) cover 95% regressions без візуальної перевірки +- Якщо треба видеоплей — `ad-test --full` (full chromium, але VPAID все ще incomplete) +- Альтернатива для full visual: запусти у real browser з `?debug=1` + дивись HUD + ## Architecture ### Mirrors (зеркала) diff --git a/docs/INFRASTRUCTURE.md b/docs/INFRASTRUCTURE.md index 1b70faf..cbd5618 100644 --- a/docs/INFRASTRUCTURE.md +++ b/docs/INFRASTRUCTURE.md @@ -37,6 +37,7 @@ | `trigger-bots [bot...]` | Auto-pull + signal новий task через `~/scripts/trigger_bot.sh` (flock + idle-wait + verify). Стандартний dispatch. Лог: `/tmp/trigger_bot.log`. | | `restart-bots [bot...]` | kill+start+wait+trigger. Для зависів/нових проектів. | | `deploy-admin-orest.sh` | (опціонально, інший проект) | +| `ad-test [url1 ...]` | Headless ad-flow regression test (Playwright + Chromium). 6 checks per URL. Default — 3 URLs з 8148. Деталі: [docs/ADS.md § Automated regression test](ADS.md#%EF%B8%8F-automated-regression-test--ad-test). | ## `~/scripts/` — bot communication diff --git a/scripts/ad-regression.mjs b/scripts/ad-regression.mjs new file mode 100644 index 0000000..9cf0ef2 --- /dev/null +++ b/scripts/ad-regression.mjs @@ -0,0 +1,257 @@ +#!/usr/bin/env node +// ad-regression.mjs — automated ad-flow validation for tubev sites +// +// Usage: +// node ad-regression.mjs [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/ повертає (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 [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 (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 = //.test(body); + const hasWrapper = //.test(body); + vastResp = { status: r.status, hasAd, hasInLine, hasWrapper, snippet: body.slice(0, 200) }; + urlChecks.push(checks(`VAST endpoint returns (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); diff --git a/scripts/ad-test.sh b/scripts/ad-test.sh new file mode 100755 index 0000000..ec126a9 --- /dev/null +++ b/scripts/ad-test.sh @@ -0,0 +1,33 @@ +#!/bin/bash +# ad-test — wrapper for /home/w4/playwright-tests/ad-regression.mjs +# +# Usage: +# ad-test [url2] ... [--screenshot] [--json] +# ad-test # без аргументів = default 3 URLs з 8148 +# +# Examples: +# ad-test "https://www.xn--3dsq7teoyo9d.com/v/box/261085/foo" +# ad-test --screenshot "https://t1.atube.sex/v/box/123/bar" +# +# Що тестує (per URL): +# 1. ad-bundle loaded (window._adConfig set) +# 2. popunder SDK loads +# 3. /api/users/ повертає (not empty no-fill) +# 4. mode = "vast" після popunder cooldown +# 5. .asg-container injects на click pjs_play_btn +# 6. console clean (no [ASGB LOADER] errors) +# +# Exit code: 0 = всі pass, 1 = >=1 fail. + +set -e + +if [[ $# -eq 0 ]]; then + # default — 3 URLs з 8148 для quick smoke test + set -- \ + "https://www.xn--3dsq7teoyo9d.com/v/box/261085/get-your-fill-of-hardcore-anal" \ + "https://www.xn--3dsq7teoyo9d.com/v/box/306026/online-kat-gives-wild-footjob" \ + "https://www.xn--3dsq7teoyo9d.com/v/box/328723/lets-talk-about-hot-j-porn-videos" +fi + +cd /home/w4/playwright-tests +exec timeout 300 node ad-regression.mjs "$@"