@chipmobilesdk/rn-ads 0.5.0 → 0.6.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.
@@ -1,463 +1,475 @@
1
- /**
2
- * Deterministic in-memory ads engine for tests (SC-012).
3
- *
4
- * This is a real implementation of the same `AdsEngine` port the AdMob adapter
5
- * implements, not a parallel mock — so a contract change breaks it at compile
6
- * time and it cannot drift. Nothing here touches a native module or a network.
7
- *
8
- * It exists to make the package's guarantees provable. The recorded call log is
9
- * what SC-002 asserts against ("zero production identifiers reached the
10
- * adapter") and what SC-003 asserts against ("zero calls before consent").
11
- * Callback emission is fully caller-driven, which is what lets a test run the
12
- * reward-before-dismiss and dismiss-before-reward orderings and demand the same
13
- * outcome from both (SC-007).
14
- *
15
- * Exported from `@chipmobilesdk/rn-ads/testing`, never from the main entrypoint,
16
- * so test doubles cannot reach a production bundle.
17
- */
18
-
19
- import type { FullScreenFormat, Reward, Unsubscribe } from '../config/types';
20
- import type { Scheduler } from '../core/timers';
21
- import type {
22
- AdsEngine,
23
- ConsentPort,
24
- EngineAdEvent,
25
- EngineAdHandle,
26
- EngineConsentInfo,
27
- EngineConsentOptions,
28
- EnginePrivacy,
29
- EngineRequestOptions,
30
- } from '../engine/port';
31
-
32
- /**
33
- * A clock a test can move by hand.
34
- *
35
- * Retry backoff, app-open expiry, the undecided-state warning, and the
36
- * ad-originated-return windows are all time-dependent, and all four must be
37
- * assertable exactly rather than approximately. Advancing a clock makes "two
38
- * retries, 2s then 4s" a precise assertion; sleeping would make it a flaky one.
39
- *
40
- * Callbacks fire in due order, and a callback scheduled *during* an advance is
41
- * honored within the same advance if its deadline has also passed — matching how
42
- * real timers behave when work chains.
43
- */
44
- export interface TestClock {
45
- now(): number;
46
- advance(ms: number): void;
47
- readonly scheduler: Scheduler;
48
- pending(): number;
49
- }
50
-
51
- export function createTestClock(startMs = 1_000_000): TestClock {
52
- let current = startMs;
53
- let sequence = 0;
54
- const timers = new Map<number, { at: number; callback: () => void }>();
55
-
56
- const scheduler: Scheduler = {
57
- setTimeout(callback, ms) {
58
- sequence += 1;
59
- timers.set(sequence, { at: current + ms, callback });
60
- return sequence;
61
- },
62
- clearTimeout(handle) {
63
- timers.delete(handle as number);
64
- },
65
- };
66
-
67
- return {
68
- now: () => current,
69
- advance(ms) {
70
- const target = current + ms;
71
- // Loop rather than iterate once: a fired callback may schedule another
72
- // timer that is also due before `target`, which is exactly what a retry
73
- // chain does.
74
- for (;;) {
75
- let nextId: number | null = null;
76
- let nextAt = Number.POSITIVE_INFINITY;
77
- for (const [id, timer] of timers) {
78
- if (timer.at <= target && timer.at < nextAt) {
79
- nextAt = timer.at;
80
- nextId = id;
81
- }
82
- }
83
- if (nextId === null) {
84
- break;
85
- }
86
- const timer = timers.get(nextId)!;
87
- timers.delete(nextId);
88
- current = timer.at;
89
- timer.callback();
90
- }
91
- current = target;
92
- },
93
- scheduler,
94
- pending: () => timers.size,
95
- };
96
- }
97
-
98
- export type FakeConsentCall =
99
- | { readonly kind: 'requestInfoUpdate'; readonly options: EngineConsentOptions }
100
- | { readonly kind: 'showFormIfRequired' }
101
- | { readonly kind: 'showPrivacyOptionsForm' }
102
- | { readonly kind: 'getInfo' }
103
- | { readonly kind: 'reset' };
104
-
105
- /**
106
- * A privacy options form the test is holding open (0.4.0).
107
- *
108
- * Settle it whenever the test chooses, which is what lets `busy`, a second
109
- * call, a consent update, and `dispose()` all be exercised *while* the form is
110
- * on screen — in any order, rather than only the one a resolved promise allows.
111
- */
112
- export interface HeldPrivacyOptionsForm {
113
- /**
114
- * The user closes the form. `info` is what the provider reports afterwards,
115
- * merged over the current information; omit it for a user who changed nothing.
116
- */
117
- close(info?: Partial<EngineConsentInfo>): void;
118
- /** The form fails while open. */
119
- fail(cause?: unknown): void;
120
- }
121
-
122
- export type FakeEngineCall =
123
- | { readonly kind: 'initialize'; readonly privacy: EnginePrivacy }
124
- | {
125
- readonly kind: 'createFullScreen';
126
- readonly format: FullScreenFormat;
127
- readonly adUnitId: string;
128
- readonly handleId: string;
129
- /** Per-request privacy as the package sent it (FR-017's personalization half). */
130
- readonly request?: EngineRequestOptions;
131
- }
132
- | { readonly kind: 'show'; readonly handleId: string }
133
- | { readonly kind: 'dispose'; readonly handleId: string };
134
-
135
- export interface FakeEngineOptions {
136
- /** Simulates an unsupported platform or a missing bridge (FR-025). */
137
- readonly available?: boolean;
138
- /** Rejects `initialize`, for the failed-initialization path (FR-024). */
139
- readonly failInitialize?: unknown;
140
- /**
141
- * Withholds the `loaded` callback so a load runs to its timeout. This is how
142
- * FR-032's timeout behavior is tested without waiting on a real network.
143
- */
144
- readonly neverCallBack?: boolean;
145
- /** Rejects `show`, for the display-failure path (FR-044). */
146
- readonly failShow?: unknown;
147
- /** Initial consent information. Defaults to the not-required, allowed case. */
148
- readonly consentInfo?: Partial<EngineConsentInfo>;
149
- /** Drives time for retry, expiry, and the undecided warning. */
150
- readonly clock?: TestClock;
151
- }
152
-
153
- const DEFAULT_CONSENT: EngineConsentInfo = {
154
- status: 'not_required',
155
- canRequestAds: true,
156
- isFormAvailable: false,
157
- privacyOptions: 'not_required',
158
- };
159
-
160
- export interface FakeAdsEngine extends AdsEngine {
161
- /** Every call the package made, in order. */
162
- readonly calls: readonly FakeEngineCall[];
163
- /** Ad unit ids passed to `createFullScreen`, in order — the SC-002 assertion. */
164
- adUnitIds(): readonly string[];
165
- /** Handle ids currently alive; non-empty after dispose means a leak. */
166
- liveHandles(): readonly string[];
167
- /** Emits a provider callback for one handle. The caller controls ordering. */
168
- emit(handleId: string, event: EngineAdEvent): void;
169
- /** Convenience for the common `loaded` case. */
170
- completeLoad(handleId: string): void;
171
- /** Emits a reward with a normalized payload. */
172
- emitReward(handleId: string, reward?: Reward): void;
173
- /** How many listeners are attached; must reach zero after cleanup (SC-008). */
174
- listenerCount(): number;
175
- /** Drops every recorded call, for tests that assert on a later window only. */
176
- reset(): void;
177
-
178
- /** The clock driving this engine's timers. Wire it into `createAds`. */
179
- readonly clock: TestClock;
180
- /** Moves the clock and fires anything due. */
181
- advance(ms: number): void;
182
-
183
- /** Every consent call the package made, in order (FR-113's assertion surface). */
184
- readonly consentCalls: readonly FakeConsentCall[];
185
- /** Sets what the consent platform will report from now on. */
186
- setConsentInfo(info: Partial<EngineConsentInfo>): void;
187
- /** Makes `requestInfoUpdate` reject — the flow must yield `error`, never granted. */
188
- failConsentRequest(cause?: unknown): void;
189
- /** Makes `showFormIfRequired` reject — dismissal is not consent (FR-106). */
190
- failConsentForm(cause?: unknown): void;
191
-
192
- /**
193
- * What the privacy options form reports when the user closes it, merged over
194
- * the current information. Unset, the form closes with nothing changed — the
195
- * same as a user who opens it and backs out.
196
- */
197
- setPrivacyOptionsFormResult(info: Partial<EngineConsentInfo>): void;
198
- /** Makes `showPrivacyOptionsForm` reject — a failure is never consent (FR-106). */
199
- failPrivacyOptionsForm(cause?: unknown): void;
200
- /** Holds the next privacy options form open until the test settles it. */
201
- holdPrivacyOptionsForm(): HeldPrivacyOptionsForm;
202
- /** Makes `getInfo` reject, for the case where even the re-read after a failed form fails. */
203
- failConsentInfoRead(cause?: unknown): void;
204
- }
205
-
206
- export function createFakeAdsEngine(options: FakeEngineOptions = {}): FakeAdsEngine {
207
- const calls: FakeEngineCall[] = [];
208
- const consentCalls: FakeConsentCall[] = [];
209
- const clock = options.clock ?? createTestClock();
210
- let consentInfo: EngineConsentInfo = { ...DEFAULT_CONSENT, ...options.consentInfo };
211
- let consentRequestFailure: unknown;
212
- let consentFormFailure: unknown;
213
- let privacyFormFailure: unknown;
214
- let privacyFormResult: Partial<EngineConsentInfo> = {};
215
- let consentReadFailure: unknown;
216
- /** Set by `holdPrivacyOptionsForm`; consumed by the next form. */
217
- let heldForm: Promise<Partial<EngineConsentInfo>> | null = null;
218
-
219
- const consent: ConsentPort = {
220
- async requestInfoUpdate(consentOptions) {
221
- consentCalls.push({ kind: 'requestInfoUpdate', options: consentOptions });
222
- if (consentRequestFailure !== undefined) {
223
- throw consentRequestFailure;
224
- }
225
- return consentInfo;
226
- },
227
- async showFormIfRequired() {
228
- consentCalls.push({ kind: 'showFormIfRequired' });
229
- if (consentFormFailure !== undefined) {
230
- throw consentFormFailure;
231
- }
232
- // A presented form that the user completes moves the status on. The
233
- // fake only does this when a form was actually required, mirroring the
234
- // provider: showing nothing cannot change an answer.
235
- // A region that required a form is one that requires a way back into
236
- // it, which is what the provider reports once consent is obtained.
237
- if (consentInfo.status === 'required' && consentInfo.isFormAvailable) {
238
- consentInfo = {
239
- status: 'obtained',
240
- canRequestAds: true,
241
- isFormAvailable: false,
242
- privacyOptions: 'required',
243
- };
244
- }
245
- return consentInfo;
246
- },
247
- async showPrivacyOptionsForm() {
248
- consentCalls.push({ kind: 'showPrivacyOptionsForm' });
249
- const held = heldForm;
250
- heldForm = null;
251
- if (held) {
252
- // Rejects if the test calls `fail`, which is exactly the provider's
253
- // failure path; resolves with what the user left behind otherwise.
254
- consentInfo = { ...consentInfo, ...(await held) };
255
- return consentInfo;
256
- }
257
- if (privacyFormFailure !== undefined) {
258
- throw privacyFormFailure;
259
- }
260
- consentInfo = { ...consentInfo, ...privacyFormResult };
261
- return consentInfo;
262
- },
263
- async getInfo() {
264
- consentCalls.push({ kind: 'getInfo' });
265
- if (consentReadFailure !== undefined) {
266
- throw consentReadFailure;
267
- }
268
- return consentInfo;
269
- },
270
- reset() {
271
- consentCalls.push({ kind: 'reset' });
272
- consentInfo = {
273
- status: 'unknown',
274
- canRequestAds: false,
275
- isFormAvailable: true,
276
- privacyOptions: 'unknown',
277
- };
278
- },
279
- };
280
- const listeners = new Map<string, Set<(event: EngineAdEvent) => void>>();
281
- /** Callbacks queued before anyone subscribed; see `subscribe`. */
282
- const pending = new Map<string, EngineAdEvent>();
283
- const live = new Set<string>();
284
- let counter = 0;
285
-
286
- function record(call: FakeEngineCall): void {
287
- calls.push(call);
288
- }
289
-
290
- return {
291
- calls,
292
- consentCalls,
293
- consent,
294
- clock,
295
-
296
- advance(ms) {
297
- clock.advance(ms);
298
- },
299
-
300
- setConsentInfo(info) {
301
- consentInfo = { ...consentInfo, ...info };
302
- },
303
-
304
- failConsentRequest(cause = new Error('consent request failed')) {
305
- consentRequestFailure = cause;
306
- },
307
-
308
- failConsentForm(cause = new Error('consent form failed')) {
309
- consentFormFailure = cause;
310
- },
311
-
312
- setPrivacyOptionsFormResult(info) {
313
- privacyFormResult = info;
314
- },
315
-
316
- failPrivacyOptionsForm(cause = new Error('privacy options form failed')) {
317
- privacyFormFailure = cause;
318
- },
319
-
320
- holdPrivacyOptionsForm() {
321
- let settle!: {
322
- resolve: (info: Partial<EngineConsentInfo>) => void;
323
- reject: (cause: unknown) => void;
324
- };
325
- const promise = new Promise<Partial<EngineConsentInfo>>((resolve, reject) => {
326
- settle = { resolve, reject };
327
- });
328
- heldForm = promise;
329
- return {
330
- close(info = {}) {
331
- settle.resolve(info);
332
- },
333
- fail(cause = new Error('privacy options form failed')) {
334
- settle.reject(cause);
335
- },
336
- };
337
- },
338
-
339
- failConsentInfoRead(cause = new Error('consent information read failed')) {
340
- consentReadFailure = cause;
341
- },
342
-
343
- isAvailable() {
344
- return options.available !== false;
345
- },
346
-
347
- async initialize(privacy) {
348
- record({ kind: 'initialize', privacy });
349
- if (options.failInitialize !== undefined) {
350
- throw options.failInitialize;
351
- }
352
- },
353
-
354
- async createFullScreen(format, adUnitId, request) {
355
- counter += 1;
356
- const id = `fake-${counter}`;
357
- record({ kind: 'createFullScreen', format, adUnitId, handleId: id, request });
358
- live.add(id);
359
- // A real provider delivers `loaded` to a listener attached after the
360
- // ad object exists, so the callback is queued here and flushed when
361
- // the caller subscribes. Emitting eagerly would race the caller's own
362
- // subscribe and the event would be dropped — which is a property of
363
- // this fake, not of the package, and would make the load look hung.
364
- if (!options.neverCallBack) {
365
- pending.set(id, { type: 'loaded' });
366
- }
367
- return { id } satisfies EngineAdHandle;
368
- },
369
-
370
- async show(handle) {
371
- record({ kind: 'show', handleId: handle.id });
372
- if (options.failShow !== undefined) {
373
- throw options.failShow;
374
- }
375
- if (!options.neverCallBack) {
376
- // `show` is called with a listener already attached, so this one
377
- // can go out directly on the next microtask.
378
- queueMicrotask(() => {
379
- for (const listener of Array.from(listeners.get(handle.id) ?? [])) {
380
- listener({ type: 'opened' });
381
- }
382
- });
383
- }
384
- },
385
-
386
- subscribe(handle, listener): Unsubscribe {
387
- let set = listeners.get(handle.id);
388
- if (!set) {
389
- set = new Set();
390
- listeners.set(handle.id, set);
391
- }
392
- set.add(listener);
393
- // Flush whatever the provider would have delivered between creating
394
- // the ad and this subscription.
395
- const queued = pending.get(handle.id);
396
- if (queued) {
397
- pending.delete(handle.id);
398
- queueMicrotask(() => {
399
- for (const target of Array.from(listeners.get(handle.id) ?? [])) {
400
- target(queued);
401
- }
402
- });
403
- }
404
- let active = true;
405
- return () => {
406
- if (!active) {
407
- return;
408
- }
409
- active = false;
410
- set?.delete(listener);
411
- if (set && set.size === 0) {
412
- listeners.delete(handle.id);
413
- }
414
- };
415
- },
416
-
417
- dispose(handle) {
418
- record({ kind: 'dispose', handleId: handle.id });
419
- live.delete(handle.id);
420
- listeners.delete(handle.id);
421
- pending.delete(handle.id);
422
- },
423
-
424
- adUnitIds() {
425
- return calls
426
- .filter(
427
- (call): call is Extract<FakeEngineCall, { kind: 'createFullScreen' }> =>
428
- call.kind === 'createFullScreen',
429
- )
430
- .map(call => call.adUnitId);
431
- },
432
-
433
- liveHandles() {
434
- return Array.from(live);
435
- },
436
-
437
- emit(handleId, event) {
438
- for (const listener of Array.from(listeners.get(handleId) ?? [])) {
439
- listener(event);
440
- }
441
- },
442
-
443
- completeLoad(handleId) {
444
- this.emit(handleId, { type: 'loaded' });
445
- },
446
-
447
- emitReward(handleId, reward = { type: 'coins', amount: 10 }) {
448
- this.emit(handleId, { type: 'rewarded', reward });
449
- },
450
-
451
- listenerCount() {
452
- let total = 0;
453
- for (const set of listeners.values()) {
454
- total += set.size;
455
- }
456
- return total;
457
- },
458
-
459
- reset() {
460
- calls.length = 0;
461
- },
462
- };
463
- }
1
+ /**
2
+ * Deterministic in-memory ads engine for tests (SC-012).
3
+ *
4
+ * This is a real implementation of the same `AdsEngine` port the AdMob adapter
5
+ * implements, not a parallel mock — so a contract change breaks it at compile
6
+ * time and it cannot drift. Nothing here touches a native module or a network.
7
+ *
8
+ * It exists to make the package's guarantees provable. The recorded call log is
9
+ * what SC-002 asserts against ("zero production identifiers reached the
10
+ * adapter") and what SC-003 asserts against ("zero calls before consent").
11
+ * Callback emission is fully caller-driven, which is what lets a test run the
12
+ * reward-before-dismiss and dismiss-before-reward orderings and demand the same
13
+ * outcome from both (SC-007).
14
+ *
15
+ * Exported from `@chipmobilesdk/rn-ads/testing`, never from the main entrypoint,
16
+ * so test doubles cannot reach a production bundle.
17
+ */
18
+
19
+ import type { FullScreenFormat, Reward, Unsubscribe } from '../config/types';
20
+ import type { Scheduler } from '../core/timers';
21
+ import type {
22
+ AdsEngine,
23
+ ConsentPort,
24
+ EngineAdEvent,
25
+ EngineAdHandle,
26
+ EngineConsentInfo,
27
+ EngineConsentOptions,
28
+ EnginePrivacy,
29
+ EngineRequestOptions,
30
+ } from '../engine/port';
31
+
32
+ /**
33
+ * A clock a test can move by hand.
34
+ *
35
+ * Retry backoff, app-open expiry, the undecided-state warning, and the
36
+ * ad-originated-return windows are all time-dependent, and all four must be
37
+ * assertable exactly rather than approximately. Advancing a clock makes "two
38
+ * retries, 2s then 4s" a precise assertion; sleeping would make it a flaky one.
39
+ *
40
+ * Callbacks fire in due order, and a callback scheduled *during* an advance is
41
+ * honored within the same advance if its deadline has also passed — matching how
42
+ * real timers behave when work chains.
43
+ */
44
+ export interface TestClock {
45
+ now(): number;
46
+ advance(ms: number): void;
47
+ readonly scheduler: Scheduler;
48
+ pending(): number;
49
+ }
50
+
51
+ export function createTestClock(startMs = 1_000_000): TestClock {
52
+ let current = startMs;
53
+ let sequence = 0;
54
+ const timers = new Map<number, { at: number; callback: () => void }>();
55
+
56
+ const scheduler: Scheduler = {
57
+ setTimeout(callback, ms) {
58
+ sequence += 1;
59
+ timers.set(sequence, { at: current + ms, callback });
60
+ return sequence;
61
+ },
62
+ clearTimeout(handle) {
63
+ timers.delete(handle as number);
64
+ },
65
+ };
66
+
67
+ return {
68
+ now: () => current,
69
+ advance(ms) {
70
+ const target = current + ms;
71
+ // Loop rather than iterate once: a fired callback may schedule another
72
+ // timer that is also due before `target`, which is exactly what a retry
73
+ // chain does.
74
+ for (;;) {
75
+ let nextId: number | null = null;
76
+ let nextAt = Number.POSITIVE_INFINITY;
77
+ for (const [id, timer] of timers) {
78
+ if (timer.at <= target && timer.at < nextAt) {
79
+ nextAt = timer.at;
80
+ nextId = id;
81
+ }
82
+ }
83
+ if (nextId === null) {
84
+ break;
85
+ }
86
+ const timer = timers.get(nextId)!;
87
+ timers.delete(nextId);
88
+ current = timer.at;
89
+ timer.callback();
90
+ }
91
+ current = target;
92
+ },
93
+ scheduler,
94
+ pending: () => timers.size,
95
+ };
96
+ }
97
+
98
+ export type FakeConsentCall =
99
+ | { readonly kind: 'requestInfoUpdate'; readonly options: EngineConsentOptions }
100
+ | { readonly kind: 'showFormIfRequired' }
101
+ | { readonly kind: 'showPrivacyOptionsForm' }
102
+ | { readonly kind: 'getInfo' }
103
+ | { readonly kind: 'reset' };
104
+
105
+ /**
106
+ * A privacy options form the test is holding open (0.4.0).
107
+ *
108
+ * Settle it whenever the test chooses, which is what lets `busy`, a second
109
+ * call, a consent update, and `dispose()` all be exercised *while* the form is
110
+ * on screen — in any order, rather than only the one a resolved promise allows.
111
+ */
112
+ export interface HeldPrivacyOptionsForm {
113
+ /**
114
+ * The user closes the form. `info` is what the provider reports afterwards,
115
+ * merged over the current information; omit it for a user who changed nothing.
116
+ */
117
+ close(info?: Partial<EngineConsentInfo>): void;
118
+ /** The form fails while open. */
119
+ fail(cause?: unknown): void;
120
+ }
121
+
122
+ export type FakeEngineCall =
123
+ | { readonly kind: 'initialize'; readonly privacy: EnginePrivacy }
124
+ | {
125
+ readonly kind: 'createFullScreen';
126
+ readonly format: FullScreenFormat;
127
+ readonly adUnitId: string;
128
+ readonly handleId: string;
129
+ /** Per-request privacy as the package sent it (FR-017's personalization half). */
130
+ readonly request?: EngineRequestOptions;
131
+ }
132
+ | { readonly kind: 'show'; readonly handleId: string }
133
+ | { readonly kind: 'dispose'; readonly handleId: string };
134
+
135
+ export interface FakeEngineOptions {
136
+ /** Simulates an unsupported platform or a missing bridge (FR-025). */
137
+ readonly available?: boolean;
138
+ /** Rejects `initialize`, for the failed-initialization path (FR-024). */
139
+ readonly failInitialize?: unknown;
140
+ /**
141
+ * Withholds the `loaded` callback so a load runs to its timeout. This is how
142
+ * FR-032's timeout behavior is tested without waiting on a real network.
143
+ */
144
+ readonly neverCallBack?: boolean;
145
+ /** Rejects `show`, for the display-failure path (FR-044). */
146
+ readonly failShow?: unknown;
147
+ /** Initial consent information. Defaults to the not-required, allowed case. */
148
+ readonly consentInfo?: Partial<EngineConsentInfo>;
149
+ /** Drives time for retry, expiry, and the undecided warning. */
150
+ readonly clock?: TestClock;
151
+ }
152
+
153
+ const DEFAULT_CONSENT: EngineConsentInfo = {
154
+ status: 'not_required',
155
+ canRequestAds: true,
156
+ isFormAvailable: false,
157
+ privacyOptions: 'not_required',
158
+ };
159
+
160
+ export interface FakeAdsEngine extends AdsEngine {
161
+ /** Every call the package made, in order. */
162
+ readonly calls: readonly FakeEngineCall[];
163
+ /** Ad unit ids passed to `createFullScreen`, in order — the SC-002 assertion. */
164
+ adUnitIds(): readonly string[];
165
+ /** Handle ids currently alive; non-empty after dispose means a leak. */
166
+ liveHandles(): readonly string[];
167
+ /** Emits a provider callback for one handle. The caller controls ordering. */
168
+ emit(handleId: string, event: EngineAdEvent): void;
169
+ /** Convenience for the common `loaded` case. */
170
+ completeLoad(handleId: string): void;
171
+ /** Emits a reward with a normalized payload. */
172
+ emitReward(handleId: string, reward?: Reward): void;
173
+ /** How many listeners are attached; must reach zero after cleanup (SC-008). */
174
+ listenerCount(): number;
175
+ /** Drops every recorded call, for tests that assert on a later window only. */
176
+ reset(): void;
177
+
178
+ /** The clock driving this engine's timers. Wire it into `createAds`. */
179
+ readonly clock: TestClock;
180
+ /** Moves the clock and fires anything due. */
181
+ advance(ms: number): void;
182
+
183
+ /** Every consent call the package made, in order (FR-113's assertion surface). */
184
+ readonly consentCalls: readonly FakeConsentCall[];
185
+ /** Sets what the consent platform will report from now on. */
186
+ setConsentInfo(info: Partial<EngineConsentInfo>): void;
187
+ /** Makes `requestInfoUpdate` reject — the flow must yield `error`, never granted. */
188
+ failConsentRequest(cause?: unknown): void;
189
+ /** Makes `showFormIfRequired` reject — dismissal is not consent (FR-106). */
190
+ failConsentForm(cause?: unknown): void;
191
+
192
+ /**
193
+ * What the privacy options form reports when the user closes it, merged over
194
+ * the current information. Unset, the form closes with nothing changed — the
195
+ * same as a user who opens it and backs out.
196
+ */
197
+ setPrivacyOptionsFormResult(info: Partial<EngineConsentInfo>): void;
198
+ /** Makes `showPrivacyOptionsForm` reject — a failure is never consent (FR-106). */
199
+ failPrivacyOptionsForm(cause?: unknown): void;
200
+ /** Holds the next privacy options form open until the test settles it. */
201
+ holdPrivacyOptionsForm(): HeldPrivacyOptionsForm;
202
+ /** Makes `getInfo` reject, for the case where even the re-read after a failed form fails. */
203
+ failConsentInfoRead(cause?: unknown): void;
204
+ }
205
+
206
+ /**
207
+ * Runs `task` on the next microtask, which is when a provider's callbacks would
208
+ * arrive. Written out because React Native's type declarations omit
209
+ * `queueMicrotask`, although Hermes has it.
210
+ */
211
+ function nextMicrotask(task: () => void): void {
212
+ Promise.resolve().then(task);
213
+ }
214
+
215
+ export function createFakeAdsEngine(options: FakeEngineOptions = {}): FakeAdsEngine {
216
+ const calls: FakeEngineCall[] = [];
217
+ const consentCalls: FakeConsentCall[] = [];
218
+ const clock = options.clock ?? createTestClock();
219
+ let consentInfo: EngineConsentInfo = { ...DEFAULT_CONSENT, ...options.consentInfo };
220
+ let consentRequestFailure: unknown;
221
+ let consentFormFailure: unknown;
222
+ let privacyFormFailure: unknown;
223
+ let privacyFormResult: Partial<EngineConsentInfo> = {};
224
+ let consentReadFailure: unknown;
225
+ /** Set by `holdPrivacyOptionsForm`; consumed by the next form. */
226
+ let heldForm: Promise<Partial<EngineConsentInfo>> | null = null;
227
+
228
+ const consent: ConsentPort = {
229
+ async requestInfoUpdate(consentOptions) {
230
+ consentCalls.push({ kind: 'requestInfoUpdate', options: consentOptions });
231
+ if (consentRequestFailure !== undefined) {
232
+ throw consentRequestFailure;
233
+ }
234
+ return consentInfo;
235
+ },
236
+ async showFormIfRequired() {
237
+ consentCalls.push({ kind: 'showFormIfRequired' });
238
+ if (consentFormFailure !== undefined) {
239
+ throw consentFormFailure;
240
+ }
241
+ // A presented form that the user completes moves the status on. The
242
+ // fake only does this when a form was actually required, mirroring the
243
+ // provider: showing nothing cannot change an answer.
244
+ // A region that required a form is one that requires a way back into
245
+ // it, which is what the provider reports once consent is obtained.
246
+ if (consentInfo.status === 'required' && consentInfo.isFormAvailable) {
247
+ consentInfo = {
248
+ status: 'obtained',
249
+ canRequestAds: true,
250
+ isFormAvailable: false,
251
+ privacyOptions: 'required',
252
+ };
253
+ }
254
+ return consentInfo;
255
+ },
256
+ async showPrivacyOptionsForm() {
257
+ consentCalls.push({ kind: 'showPrivacyOptionsForm' });
258
+ const held = heldForm;
259
+ heldForm = null;
260
+ if (held) {
261
+ // Rejects if the test calls `fail`, which is exactly the provider's
262
+ // failure path; resolves with what the user left behind otherwise.
263
+ consentInfo = { ...consentInfo, ...(await held) };
264
+ return consentInfo;
265
+ }
266
+ if (privacyFormFailure !== undefined) {
267
+ throw privacyFormFailure;
268
+ }
269
+ consentInfo = { ...consentInfo, ...privacyFormResult };
270
+ return consentInfo;
271
+ },
272
+ async getInfo() {
273
+ consentCalls.push({ kind: 'getInfo' });
274
+ if (consentReadFailure !== undefined) {
275
+ throw consentReadFailure;
276
+ }
277
+ return consentInfo;
278
+ },
279
+ reset() {
280
+ consentCalls.push({ kind: 'reset' });
281
+ consentInfo = {
282
+ status: 'unknown',
283
+ canRequestAds: false,
284
+ isFormAvailable: true,
285
+ privacyOptions: 'unknown',
286
+ };
287
+ },
288
+ };
289
+ const listeners = new Map<string, Set<(event: EngineAdEvent) => void>>();
290
+ /** Callbacks queued before anyone subscribed; see `subscribe`. */
291
+ const pending = new Map<string, EngineAdEvent>();
292
+ const live = new Set<string>();
293
+ let counter = 0;
294
+
295
+ function record(call: FakeEngineCall): void {
296
+ calls.push(call);
297
+ }
298
+
299
+ return {
300
+ calls,
301
+ consentCalls,
302
+ consent,
303
+ clock,
304
+
305
+ advance(ms) {
306
+ clock.advance(ms);
307
+ },
308
+
309
+ setConsentInfo(info) {
310
+ consentInfo = { ...consentInfo, ...info };
311
+ },
312
+
313
+ failConsentRequest(cause = new Error('consent request failed')) {
314
+ consentRequestFailure = cause;
315
+ },
316
+
317
+ failConsentForm(cause = new Error('consent form failed')) {
318
+ consentFormFailure = cause;
319
+ },
320
+
321
+ setPrivacyOptionsFormResult(info) {
322
+ privacyFormResult = info;
323
+ },
324
+
325
+ failPrivacyOptionsForm(cause = new Error('privacy options form failed')) {
326
+ privacyFormFailure = cause;
327
+ },
328
+
329
+ holdPrivacyOptionsForm() {
330
+ let settle!: {
331
+ resolve: (info: Partial<EngineConsentInfo>) => void;
332
+ reject: (cause: unknown) => void;
333
+ };
334
+ const promise = new Promise<Partial<EngineConsentInfo>>((resolve, reject) => {
335
+ settle = { resolve, reject };
336
+ });
337
+ heldForm = promise;
338
+ return {
339
+ close(info = {}) {
340
+ settle.resolve(info);
341
+ },
342
+ fail(cause = new Error('privacy options form failed')) {
343
+ settle.reject(cause);
344
+ },
345
+ };
346
+ },
347
+
348
+ failConsentInfoRead(cause = new Error('consent information read failed')) {
349
+ consentReadFailure = cause;
350
+ },
351
+
352
+ isAvailable() {
353
+ return options.available !== false;
354
+ },
355
+
356
+ async initialize(privacy) {
357
+ record({ kind: 'initialize', privacy });
358
+ if (options.failInitialize !== undefined) {
359
+ throw options.failInitialize;
360
+ }
361
+ },
362
+
363
+ async createFullScreen(format, adUnitId, request) {
364
+ counter += 1;
365
+ const id = `fake-${counter}`;
366
+ record({ kind: 'createFullScreen', format, adUnitId, handleId: id, request });
367
+ live.add(id);
368
+ // A real provider delivers `loaded` to a listener attached after the
369
+ // ad object exists, so the callback is queued here and flushed when
370
+ // the caller subscribes. Emitting eagerly would race the caller's own
371
+ // subscribe and the event would be dropped — which is a property of
372
+ // this fake, not of the package, and would make the load look hung.
373
+ if (!options.neverCallBack) {
374
+ pending.set(id, { type: 'loaded' });
375
+ }
376
+ return { id } satisfies EngineAdHandle;
377
+ },
378
+
379
+ async show(handle) {
380
+ record({ kind: 'show', handleId: handle.id });
381
+ if (options.failShow !== undefined) {
382
+ throw options.failShow;
383
+ }
384
+ if (!options.neverCallBack) {
385
+ // `show` is called with a listener already attached, so this one
386
+ // can go out directly on the next microtask.
387
+ nextMicrotask(() => {
388
+ for (const listener of Array.from(listeners.get(handle.id) ?? [])) {
389
+ listener({ type: 'opened' });
390
+ }
391
+ });
392
+ }
393
+ },
394
+
395
+ subscribe(handle, listener): Unsubscribe {
396
+ let set = listeners.get(handle.id);
397
+ if (!set) {
398
+ set = new Set();
399
+ listeners.set(handle.id, set);
400
+ }
401
+ set.add(listener);
402
+ // Flush whatever the provider would have delivered between creating
403
+ // the ad and this subscription.
404
+ const queued = pending.get(handle.id);
405
+ if (queued) {
406
+ pending.delete(handle.id);
407
+ nextMicrotask(() => {
408
+ for (const target of Array.from(listeners.get(handle.id) ?? [])) {
409
+ target(queued);
410
+ }
411
+ });
412
+ }
413
+ let active = true;
414
+ return () => {
415
+ if (!active) {
416
+ return;
417
+ }
418
+ active = false;
419
+ set?.delete(listener);
420
+ if (set && set.size === 0) {
421
+ listeners.delete(handle.id);
422
+ }
423
+ };
424
+ },
425
+
426
+ dispose(handle) {
427
+ record({ kind: 'dispose', handleId: handle.id });
428
+ live.delete(handle.id);
429
+ listeners.delete(handle.id);
430
+ pending.delete(handle.id);
431
+ },
432
+
433
+ adUnitIds() {
434
+ return calls
435
+ .filter(
436
+ (call): call is Extract<FakeEngineCall, { kind: 'createFullScreen' }> =>
437
+ call.kind === 'createFullScreen',
438
+ )
439
+ .map(call => call.adUnitId);
440
+ },
441
+
442
+ liveHandles() {
443
+ return Array.from(live);
444
+ },
445
+
446
+ emit(handleId, event) {
447
+ for (const listener of Array.from(listeners.get(handleId) ?? [])) {
448
+ listener(event);
449
+ }
450
+ },
451
+
452
+ completeLoad(handleId) {
453
+ this.emit(handleId, { type: 'loaded' });
454
+ },
455
+
456
+ emitReward(handleId, reward = { type: 'coins', amount: 10 }) {
457
+ this.emit(handleId, { type: 'rewarded', reward });
458
+ },
459
+
460
+ listenerCount() {
461
+ let total = 0;
462
+ for (const set of listeners.values()) {
463
+ total += set.size;
464
+ }
465
+ return total;
466
+ },
467
+
468
+ reset() {
469
+ calls.length = 0;
470
+ },
471
+ };
472
+ }
473
+
474
+ export { adsEngineChecks, runAdsEngineConformance } from './conformance';
475
+ export type { AdsEngineHarness, AdsEngineSubject } from './conformance';