@jx3box/jx3box-ui 2.4.2 → 2.4.4

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,907 @@
1
+ const path = require("path");
2
+
3
+ require("@babel/register")({
4
+ babelrc: false,
5
+ configFile: false,
6
+ extensions: [".js"],
7
+ ignore: [
8
+ function (filename) {
9
+ return (
10
+ filename.includes(`${path.sep}node_modules${path.sep}`) &&
11
+ !filename.includes(`${path.sep}@jx3box${path.sep}jx3box-common${path.sep}`)
12
+ );
13
+ },
14
+ ],
15
+ presets: [[require.resolve("@babel/preset-env"), { modules: "commonjs", targets: { node: "current" } }]],
16
+ });
17
+
18
+ const assert = require("node:assert/strict");
19
+ const fs = require("node:fs");
20
+ const { test } = require("node:test");
21
+ const fixture = require("@jx3box/jx3box-common/docs/fixtures/analytics-dual-sink-v1.json");
22
+ const SHARED_ANALYTICS_QUEUE_STORAGE_KEY = "jx3box:analytics:queue:v1";
23
+ const { createEventQueue, createQueueStorage, createTrafficSink } = require("@jx3box/jx3box-common/js/analytics.js");
24
+ const {
25
+ DEFAULT_BLOCK_STORAGE_KEY,
26
+ DEFAULT_QUEUE_STORAGE_KEY,
27
+ createJx3boxTrafficAnalytics,
28
+ normalizeRecipientDomain,
29
+ normalizeTrafficPermission,
30
+ resolvePcTrafficGameClient,
31
+ } = require("../src/utils/traffic-analytics.js");
32
+ const trafficSource = fs.readFileSync(path.resolve(__dirname, "../src/utils/traffic-analytics.js"), "utf8");
33
+
34
+ assert.match(trafficSource, /const COMMON_HEADER_TRAFFIC_PROJECT = "jx3box-ui"/);
35
+ assert.doesNotMatch(trafficSource, /PC_PROJECT_ROOTS|resolvePcTrafficProject/);
36
+
37
+ function createMemoryStorage(initial) {
38
+ const values = new Map(Object.entries(initial || {}));
39
+ return {
40
+ getItem(key) {
41
+ return values.has(key) ? values.get(key) : null;
42
+ },
43
+ setItem(key, value) {
44
+ values.set(key, String(value));
45
+ },
46
+ removeItem(key) {
47
+ values.delete(key);
48
+ },
49
+ snapshot() {
50
+ return Object.fromEntries(values.entries());
51
+ },
52
+ };
53
+ }
54
+
55
+ function createNoopTimers() {
56
+ let nextId = 0;
57
+ const pending = new Map();
58
+ return {
59
+ setTimeout(callback, delay) {
60
+ nextId += 1;
61
+ pending.set(nextId, { callback, delay });
62
+ return nextId;
63
+ },
64
+ clearTimeout(id) {
65
+ pending.delete(id);
66
+ },
67
+ };
68
+ }
69
+
70
+ function createRouter() {
71
+ let afterEachHandler = null;
72
+ const currentRoute = { value: null };
73
+ return {
74
+ currentRoute,
75
+ isReady() {
76
+ return Promise.resolve();
77
+ },
78
+ afterEach(handler) {
79
+ afterEachHandler = handler;
80
+ return function () {
81
+ afterEachHandler = null;
82
+ };
83
+ },
84
+ navigate(to, from) {
85
+ currentRoute.value = to;
86
+ if (afterEachHandler) afterEachHandler(to, from || null, null);
87
+ },
88
+ hasOwner() {
89
+ return !!afterEachHandler;
90
+ },
91
+ };
92
+ }
93
+
94
+ function createRuntime(options) {
95
+ const settings = options || {};
96
+ const listeners = new Map();
97
+ const ids = (settings.ids || []).slice();
98
+ return {
99
+ localStorage: settings.localStorage || createMemoryStorage(),
100
+ sessionStorage: settings.sessionStorage || createMemoryStorage(),
101
+ location: {
102
+ hostname: "www.jx3box.com",
103
+ href: "https://www.jx3box.com/index?section=home#private",
104
+ },
105
+ document: {
106
+ referrer: "https://ref.example/private?q=secret#hidden",
107
+ },
108
+ navigator: settings.navigator || {
109
+ sendBeacon() {
110
+ return true;
111
+ },
112
+ },
113
+ crypto: {
114
+ randomUUID() {
115
+ return ids.shift() || `generated-${Date.now()}-${Math.random()}`;
116
+ },
117
+ },
118
+ fetch: settings.fetch,
119
+ AbortController,
120
+ setTimeout,
121
+ clearTimeout,
122
+ addEventListener(name, handler) {
123
+ if (!listeners.has(name)) listeners.set(name, new Set());
124
+ listeners.get(name).add(handler);
125
+ },
126
+ removeEventListener(name, handler) {
127
+ if (listeners.has(name)) listeners.get(name).delete(handler);
128
+ },
129
+ listenerCount(name) {
130
+ return listeners.has(name) ? listeners.get(name).size : 0;
131
+ },
132
+ };
133
+ }
134
+
135
+ function createResponse(status, payload, headers) {
136
+ const values = Object.assign({}, headers || {});
137
+ return {
138
+ ok: status >= 200 && status < 300,
139
+ status,
140
+ headers: {
141
+ get(name) {
142
+ const match = Object.keys(values).find((key) => key.toLowerCase() === String(name).toLowerCase());
143
+ return match ? values[match] : null;
144
+ },
145
+ },
146
+ async json() {
147
+ return payload;
148
+ },
149
+ };
150
+ }
151
+
152
+ function createIdentity() {
153
+ let sequence = 0;
154
+ return {
155
+ getInstanceId() {
156
+ return fixture.canonical_event.instance_id;
157
+ },
158
+ getSessionId() {
159
+ return fixture.canonical_event.session_id;
160
+ },
161
+ nextEvent() {
162
+ sequence += 1;
163
+ return {
164
+ instance_id: fixture.canonical_event.instance_id,
165
+ session_id: fixture.canonical_event.session_id,
166
+ sequence_no: sequence,
167
+ session_rotated: false,
168
+ };
169
+ },
170
+ };
171
+ }
172
+
173
+ function waitForRouter() {
174
+ return new Promise((resolve) => setTimeout(resolve, 5));
175
+ }
176
+
177
+ function walkKeys(value, output) {
178
+ if (!value || typeof value !== "object") return;
179
+ Object.keys(value).forEach((key) => {
180
+ output.push(key);
181
+ walkKeys(value[key], output);
182
+ });
183
+ }
184
+
185
+ test("permission normalization understands heartbeat allow and persistent block states", () => {
186
+ assert.deepEqual(normalizeTrafficPermission({ traffic_allowed: true }), { known: true, allow: true });
187
+ assert.deepEqual(normalizeTrafficPermission({ is_robot: true }), {
188
+ known: true,
189
+ allow: false,
190
+ reason: "robot",
191
+ });
192
+ assert.deepEqual(
193
+ normalizeTrafficPermission({
194
+ traffic_allowed: false,
195
+ collection_blocked_reason: "robot",
196
+ }),
197
+ {
198
+ known: true,
199
+ allow: false,
200
+ reason: "robot",
201
+ }
202
+ );
203
+ assert.deepEqual(normalizeTrafficPermission(undefined), { known: false, allow: false });
204
+ });
205
+
206
+ test("recipient domains preserve web hosts and normalize embedded surfaces", () => {
207
+ assert.equal(normalizeRecipientDomain("WWW.JX3BOX.COM", "pc_web"), "www.jx3box.com");
208
+ assert.equal(normalizeRecipientDomain("origin.jx3box.com", "pc_web"), "origin.jx3box.com");
209
+ assert.equal(normalizeRecipientDomain("localhost", "pc_game"), "embedded");
210
+ assert.equal(normalizeRecipientDomain("https://www.jx3box.com/private", "pc_web"), "");
211
+ });
212
+
213
+ test("PC CommonHeader resolves query-first game_client and defaults every non-origin host to std", () => {
214
+ assert.equal(
215
+ resolvePcTrafficGameClient({ location: { hostname: "www.jx3box.com", search: "?client=origin" } }),
216
+ "origin"
217
+ );
218
+ assert.equal(
219
+ resolvePcTrafficGameClient({ location: { hostname: "origin.jx3box.com", search: "?client=std" } }),
220
+ "std"
221
+ );
222
+ assert.equal(
223
+ resolvePcTrafficGameClient({ location: { hostname: "origin.jx3box.com", search: "?client=all" } }),
224
+ "origin"
225
+ );
226
+ assert.equal(resolvePcTrafficGameClient({ location: { hostname: "localhost", search: "" } }), "std");
227
+ assert.equal(resolvePcTrafficGameClient({ location: { hostname: "localhost" } }), "std");
228
+ assert.equal(resolvePcTrafficGameClient({ location: { hostname: "other.jx3box.com" } }), "std");
229
+ });
230
+
231
+ test("headless opt-in emits the official Traffic fixture once and honors per-id ACK", async () => {
232
+ const requests = [];
233
+ const timers = createNoopTimers();
234
+ let currentTime = Date.parse(fixture.canonical_event.occurred_at);
235
+ const runtime = createRuntime({
236
+ ids: [fixture.canonical_event.page_view_id, fixture.canonical_event.event_id],
237
+ async fetch(url, options) {
238
+ requests.push({ url, options: options || {} });
239
+ if (String(url).startsWith("/api/cms/system/traffic/config")) {
240
+ return createResponse(200, {
241
+ code: 0,
242
+ data: {
243
+ enabled: true,
244
+ page_key: fixture.canonical_event.page_key,
245
+ route_pattern: fixture.canonical_event.route_pattern,
246
+ layout_version: fixture.canonical_event.layout_version,
247
+ sample_rate: 1,
248
+ event_types: ["page_view"],
249
+ require_public_target: true,
250
+ query_targets: [
251
+ {
252
+ query_key: "section",
253
+ target_type: "section",
254
+ validator: "bounded_slug",
255
+ required: true,
256
+ },
257
+ ],
258
+ rule_version: "traffic-2026-08-26",
259
+ },
260
+ });
261
+ }
262
+ const envelope = JSON.parse(options.body);
263
+ return createResponse(200, {
264
+ code: 0,
265
+ data: {
266
+ items: [{ event_id: envelope.events[0].event_id, status: "duplicate" }],
267
+ blocked: null,
268
+ rule_version: fixture.traffic.response.data.rule_version,
269
+ },
270
+ });
271
+ },
272
+ });
273
+ const router = createRouter();
274
+ const traffic = createJx3boxTrafficAnalytics({
275
+ runtime,
276
+ router,
277
+ captureInitial: false,
278
+ identity: createIdentity(),
279
+ now: () => currentTime,
280
+ project: fixture.canonical_event.project,
281
+ product: fixture.canonical_event.product,
282
+ client: fixture.canonical_event.client,
283
+ surface: fixture.canonical_event.surface,
284
+ gameClient: fixture.canonical_event.game_client,
285
+ platform: fixture.canonical_event.platform,
286
+ trafficAllowed: true,
287
+ queueSetTimeout: timers.setTimeout,
288
+ queueClearTimeout: timers.clearTimeout,
289
+ });
290
+
291
+ assert.equal(traffic.getState().initialized, false);
292
+ assert.equal(requests.length, 0, "importing and creating the adapter must be inert");
293
+ await traffic.init();
294
+ await traffic.init();
295
+ assert.equal(router.hasOwner(), true);
296
+ assert.equal(runtime.listenerCount("pagehide"), 1);
297
+ assert.equal(requests.length, 0, "init without an initial route must not synthesize a page view");
298
+
299
+ router.navigate({
300
+ name: fixture.canonical_event.route_name,
301
+ matched: [{ path: fixture.canonical_event.route_pattern }],
302
+ meta: {
303
+ analytics: {
304
+ page_key: fixture.canonical_event.page_key,
305
+ layout_version: fixture.canonical_event.layout_version,
306
+ },
307
+ },
308
+ params: {},
309
+ query: { section: "home", secret: "must-stay-local" },
310
+ hash: "#private",
311
+ fullPath: "/index?section=home&secret=must-stay-local#private",
312
+ });
313
+ await waitForRouter();
314
+
315
+ assert.equal(requests.length, 1, "navigation should request config but defer Traffic until finalization");
316
+ assert.match(requests[0].url, /^\/api\/cms\/system\/traffic\/config\?/);
317
+ assert.equal(requests[0].url.includes("must-stay-local"), false);
318
+ assert.equal(requests[0].url.includes("section=home"), false);
319
+ assert.equal(traffic.getState().analytics.queue.pending, 1);
320
+ assert.equal(traffic.getState().analytics.queue.entries[0].deliveries.traffic.state, "deferred");
321
+
322
+ currentTime += fixture.traffic_finalization.duration_ms;
323
+ assert.equal(traffic.finalize({ is_exit: true, reason: "contract_test" }), true);
324
+ const flushResult = await traffic.flush({ reason: "contract_test" });
325
+ assert.equal(flushResult.sent, 1);
326
+ assert.equal(requests.length, 2);
327
+ const envelope = JSON.parse(requests[1].options.body);
328
+ const expectedEnvelope = JSON.parse(JSON.stringify(fixture.traffic.request));
329
+ expectedEnvelope.events[0].event_id = envelope.events[0].event_id;
330
+ assert.deepEqual(envelope, expectedEnvelope);
331
+ const payloadKeys = [];
332
+ walkKeys(envelope, payloadKeys);
333
+ fixture.privacy_assertions.payload_fields_that_must_be_absent.forEach((key) => {
334
+ assert.equal(payloadKeys.includes(key), false, `${key} must stay out of Traffic payloads`);
335
+ });
336
+ assert.equal(traffic.getState().analytics.queue.pending, 0, "duplicate ACK is terminal per event id");
337
+ assert.equal(
338
+ requests.some((request) => request.url.includes("/system/stat/tracking")),
339
+ false
340
+ );
341
+
342
+ traffic.destroy();
343
+ assert.equal(router.hasOwner(), false);
344
+ assert.equal(runtime.listenerCount("pagehide"), 0);
345
+ });
346
+
347
+ test("remote Config failure is fail-closed and creates no Traffic journal entry", async () => {
348
+ const requests = [];
349
+ const timers = createNoopTimers();
350
+ const runtime = createRuntime({
351
+ async fetch(url) {
352
+ requests.push(url);
353
+ return createResponse(503, { code: 503 });
354
+ },
355
+ });
356
+ const router = createRouter();
357
+ const traffic = createJx3boxTrafficAnalytics({
358
+ runtime,
359
+ router,
360
+ captureInitial: false,
361
+ project: "index",
362
+ client: "pc_web",
363
+ surface: "pc_web",
364
+ gameClient: "std",
365
+ trafficAllowed: true,
366
+ queueSetTimeout: timers.setTimeout,
367
+ queueClearTimeout: timers.clearTimeout,
368
+ });
369
+ await traffic.init();
370
+ router.navigate({
371
+ name: "index",
372
+ matched: [{ path: "/index" }],
373
+ meta: { analytics: { page_key: "index.home" } },
374
+ params: {},
375
+ query: {},
376
+ });
377
+ await waitForRouter();
378
+ assert.equal(requests.length, 1);
379
+ assert.equal(traffic.getState().analytics.active, false);
380
+ assert.equal(traffic.getState().analytics.queue.pending, 0);
381
+ traffic.destroy();
382
+ });
383
+
384
+ test("embedded hosts use the backend recipient domain in both Config and envelope", async () => {
385
+ const requests = [];
386
+ const timers = createNoopTimers();
387
+ const runtime = createRuntime({
388
+ async fetch(url, options) {
389
+ requests.push({ url, options: options || {} });
390
+ if (String(url).startsWith("/api/cms/system/traffic/config")) {
391
+ return createResponse(200, {
392
+ code: 0,
393
+ data: {
394
+ enabled: true,
395
+ page_key: "game.home",
396
+ route_pattern: "/game",
397
+ sample_rate: 1,
398
+ event_types: ["page_view"],
399
+ rule_version: "traffic-embedded-domain",
400
+ },
401
+ });
402
+ }
403
+ const envelope = JSON.parse(options.body);
404
+ return createResponse(200, {
405
+ code: 0,
406
+ data: {
407
+ items: envelope.events.map((event) => ({ event_id: event.event_id, status: "accepted" })),
408
+ },
409
+ });
410
+ },
411
+ });
412
+ runtime.location.hostname = "localhost";
413
+ const router = createRouter();
414
+ const traffic = createJx3boxTrafficAnalytics({
415
+ runtime,
416
+ router,
417
+ captureInitial: false,
418
+ project: "game",
419
+ client: "pc_game",
420
+ surface: "pc_game",
421
+ gameClient: "std",
422
+ trafficAllowed: true,
423
+ queueSetTimeout: timers.setTimeout,
424
+ queueClearTimeout: timers.clearTimeout,
425
+ });
426
+ await traffic.init();
427
+ router.navigate({
428
+ name: "game",
429
+ matched: [{ path: "/game" }],
430
+ meta: { analytics: { page_key: "game.home" } },
431
+ params: {},
432
+ query: {},
433
+ });
434
+ await waitForRouter();
435
+ traffic.finalize({ is_exit: true, reason: "embedded_test" });
436
+ await traffic.flush({ reason: "embedded_test" });
437
+
438
+ assert.match(requests[0].url, /(?:\?|&)domain=embedded(?:&|$)/);
439
+ const envelope = JSON.parse(requests[1].options.body);
440
+ assert.equal(envelope.domain, "embedded");
441
+ assert.equal(envelope.surface, "pc_game");
442
+ traffic.destroy();
443
+ });
444
+
445
+ test("an explicit unknown permission pauses in memory until the host unblocks", async () => {
446
+ const requests = [];
447
+ const timers = createNoopTimers();
448
+ const localStorage = createMemoryStorage();
449
+ const runtime = createRuntime({
450
+ localStorage,
451
+ async fetch(url, options) {
452
+ requests.push({ url, options: options || {} });
453
+ if (String(url).startsWith("/api/cms/system/traffic/config")) {
454
+ return createResponse(200, {
455
+ code: 0,
456
+ data: {
457
+ enabled: true,
458
+ page_key: "index.home",
459
+ route_pattern: "/index",
460
+ sample_rate: 1,
461
+ event_types: ["page_view"],
462
+ rule_version: "traffic-permission-transition",
463
+ },
464
+ });
465
+ }
466
+ const envelope = JSON.parse(options.body);
467
+ return createResponse(200, {
468
+ code: 0,
469
+ data: {
470
+ items: envelope.events.map((event) => ({
471
+ event_id: event.event_id,
472
+ status: "accepted",
473
+ })),
474
+ },
475
+ });
476
+ },
477
+ });
478
+ const router = createRouter();
479
+ const traffic = createJx3boxTrafficAnalytics({
480
+ runtime,
481
+ router,
482
+ captureInitial: false,
483
+ project: "index",
484
+ client: "pc_web",
485
+ surface: "pc_web",
486
+ gameClient: "std",
487
+ trafficAllowed: true,
488
+ queueSetTimeout: timers.setTimeout,
489
+ queueClearTimeout: timers.clearTimeout,
490
+ });
491
+ await traffic.init();
492
+ router.navigate({
493
+ name: "index",
494
+ matched: [{ path: "/index" }],
495
+ meta: { analytics: { page_key: "index.home" } },
496
+ params: {},
497
+ query: {},
498
+ });
499
+ await waitForRouter();
500
+ traffic.finalize({ is_exit: true, reason: "permission_test" });
501
+ assert.equal(traffic.getState().analytics.queue.pending, 1);
502
+
503
+ assert.deepEqual(traffic.setTrafficPermission(undefined), { known: false, allow: false });
504
+ assert.equal(traffic.getState().analytics.queue.blocked, true);
505
+ assert.equal(traffic.getState().analytics.queue.blockReason.reason, "traffic_permission_unknown");
506
+ assert.equal(traffic.getState().analytics.queue.pending, 1, "unknown state retains sanitized Journal data");
507
+ assert.equal(
508
+ localStorage.getItem(DEFAULT_BLOCK_STORAGE_KEY),
509
+ null,
510
+ "unknown state is not a persistent device block"
511
+ );
512
+ await traffic.flush({ reason: "permission_unknown" });
513
+ assert.equal(requests.filter((request) => request.url === "/api/cms/system/traffic/visits/batch").length, 0);
514
+
515
+ assert.deepEqual(traffic.unblock(), { known: true, allow: true });
516
+ assert.equal(traffic.getState().analytics.queue.blocked, false);
517
+ await traffic.flush({ reason: "permission_restored" });
518
+ assert.equal(traffic.getState().analytics.queue.pending, 0);
519
+
520
+ traffic.block("host_privacy_disabled");
521
+ assert.equal(traffic.getState().analytics.queue.blocked, true);
522
+ assert.equal(JSON.parse(localStorage.getItem(DEFAULT_BLOCK_STORAGE_KEY)).reason, "host_privacy_disabled");
523
+ traffic.destroy();
524
+ });
525
+
526
+ test("destroy during asynchronous init releases resources without installing a Router owner", async () => {
527
+ const timers = createNoopTimers();
528
+ let resolvePermission;
529
+ const permission = new Promise((resolve) => {
530
+ resolvePermission = resolve;
531
+ });
532
+ const router = createRouter();
533
+ const traffic = createJx3boxTrafficAnalytics({
534
+ runtime: createRuntime(),
535
+ router,
536
+ captureInitial: false,
537
+ project: "index",
538
+ client: "pc_web",
539
+ surface: "pc_web",
540
+ gameClient: "std",
541
+ resolveTrafficPermission: () => permission,
542
+ queueSetTimeout: timers.setTimeout,
543
+ queueClearTimeout: timers.clearTimeout,
544
+ });
545
+ const initializing = traffic.init();
546
+ await new Promise((resolve) => setImmediate(resolve));
547
+ traffic.destroy();
548
+ resolvePermission({ traffic_allowed: true });
549
+ await assert.rejects(initializing, /has been destroyed/);
550
+ assert.equal(router.hasOwner(), false);
551
+ assert.equal(traffic.getState().destroyed, true);
552
+ });
553
+
554
+ test("two adapters cannot own the same Router and the first owner remains intact", async () => {
555
+ const timers = createNoopTimers();
556
+ const router = createRouter();
557
+ const commonOptions = {
558
+ runtime: createRuntime(),
559
+ router,
560
+ captureInitial: false,
561
+ project: "index",
562
+ client: "pc_web",
563
+ surface: "pc_web",
564
+ gameClient: "std",
565
+ trafficAllowed: true,
566
+ queueSetTimeout: timers.setTimeout,
567
+ queueClearTimeout: timers.clearTimeout,
568
+ };
569
+ const first = createJx3boxTrafficAnalytics(commonOptions);
570
+ const second = createJx3boxTrafficAnalytics(commonOptions);
571
+ await first.init();
572
+ await assert.rejects(second.init(), /router owner already installed/);
573
+ assert.equal(router.hasOwner(), true);
574
+ assert.equal(first.getState().analytics.navigationOwner, "router");
575
+ second.destroy();
576
+ first.destroy();
577
+ assert.equal(router.hasOwner(), false);
578
+ });
579
+
580
+ test("409 and 429 never ACK; retry keeps the same id until an explicit ACK", async () => {
581
+ const timers = createNoopTimers();
582
+ const storage = createMemoryStorage();
583
+ const statuses = [409, 429, 200];
584
+ let currentTime = Date.parse(fixture.canonical_event.occurred_at);
585
+ const sink = createTrafficSink({
586
+ endpoint: "/traffic",
587
+ runtime: {
588
+ async fetch() {
589
+ const status = statuses.shift();
590
+ if (status === 409) {
591
+ return createResponse(status, {
592
+ code: 0,
593
+ data: { items: [{ event_id: fixture.canonical_event.event_id, status: "accepted" }] },
594
+ });
595
+ }
596
+ if (status === 429) {
597
+ return createResponse(status, { code: 429 }, { "Retry-After": "2" });
598
+ }
599
+ return createResponse(status, {
600
+ code: 0,
601
+ data: { items: [{ event_id: fixture.canonical_event.event_id, status: "accepted" }] },
602
+ });
603
+ },
604
+ navigator: {},
605
+ AbortController,
606
+ },
607
+ });
608
+ const queue = createEventQueue({
609
+ runtime: { AbortController },
610
+ storage: createQueueStorage({ storage, key: "retry", now: () => currentTime }),
611
+ sinks: [sink],
612
+ legacySinkKey: "tracking",
613
+ beforeFlush: () => ({ allow: true }),
614
+ now: () => currentTime,
615
+ random: () => 0.5,
616
+ setTimeout: timers.setTimeout,
617
+ clearTimeout: timers.clearTimeout,
618
+ });
619
+ assert.equal(queue.enqueue(fixture.canonical_event, { sinkKeys: ["traffic"] }), true);
620
+ assert.equal(queue.finalize(fixture.canonical_event.event_id, "traffic", fixture.traffic_finalization), true);
621
+
622
+ await queue.flush({ reason: "409" });
623
+ let delivery = queue.getState().entries[0].deliveries.traffic;
624
+ assert.equal(delivery.state, "retry");
625
+ assert.equal(delivery.attempts, 1);
626
+ assert.equal(queue.getState().eventIds[0], fixture.canonical_event.event_id);
627
+
628
+ currentTime += 10;
629
+ await queue.flush({ reason: "429" });
630
+ delivery = queue.getState().entries[0].deliveries.traffic;
631
+ assert.equal(delivery.state, "retry");
632
+ assert.equal(delivery.attempts, 2);
633
+ assert.equal(delivery.retry_at, currentTime + 2000);
634
+
635
+ const requestsBeforeRetryAt = statuses.length;
636
+ await queue.flush({ reason: "too_early", respectRetryAt: true });
637
+ assert.equal(statuses.length, requestsBeforeRetryAt, "respectRetryAt must not send before the server delay");
638
+ currentTime = delivery.retry_at;
639
+ await queue.flush({ reason: "ack", respectRetryAt: true });
640
+ assert.equal(queue.getState().pending, 0);
641
+ assert.equal(statuses.length, 0);
642
+ queue.destroy();
643
+ });
644
+
645
+ test("partial per-id ACK removes only terminal deliveries and retries the missing id", async () => {
646
+ const timers = createNoopTimers();
647
+ const storage = createMemoryStorage();
648
+ const firstEvent = Object.assign({}, fixture.canonical_event, {
649
+ event_id: `${fixture.canonical_event.event_id}-first`,
650
+ sequence_no: 1,
651
+ });
652
+ const secondEvent = Object.assign({}, fixture.canonical_event, {
653
+ event_id: `${fixture.canonical_event.event_id}-second`,
654
+ previous_event_id: firstEvent.event_id,
655
+ sequence_no: 2,
656
+ });
657
+ let requestCount = 0;
658
+ const sink = createTrafficSink({
659
+ endpoint: "/traffic",
660
+ runtime: {
661
+ async fetch(url, options) {
662
+ assert.equal(url, "/traffic");
663
+ const envelope = JSON.parse(options.body);
664
+ requestCount += 1;
665
+ return createResponse(200, {
666
+ code: 0,
667
+ data: {
668
+ items:
669
+ requestCount === 1
670
+ ? [{ event_id: envelope.events[0].event_id, status: "accepted" }]
671
+ : [{ event_id: envelope.events[0].event_id, status: "duplicate" }],
672
+ },
673
+ });
674
+ },
675
+ navigator: {},
676
+ AbortController,
677
+ },
678
+ });
679
+ const queue = createEventQueue({
680
+ runtime: { AbortController },
681
+ storage: createQueueStorage({ storage, key: "partial-ack" }),
682
+ sinks: [sink],
683
+ legacySinkKey: "tracking",
684
+ beforeFlush: () => ({ allow: true }),
685
+ setTimeout: timers.setTimeout,
686
+ clearTimeout: timers.clearTimeout,
687
+ });
688
+ [firstEvent, secondEvent].forEach((event) => {
689
+ assert.equal(queue.enqueue(event, { sinkKeys: ["traffic"] }), true);
690
+ assert.equal(queue.finalize(event.event_id, "traffic", fixture.traffic_finalization), true);
691
+ });
692
+
693
+ const firstResult = await queue.flush({ reason: "partial_ack" });
694
+ assert.equal(firstResult.sent, 1);
695
+ assert.deepEqual(queue.getState().eventIds, [secondEvent.event_id]);
696
+ assert.equal(queue.getState().entries[0].deliveries.traffic.state, "retry");
697
+
698
+ const secondResult = await queue.flush({ reason: "retry_missing_ack" });
699
+ assert.equal(secondResult.sent, 1);
700
+ assert.equal(queue.getState().pending, 0);
701
+ assert.equal(requestCount, 2);
702
+ queue.destroy();
703
+ });
704
+
705
+ test("trusted block persists, clears the queue, cancels inflight and requires explicit unblock", async () => {
706
+ const timers = createNoopTimers();
707
+ const localStorage = createMemoryStorage();
708
+ const first = createJx3boxTrafficAnalytics({
709
+ runtime: createRuntime({ localStorage }),
710
+ router: createRouter(),
711
+ captureInitial: false,
712
+ project: "index",
713
+ client: "pc_web",
714
+ surface: "pc_web",
715
+ gameClient: "std",
716
+ trafficAllowed: { traffic_allowed: false, collection_blocked_reason: "robot" },
717
+ queueSetTimeout: timers.setTimeout,
718
+ queueClearTimeout: timers.clearTimeout,
719
+ });
720
+ await first.init();
721
+ assert.equal(first.getState().analytics.queue.blocked, true);
722
+ assert.equal(first.getState().analytics.queue.blockReason.reason, "robot");
723
+ assert.equal(JSON.parse(localStorage.getItem(DEFAULT_BLOCK_STORAGE_KEY)).reason, "robot");
724
+ first.destroy();
725
+
726
+ const restored = createJx3boxTrafficAnalytics({
727
+ runtime: createRuntime({ localStorage }),
728
+ router: createRouter(),
729
+ captureInitial: false,
730
+ project: "index",
731
+ client: "pc_web",
732
+ surface: "pc_web",
733
+ gameClient: "std",
734
+ queueSetTimeout: timers.setTimeout,
735
+ queueClearTimeout: timers.clearTimeout,
736
+ });
737
+ await restored.init();
738
+ assert.equal(restored.getState().analytics.queue.blocked, true, "block must survive reload");
739
+ restored.setTrafficPermission({ traffic_allowed: true });
740
+ assert.equal(restored.getState().analytics.queue.blocked, false);
741
+ assert.equal(localStorage.getItem(DEFAULT_BLOCK_STORAGE_KEY), null);
742
+ restored.destroy();
743
+
744
+ let aborted = false;
745
+ let requestSignal;
746
+ const sink = createTrafficSink({
747
+ endpoint: "/traffic",
748
+ runtime: {
749
+ fetch(url, options) {
750
+ requestSignal = options.signal;
751
+ return new Promise((resolve, reject) => {
752
+ options.signal.addEventListener("abort", () => {
753
+ aborted = true;
754
+ reject(new Error("aborted"));
755
+ });
756
+ });
757
+ },
758
+ navigator: {},
759
+ AbortController,
760
+ },
761
+ });
762
+ const queue = createEventQueue({
763
+ runtime: { AbortController },
764
+ storage: createQueueStorage({ storage: localStorage, key: "inflight" }),
765
+ sinks: [sink],
766
+ legacySinkKey: "tracking",
767
+ beforeFlush: () => ({ allow: true }),
768
+ setTimeout: timers.setTimeout,
769
+ clearTimeout: timers.clearTimeout,
770
+ });
771
+ queue.enqueue(fixture.canonical_event, { sinkKeys: ["traffic"] });
772
+ queue.finalize(fixture.canonical_event.event_id, "traffic", fixture.traffic_finalization);
773
+ const flushing = queue.flush({ reason: "inflight" });
774
+ await new Promise((resolve) => setImmediate(resolve));
775
+ assert.ok(requestSignal);
776
+ assert.equal(queue.cancelInflight("permission_changed"), 1);
777
+ await flushing;
778
+ assert.equal(aborted, true);
779
+ assert.equal(queue.clear("privacy_disable"), 1);
780
+ assert.equal(queue.getState().pending, 0);
781
+ queue.destroy();
782
+ });
783
+
784
+ test("a trusted server ACK block is persisted before the queue is cleared", async () => {
785
+ const timers = createNoopTimers();
786
+ const localStorage = createMemoryStorage();
787
+ const runtime = createRuntime({
788
+ localStorage,
789
+ async fetch(url, options) {
790
+ if (String(url).startsWith("/api/cms/system/traffic/config")) {
791
+ return createResponse(200, {
792
+ code: 0,
793
+ data: {
794
+ enabled: true,
795
+ page_key: "index.home",
796
+ route_pattern: "/index",
797
+ sample_rate: 1,
798
+ event_types: ["page_view"],
799
+ rule_version: "traffic-server-block",
800
+ },
801
+ });
802
+ }
803
+ assert.ok(options.body);
804
+ return createResponse(403, {
805
+ code: 403,
806
+ data: { blocked: "robot" },
807
+ });
808
+ },
809
+ });
810
+ const router = createRouter();
811
+ const traffic = createJx3boxTrafficAnalytics({
812
+ runtime,
813
+ router,
814
+ captureInitial: false,
815
+ project: "index",
816
+ client: "pc_web",
817
+ surface: "pc_web",
818
+ gameClient: "std",
819
+ trafficAllowed: true,
820
+ queueSetTimeout: timers.setTimeout,
821
+ queueClearTimeout: timers.clearTimeout,
822
+ });
823
+ await traffic.init();
824
+ router.navigate({
825
+ name: "index",
826
+ matched: [{ path: "/index" }],
827
+ meta: { analytics: { page_key: "index.home" } },
828
+ params: {},
829
+ query: {},
830
+ });
831
+ await waitForRouter();
832
+ traffic.finalize({ is_exit: true, reason: "server_block_test" });
833
+ await traffic.flush({ reason: "server_block_test" });
834
+ assert.equal(traffic.getState().analytics.queue.blocked, true);
835
+ assert.equal(traffic.getState().analytics.queue.pending, 0);
836
+ assert.equal(JSON.parse(localStorage.getItem(DEFAULT_BLOCK_STORAGE_KEY)).reason, "robot");
837
+ traffic.destroy();
838
+ });
839
+
840
+ test("the Traffic-only Journal never consumes or rewrites a shared Tracking Journal", async () => {
841
+ const now = Date.parse(fixture.canonical_event.occurred_at);
842
+ const sharedJournal = JSON.stringify({
843
+ version: 1,
844
+ saved_at: now,
845
+ entries: [{ event: fixture.canonical_event, queued_at: now, attempts: 0 }],
846
+ });
847
+ const localStorage = createMemoryStorage({
848
+ [SHARED_ANALYTICS_QUEUE_STORAGE_KEY]: sharedJournal,
849
+ });
850
+ let batchRequests = 0;
851
+ const timers = createNoopTimers();
852
+ const router = createRouter();
853
+ const traffic = createJx3boxTrafficAnalytics({
854
+ runtime: createRuntime({
855
+ localStorage,
856
+ async fetch(url, options) {
857
+ if (String(url).startsWith("/api/cms/system/traffic/config")) {
858
+ return createResponse(200, {
859
+ code: 0,
860
+ data: {
861
+ enabled: true,
862
+ page_key: "index.home",
863
+ route_pattern: "/index",
864
+ sample_rate: 1,
865
+ event_types: ["page_view"],
866
+ rule_version: "traffic-independent-journal",
867
+ },
868
+ });
869
+ }
870
+ batchRequests += 1;
871
+ const envelope = JSON.parse(options.body);
872
+ return createResponse(200, {
873
+ code: 0,
874
+ data: {
875
+ items: envelope.events.map((event) => ({ event_id: event.event_id, status: "accepted" })),
876
+ },
877
+ });
878
+ },
879
+ }),
880
+ router,
881
+ captureInitial: false,
882
+ now: () => now,
883
+ project: "index",
884
+ client: "pc_web",
885
+ surface: "pc_web",
886
+ gameClient: "std",
887
+ trafficAllowed: true,
888
+ queueSetTimeout: timers.setTimeout,
889
+ queueClearTimeout: timers.clearTimeout,
890
+ });
891
+ await traffic.init();
892
+ assert.equal(traffic.getState().analytics.queue.pending, 0);
893
+ router.navigate({
894
+ name: "index",
895
+ matched: [{ path: "/index" }],
896
+ meta: { analytics: { page_key: "index.home" } },
897
+ params: {},
898
+ query: {},
899
+ });
900
+ await waitForRouter();
901
+ traffic.finalize({ is_exit: true, reason: "journal_isolation" });
902
+ await traffic.flush({ reason: "journal_isolation" });
903
+ assert.equal(batchRequests, 1);
904
+ assert.equal(localStorage.getItem(SHARED_ANALYTICS_QUEUE_STORAGE_KEY), sharedJournal);
905
+ assert.notEqual(DEFAULT_QUEUE_STORAGE_KEY, SHARED_ANALYTICS_QUEUE_STORAGE_KEY);
906
+ traffic.destroy();
907
+ });