Files
OpenFrontIO/tests/AdGatekeeper.test.ts
T
d59b5cdfd7 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>
2026-07-16 15:05:23 -07:00

160 lines
4.6 KiB
TypeScript

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();
});
});