@chipmobilesdk/rn-ads 0.5.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -16,13 +16,15 @@
16
16
  * a banner through the load/show handle model, which does not fit it.
17
17
  */
18
18
 
19
- import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
19
+ import React, { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
20
20
  import { View, type LayoutChangeEvent, type ViewStyle } from 'react-native';
21
21
 
22
22
  import type { PlacementState } from '../config/types';
23
- import { AdsError, normalizeProviderError } from '../diagnostics/errors';
23
+ import { normalizeProviderError } from '../diagnostics/errors';
24
+ import type { AdEvent } from '../diagnostics/events';
25
+ import type { EngineFailureKind } from '../engine/port';
24
26
  import { useAds } from './AdsProvider';
25
- import type { ThrottledResult } from '../core/adsInstance';
27
+ import { getInternals, type ThrottledResult } from '../core/adsInstance';
26
28
  import { useAdsStatus } from './hooks';
27
29
 
28
30
  export interface AdsBannerProps {
@@ -33,21 +35,14 @@ export interface AdsBannerProps {
33
35
  readonly style?: ViewStyle;
34
36
  }
35
37
 
36
- interface ProviderBannerModule {
37
- BannerAd: React.ComponentType<Record<string, unknown>>;
38
- BannerAdSize: Record<string, string>;
39
- }
40
-
41
- function loadBannerModule(): ProviderBannerModule | null {
42
- try {
43
- return require('react-native-google-mobile-ads') as ProviderBannerModule;
44
- } catch {
45
- return null;
46
- }
47
- }
48
-
49
38
  export function AdsBanner({ placementKey, onStateChange, style }: AdsBannerProps) {
50
39
  const ads = useAds();
40
+ // The instance's banner wiring (ADS-13). An instance the app wrote by hand has
41
+ // none, and is treated like a missing one.
42
+ const internals = useMemo(() => (ads ? getInternals(ads) : null), [ads]);
43
+ // The throttle token for this mount: stable across renders and minted by React
44
+ // rather than by a counter, so a render that runs twice charges one slot.
45
+ const mountToken = useId();
51
46
  // The reported state lives in a ref rather than in component state: the host
52
47
  // itself renders the same tree for every state, so storing it would only
53
48
  // force re-renders the PERF-002 budget does not need to spend. The instance
@@ -58,6 +53,9 @@ export function AdsBanner({ placementKey, onStateChange, style }: AdsBannerProps
58
53
  // produces a genuinely re-adapted banner rather than a stretched old one.
59
54
  const [cycle, setCycle] = useState(0);
60
55
  const correlationRef = useRef<string | null>(null);
56
+ // Whether the current correlation already reported `loaded`. The provider
57
+ // auto-refreshes a banner inside one mount; each refresh is a new cycle.
58
+ const loadedInCycleRef = useRef(false);
61
59
  const startedAtRef = useRef(0);
62
60
  // Whether this mount has already taken a throttle slot, and the refusal if it
63
61
  // was refused. Resets with the mount, which is exactly the granularity FR-140
@@ -66,23 +64,29 @@ export function AdsBanner({ placementKey, onStateChange, style }: AdsBannerProps
66
64
  decided: boolean;
67
65
  refusal: ThrottledResult | null;
68
66
  }>({ decided: false, refusal: null });
67
+ // Which attempt this mount is on. A refused mount asks again once the window
68
+ // passes (ADS-06), and each attempt needs its own token: the throttle replays
69
+ // the stored verdict for a token it has seen.
70
+ const throttleAttemptRef = useRef(0);
69
71
 
70
- const provider = useMemo(loadBannerModule, []);
72
+ // The engine's banner component (ADS-12): the host never names the provider.
73
+ const banner = useMemo(() => internals?.banner() ?? null, [internals]);
71
74
  // Initialization is asynchronous and a banner is almost always mounted before
72
75
  // it finishes. Without this the resolution below would be computed once, while
73
76
  // the instance was still `not_started`, be refused, and never be retried — the
74
77
  // banner would sit at `unavailable` forever on a perfectly healthy app.
75
78
  const status = useAdsStatus();
76
79
 
77
- // Keyed on `cycle` so a genuine width change mints a fresh correlation id and
78
- // starts a new, separately traceable load (FR-038, FR-052), and on `status` so
79
- // the banner recovers the moment the instance becomes able to request.
80
+ // Keyed on `cycle` so a genuine width change starts a new, separately traceable
81
+ // load (FR-038, FR-052), and on `status` so the banner recovers the moment the
82
+ // instance becomes able to request. Pure: the cycle's correlation id is minted
83
+ // in the effect that starts it, not here (ADS-20).
80
84
  const resolved = useMemo(
81
85
  () =>
82
- ads && status === 'ready'
83
- ? { cycle, value: ads.internals.resolveBanner(placementKey) }
86
+ internals && status === 'ready'
87
+ ? { cycle, value: internals.resolveBanner(placementKey) }
84
88
  : null,
85
- [ads, placementKey, cycle, status],
89
+ [internals, placementKey, cycle, status],
86
90
  );
87
91
  const resolution = resolved?.value ?? null;
88
92
 
@@ -92,10 +96,10 @@ export function AdsBanner({ placementKey, onStateChange, style }: AdsBannerProps
92
96
  return;
93
97
  }
94
98
  stateRef.current = next;
95
- ads?.internals.setPlacementState(placementKey, next);
99
+ internals?.setPlacementState(placementKey, next);
96
100
  onStateChange?.(next);
97
101
  },
98
- [ads, onStateChange, placementKey],
102
+ [internals, onStateChange, placementKey],
99
103
  );
100
104
 
101
105
  // FR-140. Decided **during render**, not in the effect below, because the
@@ -108,20 +112,23 @@ export function AdsBanner({ placementKey, onStateChange, style }: AdsBannerProps
108
112
  // out), while a later cycle in the *same* mount is a width re-adaptation —
109
113
  // rotation — which must not be refused or a rotated screen loses its banner
110
114
  // for up to a minute.
111
- if (ads && resolution?.ok && containerWidth > 0 && !throttleRef.current.decided) {
115
+ if (internals && resolution?.ok && containerWidth > 0 && !throttleRef.current.decided) {
112
116
  throttleRef.current = {
113
117
  decided: true,
114
- refusal: ads.internals.commitBannerRequest(placementKey, resolution.correlationId),
118
+ refusal: internals.commitBannerRequest(
119
+ placementKey,
120
+ `${mountToken}:${throttleAttemptRef.current}`,
121
+ ),
115
122
  };
116
123
  }
117
124
  const throttleRefusal = throttleRef.current.refusal;
118
125
 
119
126
  const usable = Boolean(
120
- ads && provider && resolution?.ok && containerWidth > 0 && !throttleRefusal,
127
+ internals && banner && resolution?.ok && containerWidth > 0 && !throttleRefusal,
121
128
  );
122
129
 
123
130
  useEffect(() => {
124
- if (!ads || !provider) {
131
+ if (!internals || !banner) {
125
132
  move('unavailable');
126
133
  return;
127
134
  }
@@ -165,30 +172,39 @@ export function AdsBanner({ placementKey, onStateChange, style }: AdsBannerProps
165
172
  // collapse the space for both, but only one is worth investigating.
166
173
  if (throttleRefusal) {
167
174
  move('throttled');
168
- ads.internals.emit({
175
+ internals.emit({
169
176
  type: 'request_throttled',
170
- timestamp: Date.now(),
177
+ timestamp: internals.now(),
171
178
  placementKey,
172
179
  format: 'banner',
173
180
  retryAfterMs: throttleRefusal.retryAfterMs,
174
- minRequestIntervalMs: ads.internals.throttleFloorFor(placementKey),
181
+ minRequestIntervalMs: internals.throttleFloorFor(placementKey),
182
+ });
183
+ // ADS-06: ask again in this same mount once the window has passed. Until
184
+ // 0.7.0 a refused banner stayed empty until it was remounted, so a banner
185
+ // pinned to a screen the user returned to within a minute never filled.
186
+ return internals.schedule(throttleRefusal.retryAfterMs, () => {
187
+ throttleRef.current = { decided: false, refusal: null };
188
+ throttleAttemptRef.current += 1;
189
+ setCycle(value => value + 1);
175
190
  });
176
- return;
177
191
  }
178
- correlationRef.current = resolution.correlationId;
179
- startedAtRef.current = Date.now();
192
+ const correlationId = internals.nextCorrelationId();
193
+ correlationRef.current = correlationId;
194
+ loadedInCycleRef.current = false;
195
+ startedAtRef.current = internals.now();
180
196
  move('loading');
181
- ads.internals.emit({
197
+ internals.emit({
182
198
  type: 'load_started',
183
- timestamp: Date.now(),
199
+ timestamp: startedAtRef.current,
184
200
  placementKey,
185
201
  format: 'banner',
186
- correlationId: resolution.correlationId,
202
+ correlationId,
187
203
  });
188
204
  // Deliberately keyed on `cycle`, not on every render: FR-041 forbids a
189
205
  // re-render from producing a second uncontrolled request.
190
206
  // eslint-disable-next-line react-hooks/exhaustive-deps
191
- }, [ads, provider, status, resolution, containerWidth, cycle]);
207
+ }, [internals, banner, status, resolution, containerWidth, cycle]);
192
208
 
193
209
  const onLayout = useCallback((event: LayoutChangeEvent) => {
194
210
  const width = Math.round(event.nativeEvent.layout.width);
@@ -206,48 +222,59 @@ export function AdsBanner({ placementKey, onStateChange, style }: AdsBannerProps
206
222
  }, []);
207
223
 
208
224
  const emit = useCallback(
209
- (event: Parameters<NonNullable<typeof ads>['internals']['emit']>[0]) => {
210
- ads?.internals.emit(event);
225
+ (event: AdEvent) => {
226
+ internals?.emit(event);
211
227
  },
212
- [ads],
228
+ [internals],
213
229
  );
230
+ const clock = useCallback(() => internals?.now() ?? Date.now(), [internals]);
214
231
 
215
232
  const handleLoaded = useCallback(() => {
233
+ // A second load in one correlation is the provider's auto-refresh: a new
234
+ // ad, so a new cycle. Under the old correlation its `loaded` and
235
+ // `impression` were deduplicated away and revenue went uncounted (ADS-05).
236
+ // For a refresh, `durationMs` is the time since the previous load.
237
+ if (loadedInCycleRef.current && internals) {
238
+ correlationRef.current = internals.nextCorrelationId();
239
+ }
240
+ loadedInCycleRef.current = true;
241
+ const loadedAt = clock();
216
242
  move('ready');
217
243
  emit({
218
244
  type: 'loaded',
219
- timestamp: Date.now(),
245
+ timestamp: loadedAt,
220
246
  placementKey,
221
247
  format: 'banner',
222
248
  correlationId: correlationRef.current ?? undefined,
223
- durationMs: Date.now() - startedAtRef.current,
249
+ durationMs: loadedAt - startedAtRef.current,
224
250
  });
225
- }, [emit, move, placementKey]);
251
+ startedAtRef.current = loadedAt;
252
+ }, [internals, clock, emit, move, placementKey]);
226
253
 
227
254
  const handleFailed = useCallback(
228
- (error: unknown) => {
229
- const normalized = normalizeProviderError(error, {
230
- operation: 'load',
231
- placementKey,
232
- format: 'banner',
233
- });
255
+ (error: unknown, kind?: EngineFailureKind) => {
256
+ const normalized = normalizeProviderError(
257
+ error,
258
+ { operation: 'load', placementKey, format: 'banner' },
259
+ kind,
260
+ );
234
261
  move('failed');
235
262
  emit({
236
263
  type: 'load_failed',
237
- timestamp: Date.now(),
264
+ timestamp: clock(),
238
265
  placementKey,
239
266
  format: 'banner',
240
267
  correlationId: correlationRef.current ?? undefined,
241
268
  error: normalized,
242
269
  });
243
270
  },
244
- [emit, move, placementKey],
271
+ [clock, emit, move, placementKey],
245
272
  );
246
273
 
247
274
  const handleImpression = useCallback(() => {
248
275
  emit({
249
276
  type: 'impression',
250
- timestamp: Date.now(),
277
+ timestamp: clock(),
251
278
  placementKey,
252
279
  format: 'banner',
253
280
  correlationId: correlationRef.current ?? undefined,
@@ -256,17 +283,17 @@ export function AdsBanner({ placementKey, onStateChange, style }: AdsBannerProps
256
283
  // reconciliation, so it is recorded rather than flattened (R5).
257
284
  source: 'provider',
258
285
  });
259
- }, [emit, placementKey]);
286
+ }, [clock, emit, placementKey]);
260
287
 
261
288
  const handleClicked = useCallback(() => {
262
289
  emit({
263
290
  type: 'click',
264
- timestamp: Date.now(),
291
+ timestamp: clock(),
265
292
  placementKey,
266
293
  format: 'banner',
267
294
  correlationId: correlationRef.current ?? undefined,
268
295
  });
269
- }, [emit, placementKey]);
296
+ }, [clock, emit, placementKey]);
270
297
 
271
298
  // FR-041: nothing survives unmount. No retained native view, no dangling
272
299
  // correlation, no state written into a disposed instance.
@@ -276,7 +303,7 @@ export function AdsBanner({ placementKey, onStateChange, style }: AdsBannerProps
276
303
  };
277
304
  }, []);
278
305
 
279
- if (!ads || !provider) {
306
+ if (!internals || !banner) {
280
307
  // FR-042, UX-003 — out of the tree entirely, blocking no touch.
281
308
  return null;
282
309
  }
@@ -304,34 +331,22 @@ export function AdsBanner({ placementKey, onStateChange, style }: AdsBannerProps
304
331
  }
305
332
  }
306
333
 
307
- const { BannerAd, BannerAdSize } = provider;
334
+ const BannerComponent = banner.Component;
308
335
  const adUnitId = resolution?.ok ? resolution.adUnitId : null;
309
- // The provider's polarity is the inverse of the consumer-facing option.
310
- const requestNonPersonalizedAdsOnly = resolution?.ok ? !resolution.personalizedAds : false;
311
336
 
312
337
  return (
313
338
  <View style={style} onLayout={onLayout} testID={`ads-banner-${placementKey}`}>
314
- {usable && adUnitId ? (
315
- <BannerAd
339
+ {usable && adUnitId && resolution?.ok ? (
340
+ <BannerComponent
316
341
  key={`${placementKey}-${cycle}`}
317
- unitId={adUnitId}
318
- size={BannerAdSize.ANCHORED_ADAPTIVE_BANNER}
319
- requestOptions={{ requestNonPersonalizedAdsOnly }}
320
- onAdLoaded={handleLoaded}
321
- onAdFailedToLoad={handleFailed}
322
- onAdImpression={handleImpression}
323
- onAdClicked={handleClicked}
342
+ adUnitId={adUnitId}
343
+ personalizedAds={resolution.personalizedAds}
344
+ onLoaded={handleLoaded}
345
+ onFailed={handleFailed}
346
+ onImpression={handleImpression}
347
+ onClicked={handleClicked}
324
348
  />
325
349
  ) : null}
326
350
  </View>
327
351
  );
328
352
  }
329
-
330
- /** Exposed for tests that assert the failure taxonomy without a renderer. */
331
- export function bannerUnavailableError(placementKey: string): AdsError {
332
- return new AdsError('unsupported_platform', {
333
- operation: 'load',
334
- placementKey,
335
- format: 'banner',
336
- });
337
- }
@@ -10,8 +10,8 @@
10
10
  * startup where the app can decide what to do about it.
11
11
  */
12
12
 
13
+ import { appLifecycle } from '@chipmobilesdk/rn-core';
13
14
  import React, { createContext, useContext, useEffect, type ReactNode } from 'react';
14
- import { AppState, type AppStateStatus } from 'react-native';
15
15
 
16
16
  import type { AdsInstance } from '../core/adsInstance';
17
17
 
@@ -23,7 +23,9 @@ export interface AdsProviderProps {
23
23
  * Whether the provider forwards foreground/background transitions to the
24
24
  * instance. On by default: FR-034's "never show while inactive" and
25
25
  * "re-evaluate a stale ad on return" both depend on the instance being told.
26
- * Turn it off only when the app already drives `setAppActive` itself.
26
+ * Turn it off only when the app already drives `setAppActive` itself. The
27
+ * transitions come from rn-core's shared `appLifecycle()`; `inactive` counts as
28
+ * not active.
27
29
  */
28
30
  readonly trackAppState?: boolean;
29
31
  readonly children: ReactNode;
@@ -34,12 +36,9 @@ export function AdsProvider({ instance, trackAppState = true, children }: AdsPro
34
36
  if (!trackAppState) {
35
37
  return;
36
38
  }
37
- const subscription = AppState.addEventListener('change', (status: AppStateStatus) => {
38
- instance.setAppActive(status === 'active');
39
+ return appLifecycle().subscribe(state => {
40
+ instance.setAppActive(state === 'active');
39
41
  });
40
- return () => {
41
- subscription.remove();
42
- };
43
42
  }, [instance, trackAppState]);
44
43
 
45
44
  return <AdsContext.Provider value={instance}>{children}</AdsContext.Provider>;
@@ -10,7 +10,8 @@
10
10
  * state; it never holds provider-owned memory (FR-027).
11
11
  */
12
12
 
13
- import { useEffect, useState } from 'react';
13
+ import { useExternalStore, useLatestCallback } from '@chipmobilesdk/rn-core/react';
14
+ import { useCallback, useEffect } from 'react';
14
15
 
15
16
  import type { InitStatus, PlacementState } from '../config/types';
16
17
  import type { AdEventListener } from '../diagnostics/events';
@@ -19,42 +20,33 @@ import { useAds } from './AdsProvider';
19
20
  /** Live state for one placement. `unavailable` outside a provider. */
20
21
  export function usePlacementState(placementKey: string): PlacementState {
21
22
  const ads = useAds();
22
- const [state, setState] = useState<PlacementState>(() =>
23
- ads ? ads.getPlacementState(placementKey) : 'unavailable',
23
+ const subscribe = useCallback(
24
+ (listener: () => void) =>
25
+ ads ? ads.subscribePlacement(placementKey, listener) : NO_SUBSCRIPTION,
26
+ [ads, placementKey],
24
27
  );
25
-
26
- useEffect(() => {
27
- if (!ads) {
28
- setState('unavailable');
29
- return;
30
- }
31
- // Re-read on subscribe: the state may have moved between the initial
32
- // render and this effect, and dropping that transition would leave the
33
- // UI showing a state the instance has already left.
34
- setState(ads.getPlacementState(placementKey));
35
- return ads.subscribePlacement(placementKey, setState);
36
- }, [ads, placementKey]);
37
-
38
- return state;
28
+ const getSnapshot = useCallback(
29
+ (): PlacementState => (ads ? ads.getPlacementState(placementKey) : 'unavailable'),
30
+ [ads, placementKey],
31
+ );
32
+ // useSyncExternalStore: tear-free, and no transition lost between render and
33
+ // subscription, which the useState copy had to re-read to avoid.
34
+ return useExternalStore(subscribe, getSnapshot);
39
35
  }
40
36
 
41
37
  /** Live initialization status. `unavailable` outside a provider. */
42
38
  export function useAdsStatus(): InitStatus {
43
39
  const ads = useAds();
44
- const [status, setStatus] = useState<InitStatus>(() => (ads ? ads.getStatus() : 'unavailable'));
45
-
46
- useEffect(() => {
47
- if (!ads) {
48
- setStatus('unavailable');
49
- return;
50
- }
51
- setStatus(ads.getStatus());
52
- return ads.subscribeStatus(setStatus);
53
- }, [ads]);
54
-
55
- return status;
40
+ const subscribe = useCallback(
41
+ (listener: () => void) => (ads ? ads.subscribeStatus(listener) : NO_SUBSCRIPTION),
42
+ [ads],
43
+ );
44
+ const getSnapshot = useCallback((): InitStatus => (ads ? ads.getStatus() : 'unavailable'), [ads]);
45
+ return useExternalStore(subscribe, getSnapshot);
56
46
  }
57
47
 
48
+ const NO_SUBSCRIPTION = (): void => undefined;
49
+
58
50
  /**
59
51
  * Forwards every event to a listener without causing a render.
60
52
  *
@@ -65,11 +57,14 @@ export function useAdsStatus(): InitStatus {
65
57
  */
66
58
  export function useAdsEvents(listener: AdEventListener): void {
67
59
  const ads = useAds();
60
+ // An inline listener is a new function every render; subscribing through the
61
+ // latest one keeps a single subscription per instance (PERF-13).
62
+ const latest = useLatestCallback(listener);
68
63
 
69
64
  useEffect(() => {
70
65
  if (!ads) {
71
66
  return;
72
67
  }
73
- return ads.subscribeEvents(listener);
74
- }, [ads, listener]);
68
+ return ads.subscribeEvents(latest);
69
+ }, [ads, latest]);
75
70
  }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * A clock the test drives (ADS-11).
3
+ *
4
+ * Every bounded wait in this package — retry backoff, the request throttle, a
5
+ * consent form that never answers — is measured against a clock. Injecting this
6
+ * one is what lets a suite assert the behaviour at the boundary instead of
7
+ * sleeping and hoping.
8
+ */
9
+ import type { Scheduler } from '../core/timers';
10
+
11
+ /**
12
+ * A clock a test can move by hand.
13
+ *
14
+ * Retry backoff, app-open expiry, the undecided-state warning, and the
15
+ * ad-originated-return windows are all time-dependent, and all four must be
16
+ * assertable exactly rather than approximately. Advancing a clock makes "two
17
+ * retries, 2s then 4s" a precise assertion; sleeping would make it a flaky one.
18
+ *
19
+ * Callbacks fire in due order, and a callback scheduled *during* an advance is
20
+ * honored within the same advance if its deadline has also passed — matching how
21
+ * real timers behave when work chains.
22
+ */
23
+ export interface TestClock {
24
+ now(): number;
25
+ advance(ms: number): void;
26
+ readonly scheduler: Scheduler;
27
+ pending(): number;
28
+ }
29
+
30
+ export function createTestClock(startMs = 1_000_000): TestClock {
31
+ let current = startMs;
32
+ let sequence = 0;
33
+ const timers = new Map<number, { at: number; callback: () => void }>();
34
+
35
+ const scheduler: Scheduler = {
36
+ setTimeout(callback, ms) {
37
+ sequence += 1;
38
+ timers.set(sequence, { at: current + ms, callback });
39
+ return sequence;
40
+ },
41
+ clearTimeout(handle) {
42
+ timers.delete(handle as number);
43
+ },
44
+ };
45
+
46
+ return {
47
+ now: () => current,
48
+ advance(ms) {
49
+ const target = current + ms;
50
+ // Loop rather than iterate once: a fired callback may schedule another
51
+ // timer that is also due before `target`, which is exactly what a retry
52
+ // chain does.
53
+ for (;;) {
54
+ let nextId: number | null = null;
55
+ let nextAt = Number.POSITIVE_INFINITY;
56
+ for (const [id, timer] of timers) {
57
+ if (timer.at <= target && timer.at < nextAt) {
58
+ nextAt = timer.at;
59
+ nextId = id;
60
+ }
61
+ }
62
+ if (nextId === null) {
63
+ break;
64
+ }
65
+ const timer = timers.get(nextId)!;
66
+ timers.delete(nextId);
67
+ current = timer.at;
68
+ timer.callback();
69
+ }
70
+ current = target;
71
+ },
72
+ scheduler,
73
+ pending: () => timers.size,
74
+ };
75
+ }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * What any `AdsEngine` must do (IOS-12, ADS-13).
3
+ *
4
+ * The same checks run against the fake engine and against the AdMob adapter over
5
+ * the provider mock (or the real provider, from a demo screen on a device), so a
6
+ * test that drives the fake proves something about the device path. The subject
7
+ * says how the provider delivers its callbacks; the checks only use the port.
8
+ */
9
+ import {
10
+ ensure,
11
+ runChecks,
12
+ type Check,
13
+ type ConformanceReport,
14
+ } from '@chipmobilesdk/rn-core/testing';
15
+
16
+ import type { Reward } from '../config/types';
17
+ import type { AdsEngine, EngineAdEvent, EngineAdHandle } from '../engine/port';
18
+
19
+ export interface AdsEngineSubject {
20
+ readonly engine: AdsEngine;
21
+ /** The provider finishes loading the ad behind `handle`. */
22
+ deliverLoad(handle: EngineAdHandle): void;
23
+ /** The provider grants a reward for the ad behind `handle`. */
24
+ deliverReward(handle: EngineAdHandle, reward: Reward): void;
25
+ /** The user closes the ad behind `handle`. */
26
+ deliverDismiss(handle: EngineAdHandle): void;
27
+ }
28
+
29
+ export interface AdsEngineHarness {
30
+ create(): AdsEngineSubject;
31
+ }
32
+
33
+ /** Lets callbacks a provider delivers on a later microtask arrive. */
34
+ async function settle(): Promise<void> {
35
+ for (let turn = 0; turn < 3; turn += 1) {
36
+ await Promise.resolve();
37
+ }
38
+ }
39
+
40
+ export const adsEngineChecks: readonly Check<AdsEngineHarness>[] = [
41
+ {
42
+ name: 'is available, and initializes with privacy applied',
43
+ async run(harness) {
44
+ const { engine } = harness.create();
45
+ ensure(engine.isAvailable(), 'reported unavailable');
46
+ await engine.initialize({ childDirected: true, maxAdContentRating: 'G' });
47
+ },
48
+ },
49
+ {
50
+ name: 'delivers loaded to a subscriber of an interstitial, and nothing after unsubscribing',
51
+ async run(harness) {
52
+ const subject = harness.create();
53
+ const handle = await subject.engine.createFullScreen('interstitial', 'conformance/1');
54
+ ensure(typeof handle.id === 'string' && handle.id !== '', 'handle has no id');
55
+ const seen: EngineAdEvent['type'][] = [];
56
+ const stop = subject.engine.subscribe(handle, event => seen.push(event.type));
57
+ subject.deliverLoad(handle);
58
+ await settle();
59
+ ensure(seen.join(',') === 'loaded', `subscriber saw ${seen.join(',') || 'nothing'}`);
60
+ stop();
61
+ stop();
62
+ subject.deliverDismiss(handle);
63
+ await settle();
64
+ ensure(seen.length === 1, `notified after unsubscribing: ${seen.join(',')}`);
65
+ },
66
+ },
67
+ {
68
+ name: 'delivers the reward and the dismissal of a rewarded ad',
69
+ async run(harness) {
70
+ const subject = harness.create();
71
+ const handle = await subject.engine.createFullScreen('rewarded', 'conformance/2', {
72
+ personalizedAds: false,
73
+ });
74
+ const seen: EngineAdEvent[] = [];
75
+ subject.engine.subscribe(handle, event => seen.push(event));
76
+ subject.deliverLoad(handle);
77
+ await settle();
78
+ subject.deliverReward(handle, { type: 'coins', amount: 5 });
79
+ subject.deliverDismiss(handle);
80
+ await settle();
81
+ const reward = seen.find(event => event.type === 'rewarded');
82
+ ensure(reward?.type === 'rewarded', `no reward among ${seen.map(e => e.type).join(',')}`);
83
+ ensure(
84
+ reward.reward.type === 'coins' && reward.reward.amount === 5,
85
+ `reward ${JSON.stringify(reward.reward)}`,
86
+ );
87
+ ensure(seen.at(-1)?.type === 'dismissed', `last event ${seen.at(-1)?.type}`);
88
+ },
89
+ },
90
+ {
91
+ name: 'gives every ad its own handle',
92
+ async run(harness) {
93
+ const { engine } = harness.create();
94
+ const first = await engine.createFullScreen('interstitial', 'conformance/1');
95
+ const second = await engine.createFullScreen('interstitial', 'conformance/1');
96
+ ensure(first.id !== second.id, 'two ads share a handle');
97
+ },
98
+ },
99
+ {
100
+ name: 'disposes safely twice',
101
+ async run(harness) {
102
+ const { engine } = harness.create();
103
+ const handle = await engine.createFullScreen('interstitial', 'conformance/1');
104
+ engine.dispose(handle);
105
+ engine.dispose(handle);
106
+ },
107
+ },
108
+ ];
109
+
110
+ export function runAdsEngineConformance(harness: AdsEngineHarness): Promise<ConformanceReport> {
111
+ return runChecks(() => harness, adsEngineChecks);
112
+ }