mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-17 15:29: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:
committed by
evanpelle
co-authored by
Claude Opus 4.8
parent
16be9d7c15
commit
e3676439d2
@@ -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 { UserSettings } from "../core/game/UserSettings";
|
||||
import "./AccountModal";
|
||||
import { adGatekeeper } from "./AdGatekeeper";
|
||||
import { loadAdmiral, onAdmiralMeasured } from "./Admiral";
|
||||
import { getUserMe, invalidateUserMe } from "./Api";
|
||||
import { reauthAfterCrazyGamesChange, userAuth } from "./Auth";
|
||||
import "./ClanModal";
|
||||
@@ -486,6 +488,22 @@ class Client {
|
||||
const isAdFree =
|
||||
userMeResponse !== false && userMeResponse.player?.adfree === true;
|
||||
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(
|
||||
new CustomEvent("userMeResponse", {
|
||||
detail: userMeResponse,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { LitElement, html } from "lit";
|
||||
import { customElement } from "lit/decorators.js";
|
||||
import { adGatekeeper } from "../../AdGatekeeper";
|
||||
import { Controller } from "../../Controller";
|
||||
import { GameView } from "../../view";
|
||||
|
||||
@@ -18,6 +19,7 @@ export class InGamePromo extends LitElement implements Controller {
|
||||
private bottomRailDestroyed: boolean = false;
|
||||
private cornerAdShown: boolean = false;
|
||||
private adCheckInterval: ReturnType<typeof setTimeout> | null = null;
|
||||
private adGateOff: (() => void) | null = null;
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
@@ -56,12 +58,17 @@ export class InGamePromo extends LitElement implements Controller {
|
||||
|
||||
if (!window.adsEnabled) return;
|
||||
|
||||
this.shouldShow = true;
|
||||
this.requestUpdate();
|
||||
// Show the intrusive in-game ad only to users who have been blocker-free.
|
||||
// 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.loadAd();
|
||||
this.checkForAds();
|
||||
this.updateComplete.then(() => {
|
||||
this.loadAd();
|
||||
this.checkForAds();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -111,6 +118,10 @@ export class InGamePromo extends LitElement implements Controller {
|
||||
}
|
||||
|
||||
public hideAd(): void {
|
||||
if (this.adGateOff) {
|
||||
this.adGateOff();
|
||||
this.adGateOff = null;
|
||||
}
|
||||
if (this.adCheckInterval) {
|
||||
clearInterval(this.adCheckInterval);
|
||||
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