mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-23 09:53:26 +00:00
feat(client): gate in-game ads by adblock detection + Admiral recovery (#4534)
## What Two related pieces, wired into the existing `window.adsEnabled` / `userMeResponse` ad flow: 1. **`AdGatekeeper`** — decides whether the *intrusive* in-game ad may show. Once a blocker is **ever** detected, the ad is suppressed **permanently** (terminal state, persisted to `localStorage["adblock-detected"]`). Ad-block users are highly ad-sensitive, so disabling the blocker does **not** unlock the ad — in this or any future session. Detection = a DOM bait probe, refined by Admiral's `measure.detected` signal (`adblocking && !whitelisted`) when it fires. Clean users are never latched. 2. **`Admiral.ts`** — injects the ad-recovery tag (command-queue stub + payload + GAM targeting shim) for **ad-eligible users only**. Paid/`adfree` users have `window.adsEnabled === false`, so Admiral never loads and its adblock popup can't fire for them. Only the in-game ad (`InGamePromo`) is gated — it now loads via `adGatekeeper.whenClear(...)`. Passive homepage/gutter ads are unchanged. ## Why - Paid users (any shop purchase → `adfree` for life) must never see ads *or* load Admiral. - Free adblock users get Admiral's recovery popup, but should never be hit with an intrusive in-game ad even if they disable their blocker. ## How it behaves | Visitor | Admiral | In-game ad | |---|---|---| | Paid (`adfree`) | never loaded | never shown | | Free, no adblock | loaded | shown | | Free, adblock on (or ever was) | loaded (recovery popup) | suppressed forever | | Free, adblock blocks Admiral too | callback never fires | bait fallback suppresses | ## Testing - **Unit:** `tests/AdGatekeeper.test.ts` (9 cases) — terminal latch, "disabling blocker doesn't unlock", cross-session persistence, seed path, no-false-positive. `tsc` clean, `eslint` clean. - **Manual (headless Chromium, real bootstrap):** free user → `adsEnabled: true`, Admiral tag injected + payload initialized, `persisted: null` (no false positive); simulated blocker → flag latches to `"1"`; reload with no blocker → still `"1"` (forever); reset clean afterward. ## Notes / follow-ups - The GAM targeting shim (block 3 of the provider's tag) is ported verbatim but is likely a no-op here since serving is via Playwire RAMP, not Google Ad Manager. Kept for fidelity; can drop if unused. - `ADMIRAL_PAYLOAD_SRC` is a disguised, rotating domain — re-sync from the provider when they reissue the tag. - Admiral's own popup is dashboard-configured and typically domain-locked; best verified on the production domain with a real blocker. - `res.subscribed` (Admiral's own ad-free pass) is intentionally ignored — OpenFront's ad-free is the server `adfree` flag. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,181 @@
|
|||||||
|
export type AdblockState = "blocked" | "clear";
|
||||||
|
|
||||||
|
type Listener = (state: AdblockState, canShowAds: boolean) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Once a blocker is ever detected we suppress the in-game ad forever, so the
|
||||||
|
* verdict is persisted. Safe to store client-side: forging it only opts a user
|
||||||
|
* OUT of ads (which a blocker already does), so there's nothing to exploit.
|
||||||
|
*/
|
||||||
|
const ADBLOCK_STORAGE_KEY = "adblock-detected";
|
||||||
|
|
||||||
|
export interface AdGatekeeperOptions {
|
||||||
|
/** Background re-check interval. Kept lazy — aggressive polling draws filter-list heat. */
|
||||||
|
pollMs?: number;
|
||||||
|
/**
|
||||||
|
* Adblock probe. Defaults to a DOM bait check; injectable so the state
|
||||||
|
* machine can be unit-tested without a real blocker (jsdom does no layout,
|
||||||
|
* so the bait always reads "blocked" there).
|
||||||
|
*/
|
||||||
|
probe?: () => Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decides whether the *intrusive* in-game ad may show.
|
||||||
|
*
|
||||||
|
* Ad-block users are far more ad-sensitive, so once we ever detect a blocker
|
||||||
|
* this session the ad is suppressed PERMANENTLY — disabling the blocker does
|
||||||
|
* NOT unlock it. The ad shows only for users who have been continuously
|
||||||
|
* blocker-free. `canShowAds` is true only in 'clear'; 'blocked' is terminal.
|
||||||
|
*
|
||||||
|
* Orthogonal to `window.adsEnabled` (the entitlement gate for adfree /
|
||||||
|
* CrazyGames users). Construct/start it only for ad-eligible users — paid /
|
||||||
|
* adfree users never build one, so no bait element or polling runs for them.
|
||||||
|
* A fast external signal (e.g. Admiral's `measure.detected`) feeds `seed()`.
|
||||||
|
*/
|
||||||
|
export class AdGatekeeper {
|
||||||
|
private state: AdblockState | null = null;
|
||||||
|
private listeners = new Set<Listener>();
|
||||||
|
private pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
private started = false;
|
||||||
|
private readonly pollMs: number;
|
||||||
|
private readonly probe: () => Promise<boolean>;
|
||||||
|
|
||||||
|
constructor(opts: AdGatekeeperOptions = {}) {
|
||||||
|
this.pollMs = opts.pollMs ?? 15000;
|
||||||
|
this.probe = opts.probe ?? (() => this.baitBlocked());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True only once we've confirmed the user has been blocker-free all session. */
|
||||||
|
get canShowAds(): boolean {
|
||||||
|
return this.state === "clear";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Subscribe to state changes. Emits the current state immediately if known. */
|
||||||
|
subscribe(fn: Listener): () => void {
|
||||||
|
this.listeners.add(fn);
|
||||||
|
if (this.state !== null) fn(this.state, this.canShowAds);
|
||||||
|
return () => this.listeners.delete(fn);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run `fn` once the gate is (or becomes) clear. Fires synchronously if
|
||||||
|
* already clear. Never fires once 'blocked' has latched. Returns an
|
||||||
|
* unsubscribe for the still-pending case.
|
||||||
|
*/
|
||||||
|
whenClear(fn: () => void): () => void {
|
||||||
|
if (this.canShowAds) {
|
||||||
|
fn();
|
||||||
|
return () => {};
|
||||||
|
}
|
||||||
|
const off = this.subscribe((_state, canShowAds) => {
|
||||||
|
if (canShowAds) {
|
||||||
|
off();
|
||||||
|
fn();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return off;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Feed an external adblock reading (e.g. Admiral's `measure.detected`) into
|
||||||
|
* the state machine as a fast, reliable signal. Ignored until started and
|
||||||
|
* once 'blocked' has latched.
|
||||||
|
*/
|
||||||
|
seed(blocked: boolean): void {
|
||||||
|
if (!this.started) return;
|
||||||
|
this.applyReading(blocked);
|
||||||
|
}
|
||||||
|
|
||||||
|
start(): void {
|
||||||
|
if (this.started) return;
|
||||||
|
this.started = true;
|
||||||
|
// A blocker detected in any past session suppresses the ad forever — no
|
||||||
|
// need to probe or listen at all.
|
||||||
|
if (readPersistedBlock()) {
|
||||||
|
this.transition("blocked");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void this.evaluate();
|
||||||
|
// Toggling an extension means leaving the tab and coming back — re-check on
|
||||||
|
// return. Cheap, event-driven, and catches a mid-session enable before the
|
||||||
|
// in-game ad fires, without hammering a poll.
|
||||||
|
document.addEventListener("visibilitychange", this.onVisibility);
|
||||||
|
window.addEventListener("focus", this.onFocus);
|
||||||
|
this.pollTimer = setInterval(() => void this.evaluate(), this.pollMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
this.stopProbing();
|
||||||
|
this.listeners.clear();
|
||||||
|
this.started = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private stopProbing(): void {
|
||||||
|
if (this.pollTimer !== null) clearInterval(this.pollTimer);
|
||||||
|
this.pollTimer = null;
|
||||||
|
document.removeEventListener("visibilitychange", this.onVisibility);
|
||||||
|
window.removeEventListener("focus", this.onFocus);
|
||||||
|
}
|
||||||
|
|
||||||
|
private onVisibility = (): void => {
|
||||||
|
if (!document.hidden) void this.evaluate();
|
||||||
|
};
|
||||||
|
|
||||||
|
private onFocus = (): void => void this.evaluate();
|
||||||
|
|
||||||
|
private async evaluate(): Promise<void> {
|
||||||
|
if (this.state === "blocked") return; // terminal — nothing left to check
|
||||||
|
const blocked = await this.probe();
|
||||||
|
if (!this.started) return; // stopped mid-probe; applyReading guards a late latch
|
||||||
|
this.applyReading(blocked);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fold a raw adblock reading into the state machine. 'blocked' is terminal. */
|
||||||
|
private applyReading(blocked: boolean): void {
|
||||||
|
if (this.state === "blocked") return; // once blocked, always blocked
|
||||||
|
if (blocked) {
|
||||||
|
persistBlock(); // suppress the in-game ad in this and every future session
|
||||||
|
this.transition("blocked");
|
||||||
|
this.stopProbing(); // suppressed forever — no need to keep probing
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.transition("clear");
|
||||||
|
}
|
||||||
|
|
||||||
|
private transition(next: AdblockState): void {
|
||||||
|
if (next === this.state) return;
|
||||||
|
this.state = next;
|
||||||
|
for (const fn of this.listeners) fn(next, this.canShowAds);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** DOM bait detector: a blocker hides/collapses elements with ad-like classes. */
|
||||||
|
private async baitBlocked(): Promise<boolean> {
|
||||||
|
const bait = document.createElement("div");
|
||||||
|
bait.className = "adsbox ad-banner pub_300x250";
|
||||||
|
bait.style.cssText = "position:absolute;left:-9999px;width:1px;height:1px;";
|
||||||
|
document.body.appendChild(bait);
|
||||||
|
await new Promise(requestAnimationFrame); // let the blocker act
|
||||||
|
const blocked = bait.offsetHeight === 0 || bait.offsetParent === null;
|
||||||
|
bait.remove();
|
||||||
|
return blocked;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readPersistedBlock(): boolean {
|
||||||
|
try {
|
||||||
|
return window.localStorage.getItem(ADBLOCK_STORAGE_KEY) === "1";
|
||||||
|
} catch {
|
||||||
|
return false; // storage disabled (private mode, etc.)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistBlock(): void {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(ADBLOCK_STORAGE_KEY, "1");
|
||||||
|
} catch {
|
||||||
|
/* best-effort; falls back to session-only suppression */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const adGatekeeper = new AdGatekeeper();
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
export interface AdmiralMeasureResult {
|
||||||
|
/** True when the visitor has an adblocker active. */
|
||||||
|
adblocking?: boolean;
|
||||||
|
/**
|
||||||
|
* Admiral's OWN ad-free pass (its paywall), NOT our tier. Intentionally
|
||||||
|
* ignored: OpenFront ad-free is the server `adfree` flag (any shop purchase →
|
||||||
|
* ad-free for life), which already zeroes `window.adsEnabled` so Admiral
|
||||||
|
* never loads for those users. Only wire this if we ever sell via Admiral.
|
||||||
|
*/
|
||||||
|
subscribed?: boolean;
|
||||||
|
/** True when the visitor has whitelisted this site in their blocker (ads render). */
|
||||||
|
whitelisted?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type AdmiralFn = {
|
||||||
|
(
|
||||||
|
hook: "after",
|
||||||
|
event: "measure.detected",
|
||||||
|
cb: (res: AdmiralMeasureResult) => void,
|
||||||
|
): void;
|
||||||
|
q?: unknown[];
|
||||||
|
v?: number;
|
||||||
|
s?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface GoogleTag {
|
||||||
|
cmd: Array<() => void>;
|
||||||
|
pubads?: () => { setTargeting: (key: string, value: string) => void };
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
admiral?: AdmiralFn;
|
||||||
|
googletag?: GoogleTag;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admiral ad-recovery payload. The delivery domain is intentionally disguised
|
||||||
|
* and ROTATES — when the provider reissues the tag, re-sync this from it.
|
||||||
|
* This third-party script runs with full page access, so it is injected ONLY
|
||||||
|
* for ad-eligible (non-adfree) users and NEVER for paid sessions.
|
||||||
|
*/
|
||||||
|
const ADMIRAL_PAYLOAD_SRC =
|
||||||
|
"https://introjava.com/assets/js/gfjjtpm64er_5.v1.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* localStorage key Admiral writes its GAM targeting segments (`.lgk`) to.
|
||||||
|
* Encodes the Admiral property id; re-sync alongside ADMIRAL_PAYLOAD_SRC.
|
||||||
|
*/
|
||||||
|
const ADMIRAL_GAM_KEY = "_aQS02Mzg3RDEwMjU5NjBGOUQ0REY5Q0YwOTEtNjc0";
|
||||||
|
|
||||||
|
let injected = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Injects the Admiral tag. Call for FREE (ad-eligible) users ONLY — gating
|
||||||
|
* happens at the single call site so paid users never load Admiral at all
|
||||||
|
* (its adblock popup fires autonomously once the payload runs).
|
||||||
|
*/
|
||||||
|
export function loadAdmiral(): void {
|
||||||
|
if (injected) return;
|
||||||
|
injected = true;
|
||||||
|
|
||||||
|
// 1. Command-queue stub — must exist before the payload loads so buffered
|
||||||
|
// admiral(...) calls replay once it initializes (same pattern as gtag).
|
||||||
|
if (!window.admiral) {
|
||||||
|
const stub = function (...args: unknown[]): void {
|
||||||
|
(stub.q = stub.q ?? []).push(args);
|
||||||
|
} as AdmiralFn;
|
||||||
|
stub.v = 2;
|
||||||
|
stub.s = "1";
|
||||||
|
window.admiral = stub;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Async-load the remote payload.
|
||||||
|
const s = document.createElement("script");
|
||||||
|
s.async = true;
|
||||||
|
s.src = ADMIRAL_PAYLOAD_SRC;
|
||||||
|
document.head.appendChild(s);
|
||||||
|
|
||||||
|
// 3. GAM key-value shim: replay Admiral's stored targeting segments into
|
||||||
|
// Google Ad Manager. Self-guarded; harmlessly no-ops if GAM isn't used
|
||||||
|
// to serve (this app serves via Playwire RAMP).
|
||||||
|
applyGamTargeting();
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyGamTargeting(): void {
|
||||||
|
const push = (): void => {
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(ADMIRAL_GAM_KEY);
|
||||||
|
if (raw === null) return;
|
||||||
|
const lgk: Array<[string, string?]> = JSON.parse(raw).lgk ?? [];
|
||||||
|
const pubads = window.googletag?.pubads?.();
|
||||||
|
if (!pubads) return;
|
||||||
|
for (const entry of lgk) {
|
||||||
|
if (entry?.[0]) pubads.setTargeting(entry[0], entry[1] ?? "");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* targeting is best-effort */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const gt = (window.googletag = window.googletag ?? { cmd: [] });
|
||||||
|
gt.cmd = gt.cmd ?? [];
|
||||||
|
if (typeof gt.pubads === "function") push();
|
||||||
|
else gt.cmd.unshift(push);
|
||||||
|
} catch {
|
||||||
|
/* targeting is best-effort */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers Admiral's measurement callback. Safe to call before the payload
|
||||||
|
* loads (buffered in the command queue). Fires only if Admiral's payload
|
||||||
|
* actually loads — a blocker that kills the delivery domain also kills this,
|
||||||
|
* which is why AdGatekeeper's bait detector remains the source of truth.
|
||||||
|
*/
|
||||||
|
export function onAdmiralMeasured(
|
||||||
|
cb: (res: AdmiralMeasureResult) => void,
|
||||||
|
): void {
|
||||||
|
window.admiral?.("after", "measure.detected", cb);
|
||||||
|
}
|
||||||
@@ -14,6 +14,8 @@ import { GameEnv } from "../core/configuration/Config";
|
|||||||
import { GameType } from "../core/game/Game";
|
import { GameType } from "../core/game/Game";
|
||||||
import { UserSettings } from "../core/game/UserSettings";
|
import { UserSettings } from "../core/game/UserSettings";
|
||||||
import "./AccountModal";
|
import "./AccountModal";
|
||||||
|
import { adGatekeeper } from "./AdGatekeeper";
|
||||||
|
import { loadAdmiral, onAdmiralMeasured } from "./Admiral";
|
||||||
import { getUserMe, invalidateUserMe } from "./Api";
|
import { getUserMe, invalidateUserMe } from "./Api";
|
||||||
import { reauthAfterCrazyGamesChange, userAuth } from "./Auth";
|
import { reauthAfterCrazyGamesChange, userAuth } from "./Auth";
|
||||||
import "./ClanModal";
|
import "./ClanModal";
|
||||||
@@ -498,6 +500,22 @@ class Client {
|
|||||||
const isAdFree =
|
const isAdFree =
|
||||||
userMeResponse !== false && userMeResponse.player?.adfree === true;
|
userMeResponse !== false && userMeResponse.player?.adfree === true;
|
||||||
window.adsEnabled = !isAdFree && !crazyGamesSDK.isOnCrazyGames();
|
window.adsEnabled = !isAdFree && !crazyGamesSDK.isOnCrazyGames();
|
||||||
|
// Ad-eligible users only: paid/adfree users must never load Admiral (its
|
||||||
|
// adblock popup fires autonomously once the payload runs). Start watching
|
||||||
|
// adblock state; once a blocker is ever detected the in-game ad is
|
||||||
|
// suppressed forever (persisted) — those users are highly ad-sensitive.
|
||||||
|
if (window.adsEnabled) {
|
||||||
|
loadAdmiral();
|
||||||
|
// Admiral's read is more reliable than our DOM bait, so use it as a
|
||||||
|
// fast initial signal. A blocker that whitelists this site still shows
|
||||||
|
// ads, so "blocked" means adblocking AND not whitelisted.
|
||||||
|
onAdmiralMeasured((res) => {
|
||||||
|
adGatekeeper.seed(
|
||||||
|
res.adblocking === true && res.whitelisted !== true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
adGatekeeper.start();
|
||||||
|
}
|
||||||
document.dispatchEvent(
|
document.dispatchEvent(
|
||||||
new CustomEvent("userMeResponse", {
|
new CustomEvent("userMeResponse", {
|
||||||
detail: userMeResponse,
|
detail: userMeResponse,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { LitElement, html } from "lit";
|
import { LitElement, html } from "lit";
|
||||||
import { customElement } from "lit/decorators.js";
|
import { customElement } from "lit/decorators.js";
|
||||||
|
import { adGatekeeper } from "../../AdGatekeeper";
|
||||||
import { Controller } from "../../Controller";
|
import { Controller } from "../../Controller";
|
||||||
import { GameView } from "../../view";
|
import { GameView } from "../../view";
|
||||||
|
|
||||||
@@ -18,6 +19,7 @@ export class InGamePromo extends LitElement implements Controller {
|
|||||||
private bottomRailDestroyed: boolean = false;
|
private bottomRailDestroyed: boolean = false;
|
||||||
private cornerAdShown: boolean = false;
|
private cornerAdShown: boolean = false;
|
||||||
private adCheckInterval: ReturnType<typeof setTimeout> | null = null;
|
private adCheckInterval: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
private adGateOff: (() => void) | null = null;
|
||||||
|
|
||||||
createRenderRoot() {
|
createRenderRoot() {
|
||||||
return this;
|
return this;
|
||||||
@@ -56,12 +58,17 @@ export class InGamePromo extends LitElement implements Controller {
|
|||||||
|
|
||||||
if (!window.adsEnabled) return;
|
if (!window.adsEnabled) return;
|
||||||
|
|
||||||
this.shouldShow = true;
|
// Show the intrusive in-game ad only to users who have been blocker-free.
|
||||||
this.requestUpdate();
|
// Once a blocker is ever detected the gate latches suppressed forever
|
||||||
|
// (persisted across sessions), so whenClear never fires for those users.
|
||||||
|
this.adGateOff = adGatekeeper.whenClear(() => {
|
||||||
|
this.shouldShow = true;
|
||||||
|
this.requestUpdate();
|
||||||
|
|
||||||
this.updateComplete.then(() => {
|
this.updateComplete.then(() => {
|
||||||
this.loadAd();
|
this.loadAd();
|
||||||
this.checkForAds();
|
this.checkForAds();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,6 +118,10 @@ export class InGamePromo extends LitElement implements Controller {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public hideAd(): void {
|
public hideAd(): void {
|
||||||
|
if (this.adGateOff) {
|
||||||
|
this.adGateOff();
|
||||||
|
this.adGateOff = null;
|
||||||
|
}
|
||||||
if (this.adCheckInterval) {
|
if (this.adCheckInterval) {
|
||||||
clearInterval(this.adCheckInterval);
|
clearInterval(this.adCheckInterval);
|
||||||
this.adCheckInterval = null;
|
this.adCheckInterval = null;
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { AdGatekeeper } from "../src/client/AdGatekeeper";
|
||||||
|
|
||||||
|
const STORAGE_KEY = "adblock-detected";
|
||||||
|
|
||||||
|
// A probe whose reading we flip at will, so we can drive the state machine
|
||||||
|
// without a real adblocker (jsdom does no layout, so the DOM bait is useless).
|
||||||
|
function controllableProbe() {
|
||||||
|
const ref = { blocked: false };
|
||||||
|
return { ref, probe: () => Promise.resolve(ref.blocked) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flush the async probe microtask (no timers involved in the state machine now).
|
||||||
|
const flush = () => vi.advanceTimersByTimeAsync(0);
|
||||||
|
|
||||||
|
describe("AdGatekeeper", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
afterEach(() => vi.useRealTimers());
|
||||||
|
|
||||||
|
it("clears (shows ads) for a user who has been blocker-free", async () => {
|
||||||
|
const { probe } = controllableProbe(); // adblock off
|
||||||
|
const gate = new AdGatekeeper({ probe });
|
||||||
|
let cleared = 0;
|
||||||
|
gate.whenClear(() => cleared++);
|
||||||
|
gate.start();
|
||||||
|
|
||||||
|
await flush();
|
||||||
|
expect(gate.canShowAds).toBe(true);
|
||||||
|
expect(cleared).toBe(1);
|
||||||
|
expect(localStorage.getItem(STORAGE_KEY)).toBeNull(); // clean users aren't persisted
|
||||||
|
|
||||||
|
gate.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stays blocked while adblock is on", async () => {
|
||||||
|
const { ref, probe } = controllableProbe();
|
||||||
|
ref.blocked = true;
|
||||||
|
const gate = new AdGatekeeper({ probe });
|
||||||
|
gate.start();
|
||||||
|
|
||||||
|
await flush();
|
||||||
|
expect(gate.canShowAds).toBe(false);
|
||||||
|
|
||||||
|
gate.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is terminal: disabling the blocker does NOT unlock ads", async () => {
|
||||||
|
const { ref, probe } = controllableProbe();
|
||||||
|
ref.blocked = true;
|
||||||
|
const gate = new AdGatekeeper({ probe });
|
||||||
|
let cleared = 0;
|
||||||
|
gate.whenClear(() => cleared++);
|
||||||
|
gate.start();
|
||||||
|
await flush();
|
||||||
|
expect(gate.canShowAds).toBe(false);
|
||||||
|
|
||||||
|
// User turns the blocker off; a re-check runs but the verdict stands.
|
||||||
|
ref.blocked = false;
|
||||||
|
window.dispatchEvent(new Event("focus"));
|
||||||
|
await flush();
|
||||||
|
expect(gate.canShowAds).toBe(false);
|
||||||
|
expect(cleared).toBe(0);
|
||||||
|
|
||||||
|
gate.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("latches blocked if the blocker is enabled after a clean start", async () => {
|
||||||
|
const { ref, probe } = controllableProbe(); // starts off → clear
|
||||||
|
const gate = new AdGatekeeper({ probe });
|
||||||
|
gate.start();
|
||||||
|
await flush();
|
||||||
|
expect(gate.canShowAds).toBe(true);
|
||||||
|
|
||||||
|
ref.blocked = true;
|
||||||
|
window.dispatchEvent(new Event("focus"));
|
||||||
|
await flush();
|
||||||
|
expect(gate.canShowAds).toBe(false);
|
||||||
|
|
||||||
|
gate.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("seed latches blocked terminally", async () => {
|
||||||
|
const { probe } = controllableProbe(); // bait reads unblocked
|
||||||
|
const gate = new AdGatekeeper({ probe });
|
||||||
|
gate.start();
|
||||||
|
await flush();
|
||||||
|
expect(gate.canShowAds).toBe(true);
|
||||||
|
|
||||||
|
gate.seed(true);
|
||||||
|
expect(gate.canShowAds).toBe(false);
|
||||||
|
|
||||||
|
// A later "unblocked" seed cannot revive it.
|
||||||
|
gate.seed(false);
|
||||||
|
expect(gate.canShowAds).toBe(false);
|
||||||
|
|
||||||
|
gate.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("seed is ignored before start()", () => {
|
||||||
|
const { probe } = controllableProbe();
|
||||||
|
const gate = new AdGatekeeper({ probe });
|
||||||
|
gate.seed(true);
|
||||||
|
expect(gate.canShowAds).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("whenClear fires synchronously once already clear", async () => {
|
||||||
|
const { probe } = controllableProbe();
|
||||||
|
const gate = new AdGatekeeper({ probe });
|
||||||
|
gate.start();
|
||||||
|
await flush();
|
||||||
|
expect(gate.canShowAds).toBe(true);
|
||||||
|
|
||||||
|
let fired = false;
|
||||||
|
gate.whenClear(() => (fired = true));
|
||||||
|
expect(fired).toBe(true);
|
||||||
|
|
||||||
|
gate.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("persists the block so a future session stays suppressed even with adblock off", async () => {
|
||||||
|
const { ref, probe } = controllableProbe();
|
||||||
|
ref.blocked = true;
|
||||||
|
const g1 = new AdGatekeeper({ probe });
|
||||||
|
g1.start();
|
||||||
|
await flush();
|
||||||
|
expect(g1.canShowAds).toBe(false);
|
||||||
|
expect(localStorage.getItem(STORAGE_KEY)).toBe("1");
|
||||||
|
g1.stop();
|
||||||
|
|
||||||
|
// New session: adblock now OFF, but the persisted verdict stands.
|
||||||
|
let fired = false;
|
||||||
|
const g2 = new AdGatekeeper({ probe: () => Promise.resolve(false) });
|
||||||
|
g2.whenClear(() => (fired = true));
|
||||||
|
g2.start();
|
||||||
|
await flush();
|
||||||
|
expect(g2.canShowAds).toBe(false);
|
||||||
|
expect(fired).toBe(false);
|
||||||
|
g2.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a pre-existing persisted flag latches blocked on start without probing", async () => {
|
||||||
|
localStorage.setItem(STORAGE_KEY, "1");
|
||||||
|
let probed = false;
|
||||||
|
const gate = new AdGatekeeper({
|
||||||
|
probe: () => {
|
||||||
|
probed = true;
|
||||||
|
return Promise.resolve(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
gate.start();
|
||||||
|
await flush();
|
||||||
|
expect(gate.canShowAds).toBe(false);
|
||||||
|
expect(probed).toBe(false); // verdict was already final — never probed
|
||||||
|
gate.stop();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user