@jskit-ai/rewarded-web 0.1.120

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.
@@ -0,0 +1,600 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+
4
+ import { launchGoogleRewardedAd } from "../patterns/google-rewarded/example/googlePublisherTag.js";
5
+ import { createRewardedRuntime } from "../src/client/runtime/rewardedRuntime.js";
6
+
7
+ function waitForAsyncTurn() {
8
+ return new Promise((resolve) => {
9
+ setImmediate(resolve);
10
+ });
11
+ }
12
+
13
+ function createJsonResponse(data, status = 200) {
14
+ return {
15
+ ok: status >= 200 && status < 300,
16
+ status,
17
+ headers: {
18
+ get(name) {
19
+ return String(name || "").toLowerCase() === "content-type"
20
+ ? "application/json"
21
+ : "";
22
+ }
23
+ },
24
+ async json() {
25
+ return data;
26
+ }
27
+ };
28
+ }
29
+
30
+ function createFetchStub({
31
+ currentResponse,
32
+ startResponse,
33
+ grantResponse,
34
+ closeResponse
35
+ } = {}) {
36
+ const calls = [];
37
+
38
+ async function fetchStub(url, options = {}) {
39
+ const method = String(options.method || "GET").toUpperCase();
40
+ const serializedBody = typeof options.body === "string" ? options.body : "";
41
+ const parsedBody = serializedBody ? JSON.parse(serializedBody) : null;
42
+
43
+ calls.push({
44
+ url: String(url),
45
+ method,
46
+ body: parsedBody
47
+ });
48
+
49
+ if (String(url) === "/api/session") {
50
+ return createJsonResponse({
51
+ csrfToken: "csrf-token"
52
+ });
53
+ }
54
+
55
+ if (String(url).includes("/rewarded/current")) {
56
+ return createJsonResponse(currentResponse);
57
+ }
58
+ if (String(url).includes("/rewarded/start")) {
59
+ return createJsonResponse(startResponse);
60
+ }
61
+ if (String(url).includes("/rewarded/grant")) {
62
+ return createJsonResponse(grantResponse);
63
+ }
64
+ if (String(url).includes("/rewarded/close")) {
65
+ return createJsonResponse(closeResponse);
66
+ }
67
+
68
+ throw new Error(`Unexpected fetch call: ${method} ${String(url)}`);
69
+ }
70
+
71
+ return {
72
+ calls,
73
+ fetchStub
74
+ };
75
+ }
76
+
77
+ function installBrowserGlobals({ mode = "grant" } = {}) {
78
+ const originalWindow = globalThis.window;
79
+ const originalDocument = globalThis.document;
80
+ const originalFetch = globalThis.fetch;
81
+
82
+ const listenerMap = new Map();
83
+ const slot = {
84
+ addService() {
85
+ return this;
86
+ }
87
+ };
88
+ const pubads = {
89
+ addEventListener(name, handler) {
90
+ if (!listenerMap.has(name)) {
91
+ listenerMap.set(name, new Set());
92
+ }
93
+ listenerMap.get(name).add(handler);
94
+ },
95
+ removeEventListener(name, handler) {
96
+ listenerMap.get(name)?.delete(handler);
97
+ }
98
+ };
99
+
100
+ function emit(name, event) {
101
+ for (const handler of listenerMap.get(name) || []) {
102
+ handler(event);
103
+ }
104
+ }
105
+
106
+ const googletag = {
107
+ apiReady: true,
108
+ cmd: {
109
+ push(handler) {
110
+ handler();
111
+ }
112
+ },
113
+ enums: {
114
+ OutOfPageFormat: {
115
+ REWARDED: "REWARDED"
116
+ }
117
+ },
118
+ pubads() {
119
+ return pubads;
120
+ },
121
+ defineOutOfPageSlot() {
122
+ return mode === "unavailable" ? null : slot;
123
+ },
124
+ enableServices() {},
125
+ display() {
126
+ emit("rewardedSlotReady", {
127
+ slot,
128
+ makeRewardedVisible() {}
129
+ });
130
+
131
+ if (mode === "grant") {
132
+ emit("rewardedSlotGranted", {
133
+ slot
134
+ });
135
+ }
136
+
137
+ emit("rewardedSlotClosed", {
138
+ slot
139
+ });
140
+ },
141
+ destroySlots() {}
142
+ };
143
+
144
+ globalThis.window = {
145
+ setTimeout,
146
+ clearTimeout,
147
+ googletag
148
+ };
149
+ globalThis.document = {};
150
+
151
+ return {
152
+ restore() {
153
+ globalThis.window = originalWindow;
154
+ globalThis.document = originalDocument;
155
+ globalThis.fetch = originalFetch;
156
+ }
157
+ };
158
+ }
159
+
160
+ test("google rewarded runtime resolves immediately when the gate is already unlocked", async () => {
161
+ const originalFetch = globalThis.fetch;
162
+ const { calls, fetchStub } = createFetchStub({
163
+ currentResponse: {
164
+ gateKey: "progress-logging",
165
+ workspaceSlug: "alpha",
166
+ surface: "app",
167
+ enabled: true,
168
+ available: true,
169
+ blocked: false,
170
+ reason: "already-unlocked",
171
+ rule: null,
172
+ providerConfig: {
173
+ id: "21",
174
+ surface: "app",
175
+ enabled: true,
176
+ placement: "/123456/rewarded",
177
+ provider: "google-publisher-tag"
178
+ },
179
+ unlock: {
180
+ id: "31",
181
+ gateKey: "progress-logging",
182
+ providerConfigId: "21",
183
+ watchSessionId: "41",
184
+ grantedAt: new Date().toISOString(),
185
+ unlockedUntil: new Date(Date.now() + 60_000).toISOString()
186
+ },
187
+ cooldownUntil: null,
188
+ dailyLimitRemaining: null
189
+ }
190
+ });
191
+ globalThis.fetch = fetchStub;
192
+
193
+ try {
194
+ const runtime = createRewardedRuntime({ launchReward: launchGoogleRewardedAd });
195
+ const result = await runtime.requireUnlock({
196
+ gateKey: "progress-logging",
197
+ workspaceSlug: "alpha"
198
+ });
199
+
200
+ assert.equal(result.granted, true);
201
+ assert.equal(result.state.reason, "already-unlocked");
202
+ assert.equal(runtime.state.open, false);
203
+ assert.equal(calls.filter((entry) => entry.url.includes("/rewarded/current")).length, 1);
204
+ assert.doesNotMatch(
205
+ calls.find((entry) => entry.url.includes("/rewarded/current"))?.url || "",
206
+ /surface=/
207
+ );
208
+ } finally {
209
+ globalThis.fetch = originalFetch;
210
+ }
211
+ });
212
+
213
+ test("google rewarded runtime rejects when the current gate state is malformed", async () => {
214
+ const originalFetch = globalThis.fetch;
215
+ const { fetchStub } = createFetchStub({
216
+ currentResponse: {
217
+ gateKey: "progress-logging",
218
+ workspaceSlug: "alpha"
219
+ }
220
+ });
221
+ globalThis.fetch = fetchStub;
222
+
223
+ try {
224
+ const runtime = createRewardedRuntime({ launchReward: launchGoogleRewardedAd });
225
+ await assert.rejects(
226
+ () => runtime.requireUnlock({
227
+ gateKey: "progress-logging",
228
+ workspaceSlug: "alpha"
229
+ }),
230
+ /invalid state/i
231
+ );
232
+ assert.equal(runtime.state.open, false);
233
+ } finally {
234
+ globalThis.fetch = originalFetch;
235
+ }
236
+ });
237
+
238
+ test("google rewarded runtime rejects reasonless non-blocking gate states", async () => {
239
+ const originalFetch = globalThis.fetch;
240
+ const { fetchStub } = createFetchStub({
241
+ currentResponse: {
242
+ gateKey: "progress-logging",
243
+ workspaceSlug: "alpha",
244
+ enabled: false,
245
+ blocked: false
246
+ }
247
+ });
248
+ globalThis.fetch = fetchStub;
249
+
250
+ try {
251
+ const runtime = createRewardedRuntime({ launchReward: launchGoogleRewardedAd });
252
+ await assert.rejects(
253
+ () => runtime.requireUnlock({
254
+ gateKey: "progress-logging",
255
+ workspaceSlug: "alpha"
256
+ }),
257
+ /invalid state/i
258
+ );
259
+ assert.equal(runtime.state.open, false);
260
+ } finally {
261
+ globalThis.fetch = originalFetch;
262
+ }
263
+ });
264
+
265
+ test("google rewarded runtime completes a rewarded watch flow and grants unlock state", async () => {
266
+ const globals = installBrowserGlobals({
267
+ mode: "grant"
268
+ });
269
+ const { calls, fetchStub } = createFetchStub({
270
+ currentResponse: {
271
+ gateKey: "progress-logging",
272
+ workspaceSlug: "alpha",
273
+ surface: "app",
274
+ enabled: true,
275
+ available: true,
276
+ blocked: true,
277
+ reason: "reward-required",
278
+ rule: null,
279
+ providerConfig: {
280
+ id: "21",
281
+ surface: "app",
282
+ enabled: true,
283
+ placement: "/123456/rewarded",
284
+ provider: "google-publisher-tag"
285
+ },
286
+ unlock: null,
287
+ cooldownUntil: null,
288
+ dailyLimitRemaining: null
289
+ },
290
+ startResponse: {
291
+ gateKey: "progress-logging",
292
+ workspaceSlug: "alpha",
293
+ surface: "app",
294
+ enabled: true,
295
+ available: true,
296
+ blocked: true,
297
+ reason: "reward-required",
298
+ rule: null,
299
+ providerConfig: {
300
+ id: "21",
301
+ surface: "app",
302
+ enabled: true,
303
+ placement: "/123456/rewarded",
304
+ provider: "google-publisher-tag"
305
+ },
306
+ unlock: null,
307
+ cooldownUntil: null,
308
+ dailyLimitRemaining: null,
309
+ session: {
310
+ id: "41",
311
+ gateKey: "progress-logging",
312
+ providerConfigId: "21",
313
+ status: "started",
314
+ startedAt: new Date().toISOString(),
315
+ rewardedAt: null,
316
+ completedAt: null,
317
+ closedAt: null
318
+ }
319
+ },
320
+ grantResponse: {
321
+ unlocked: true,
322
+ workspaceSlug: "alpha",
323
+ gateKey: "progress-logging",
324
+ unlock: {
325
+ id: "51",
326
+ gateKey: "progress-logging",
327
+ providerConfigId: "21",
328
+ watchSessionId: "41",
329
+ grantedAt: new Date().toISOString(),
330
+ unlockedUntil: new Date(Date.now() + 30 * 60_000).toISOString()
331
+ },
332
+ session: {
333
+ id: "41",
334
+ gateKey: "progress-logging",
335
+ providerConfigId: "21",
336
+ status: "rewarded",
337
+ startedAt: new Date().toISOString(),
338
+ rewardedAt: new Date().toISOString(),
339
+ completedAt: new Date().toISOString(),
340
+ closedAt: null
341
+ }
342
+ }
343
+ });
344
+ globalThis.fetch = fetchStub;
345
+
346
+ try {
347
+ const runtime = createRewardedRuntime({ launchReward: launchGoogleRewardedAd });
348
+ const unlockPromise = runtime.requireUnlock({
349
+ gateKey: "progress-logging",
350
+ workspaceSlug: "alpha"
351
+ });
352
+
353
+ await waitForAsyncTurn();
354
+ assert.equal(runtime.state.phase, "prompt");
355
+
356
+ await runtime.beginWatch();
357
+ const result = await unlockPromise;
358
+
359
+ assert.equal(result.granted, true);
360
+ assert.equal(result.state.unlock.watchSessionId, "41");
361
+ assert.equal(runtime.state.open, false);
362
+ assert.equal(calls.filter((entry) => entry.url.includes("/rewarded/start")).length, 1);
363
+ assert.equal(calls.filter((entry) => entry.url.includes("/rewarded/grant")).length, 1);
364
+ assert.equal(calls.filter((entry) => entry.url.includes("/rewarded/close")).length, 0);
365
+ assert.deepEqual(calls.find((entry) => entry.url.includes("/rewarded/start"))?.body, {
366
+ gateKey: "progress-logging"
367
+ });
368
+ } finally {
369
+ globals.restore();
370
+ }
371
+ });
372
+
373
+ test("google rewarded runtime closes a started session when the ad is dismissed without reward", async () => {
374
+ const globals = installBrowserGlobals({
375
+ mode: "close"
376
+ });
377
+ const { calls, fetchStub } = createFetchStub({
378
+ currentResponse: {
379
+ gateKey: "progress-logging",
380
+ workspaceSlug: "alpha",
381
+ surface: "app",
382
+ enabled: true,
383
+ available: true,
384
+ blocked: true,
385
+ reason: "reward-required",
386
+ rule: null,
387
+ providerConfig: {
388
+ id: "21",
389
+ surface: "app",
390
+ enabled: true,
391
+ placement: "/123456/rewarded",
392
+ provider: "google-publisher-tag"
393
+ },
394
+ unlock: null,
395
+ cooldownUntil: null,
396
+ dailyLimitRemaining: null
397
+ },
398
+ startResponse: {
399
+ gateKey: "progress-logging",
400
+ workspaceSlug: "alpha",
401
+ surface: "app",
402
+ enabled: true,
403
+ available: true,
404
+ blocked: true,
405
+ reason: "reward-required",
406
+ rule: null,
407
+ providerConfig: {
408
+ id: "21",
409
+ surface: "app",
410
+ enabled: true,
411
+ placement: "/123456/rewarded",
412
+ provider: "google-publisher-tag"
413
+ },
414
+ unlock: null,
415
+ cooldownUntil: null,
416
+ dailyLimitRemaining: null,
417
+ session: {
418
+ id: "41",
419
+ gateKey: "progress-logging",
420
+ providerConfigId: "21",
421
+ status: "started",
422
+ startedAt: new Date().toISOString(),
423
+ rewardedAt: null,
424
+ completedAt: null,
425
+ closedAt: null
426
+ }
427
+ },
428
+ closeResponse: {
429
+ closed: true,
430
+ workspaceSlug: "alpha",
431
+ gateKey: "progress-logging",
432
+ session: {
433
+ id: "41",
434
+ gateKey: "progress-logging",
435
+ providerConfigId: "21",
436
+ status: "closed",
437
+ startedAt: new Date().toISOString(),
438
+ rewardedAt: null,
439
+ completedAt: null,
440
+ closedAt: new Date().toISOString()
441
+ },
442
+ reason: null
443
+ }
444
+ });
445
+ globalThis.fetch = fetchStub;
446
+
447
+ try {
448
+ const runtime = createRewardedRuntime({ launchReward: launchGoogleRewardedAd });
449
+ const unlockPromise = runtime.requireUnlock({
450
+ gateKey: "progress-logging",
451
+ workspaceSlug: "alpha"
452
+ });
453
+
454
+ await waitForAsyncTurn();
455
+ await runtime.beginWatch();
456
+ const result = await unlockPromise;
457
+
458
+ assert.equal(result.granted, false);
459
+ assert.equal(result.state.closed, true);
460
+ assert.equal(runtime.state.open, false);
461
+ assert.equal(calls.filter((entry) => entry.url.includes("/rewarded/grant")).length, 0);
462
+ assert.equal(calls.filter((entry) => entry.url.includes("/rewarded/close")).length, 1);
463
+ } finally {
464
+ globals.restore();
465
+ }
466
+ });
467
+
468
+ test("google rewarded runtime exposes an error state when no rewarded slot is available and cleans up on dismiss", async () => {
469
+ const globals = installBrowserGlobals({
470
+ mode: "unavailable"
471
+ });
472
+ const { calls, fetchStub } = createFetchStub({
473
+ currentResponse: {
474
+ gateKey: "progress-logging",
475
+ workspaceSlug: "alpha",
476
+ surface: "app",
477
+ enabled: true,
478
+ available: true,
479
+ blocked: true,
480
+ reason: "reward-required",
481
+ rule: null,
482
+ providerConfig: {
483
+ id: "21",
484
+ surface: "app",
485
+ enabled: true,
486
+ placement: "/123456/rewarded",
487
+ provider: "google-publisher-tag"
488
+ },
489
+ unlock: null,
490
+ cooldownUntil: null,
491
+ dailyLimitRemaining: null
492
+ },
493
+ startResponse: {
494
+ gateKey: "progress-logging",
495
+ workspaceSlug: "alpha",
496
+ surface: "app",
497
+ enabled: true,
498
+ available: true,
499
+ blocked: true,
500
+ reason: "reward-required",
501
+ rule: null,
502
+ providerConfig: {
503
+ id: "21",
504
+ surface: "app",
505
+ enabled: true,
506
+ placement: "/123456/rewarded",
507
+ provider: "google-publisher-tag"
508
+ },
509
+ unlock: null,
510
+ cooldownUntil: null,
511
+ dailyLimitRemaining: null,
512
+ session: {
513
+ id: "41",
514
+ gateKey: "progress-logging",
515
+ providerConfigId: "21",
516
+ status: "started",
517
+ startedAt: new Date().toISOString(),
518
+ rewardedAt: null,
519
+ completedAt: null,
520
+ closedAt: null
521
+ }
522
+ },
523
+ closeResponse: {
524
+ closed: true,
525
+ workspaceSlug: "alpha",
526
+ gateKey: "progress-logging",
527
+ session: {
528
+ id: "41",
529
+ gateKey: "progress-logging",
530
+ providerConfigId: "21",
531
+ status: "closed",
532
+ startedAt: new Date().toISOString(),
533
+ rewardedAt: null,
534
+ completedAt: null,
535
+ closedAt: new Date().toISOString()
536
+ },
537
+ reason: null
538
+ }
539
+ });
540
+ globalThis.fetch = fetchStub;
541
+
542
+ try {
543
+ const runtime = createRewardedRuntime({ launchReward: launchGoogleRewardedAd });
544
+ const unlockPromise = runtime.requireUnlock({
545
+ gateKey: "progress-logging",
546
+ workspaceSlug: "alpha"
547
+ });
548
+
549
+ await waitForAsyncTurn();
550
+ await runtime.beginWatch();
551
+
552
+ assert.equal(runtime.state.phase, "error");
553
+ assert.match(runtime.state.errorMessage, /rewarded ad/i);
554
+
555
+ await runtime.dismissError();
556
+ const result = await unlockPromise;
557
+
558
+ assert.equal(result.granted, false);
559
+ assert.equal(runtime.state.open, false);
560
+ assert.equal(calls.filter((entry) => entry.url.includes("/rewarded/close")).length, 1);
561
+ } finally {
562
+ globals.restore();
563
+ }
564
+ });
565
+
566
+
567
+ test("reward orchestration requires explicit delivery and accepts non-Google configuration", async () => {
568
+ assert.throws(() => createRewardedRuntime(), /explicit launchReward/);
569
+ const originalFetch = globalThis.fetch;
570
+ const providerConfig = { id: "custom", placement: "bonus" };
571
+ const { calls, fetchStub } = createFetchStub({
572
+ currentResponse: { enabled: true, blocked: true, reason: "reward-required", providerConfig },
573
+ startResponse: { session: { id: "session-custom" }, providerConfig },
574
+ grantResponse: { unlocked: true, unlock: { id: "receipt-custom" } }
575
+ });
576
+ globalThis.fetch = fetchStub;
577
+ let deliveries = 0;
578
+ try {
579
+ const runtime = createRewardedRuntime({
580
+ async launchReward({ providerConfig: received, onReady, onGranted, onClosed }) {
581
+ deliveries += 1;
582
+ assert.deepEqual(received, providerConfig);
583
+ await onReady();
584
+ await onGranted();
585
+ await onClosed();
586
+ }
587
+ });
588
+ const resultPromise = runtime.requireUnlock({ gateKey: "bonus", workspaceSlug: "alpha" });
589
+ await waitForAsyncTurn();
590
+ await runtime.beginWatch();
591
+ const result = await resultPromise;
592
+ assert.equal(deliveries, 1);
593
+ assert.equal(result.granted, true);
594
+ assert.equal(result.state.unlock.id, "receipt-custom");
595
+ assert.equal(calls.filter((entry) => entry.url.includes("/grant")).length, 1);
596
+ assert.equal(runtime.state.open, false);
597
+ } finally {
598
+ globalThis.fetch = originalFetch;
599
+ }
600
+ });