@messagevisor/sdk 0.0.1 → 0.1.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.
@@ -0,0 +1,2493 @@
1
+ import type { DatafileContent } from "@messagevisor/types";
2
+
3
+ import { Messagevisor, createMessagevisor, createMessagevisorCache } from "./index";
4
+
5
+ type RichNode = { type: string; children: Array<string | RichNode> };
6
+
7
+ const datafile: DatafileContent = {
8
+ schemaVersion: "1",
9
+ messagevisorVersion: "0.0.1",
10
+ revision: "1",
11
+ target: "web",
12
+ locale: "en-US",
13
+ direction: "ltr",
14
+ formats: {
15
+ number: {
16
+ money: { style: "currency", currency: "USD", currencyDisplay: "code" },
17
+ moneySymbol: { style: "currency", currency: "USD", currencyDisplay: "symbol" },
18
+ moneyCode: { style: "currency", currency: "USD", currencyDisplay: "code" },
19
+ moneyAccounting: { style: "currency", currency: "USD", currencySign: "accounting" },
20
+ runtimeMoney: { style: "currency", currencyDisplay: "code" },
21
+ decimalFixed: { style: "decimal", minimumFractionDigits: 2, maximumFractionDigits: 2 },
22
+ compactShort: { notation: "compact", compactDisplay: "short" },
23
+ compactLong: { notation: "compact", compactDisplay: "long" },
24
+ unitDistance: { style: "unit", unit: "kilometer", unitDisplay: "short" },
25
+ signAlways: { signDisplay: "always", maximumFractionDigits: 0 },
26
+ roundingStrip: {
27
+ minimumFractionDigits: 2,
28
+ maximumFractionDigits: 2,
29
+ trailingZeroDisplay: "stripIfInteger",
30
+ },
31
+ engineering: { notation: "engineering" },
32
+ scientific: { notation: "scientific" },
33
+ noCurrency: { style: "currency", currencyDisplay: "code" } as any,
34
+ },
35
+ date: {
36
+ short: { year: "2-digit", month: "numeric", day: "numeric" },
37
+ numeric: { year: "numeric", month: "2-digit", day: "2-digit" },
38
+ weekday: { weekday: "long", year: "numeric", month: "long", day: "numeric" },
39
+ fullStyle: { dateStyle: "full" },
40
+ arabicNumeric: { year: "numeric", month: "2-digit", day: "2-digit", numberingSystem: "arab" },
41
+ },
42
+ time: {
43
+ short: { hour: "numeric", minute: "2-digit" },
44
+ seconds: { hour: "numeric", minute: "2-digit", second: "2-digit" },
45
+ event: {
46
+ hour: "numeric",
47
+ minute: "2-digit",
48
+ timeZone: "UTC",
49
+ },
50
+ fullStyle: { timeStyle: "full", timeZone: "UTC" },
51
+ period: { hour: "numeric", dayPeriod: "long", hour12: true, timeZone: "UTC" },
52
+ },
53
+ relative: {
54
+ short: { numeric: "auto", style: "short" },
55
+ },
56
+ dateTimeRange: {
57
+ event: {
58
+ year: "numeric",
59
+ month: "short",
60
+ day: "numeric",
61
+ hour: "numeric",
62
+ minute: "2-digit",
63
+ },
64
+ fullStyle: {
65
+ dateStyle: "full",
66
+ timeStyle: "short",
67
+ timeZone: "UTC",
68
+ },
69
+ },
70
+ },
71
+ segments: {
72
+ "platform-web": {
73
+ conditions: [{ attribute: "platform", operator: "equals", value: "web" }],
74
+ },
75
+ },
76
+ messages: {
77
+ greeting: {
78
+ overrides: [
79
+ {
80
+ key: "platform-web",
81
+ segments: "platform-web",
82
+ translation: "Hello web {name}",
83
+ },
84
+ ],
85
+ },
86
+ total: {},
87
+ namedTotal: {},
88
+ skeletonTotal: {},
89
+ fallbackTotal: {},
90
+ symbolTotal: {},
91
+ codeTotal: {},
92
+ accountingTotal: {},
93
+ compactTotal: {},
94
+ eventTime: {},
95
+ dateFormats: {},
96
+ timeFormats: {},
97
+ featureGate: {
98
+ overrides: [
99
+ {
100
+ key: "feature-new-checkout",
101
+ conditions: { feature: "new-checkout", operator: "isEnabled" } as any,
102
+ translation: "Feature enabled",
103
+ },
104
+ ],
105
+ },
106
+ experimentGate: {
107
+ overrides: [
108
+ {
109
+ key: "experiment-checkout-b",
110
+ conditions: { experiment: "checkout-copy", operator: "hasVariation", value: "b" } as any,
111
+ translation: "Experiment B",
112
+ },
113
+ ],
114
+ },
115
+ richTerms: {},
116
+ richPromo: {
117
+ overrides: [
118
+ {
119
+ key: "promo-web",
120
+ segments: "platform-web",
121
+ translation:
122
+ "Web <strong>{product}</strong> costs <price>{amount, number, money}</price> today.",
123
+ },
124
+ ],
125
+ },
126
+ },
127
+ translations: {
128
+ greeting: "Hello {name}",
129
+ total: "Total: {amount, number, ::currency/USD}",
130
+ namedTotal: "Total: {amount, number, money}",
131
+ skeletonTotal: "Total: {amount, number, ::currency/GBP}",
132
+ fallbackTotal: "Total: {amount, number, noCurrency}",
133
+ symbolTotal: "Total: {amount, number, moneySymbol}",
134
+ codeTotal: "Total: {amount, number, moneyCode}",
135
+ accountingTotal: "Total: {amount, number, moneyAccounting}",
136
+ compactTotal: "Total: {currency}{amount, number, decimalFixed}",
137
+ eventTime: "Starts at {startsAt, time, event}",
138
+ dateFormats: "Numeric: {startsAt, date, numeric}; weekday: {startsAt, date, weekday}",
139
+ timeFormats: "Short: {startsAt, time, short}; seconds: {startsAt, time, seconds}",
140
+ featureGate: "Feature disabled",
141
+ experimentGate: "Experiment default",
142
+ richTerms: "Read our <link>terms</link> for <strong>{product}</strong>.",
143
+ richPromo: "Default <strong>{product}</strong> costs <price>{amount, number, money}</price>.",
144
+ },
145
+ };
146
+
147
+ describe("createMessagevisor", function () {
148
+ let consoleInfoSpy: jest.SpyInstance;
149
+ let consoleWarnSpy: jest.SpyInstance;
150
+ let consoleErrorSpy: jest.SpyInstance;
151
+ let consoleDebugSpy: jest.SpyInstance;
152
+
153
+ beforeEach(function () {
154
+ consoleInfoSpy = jest.spyOn(console, "info").mockImplementation(function () {});
155
+ consoleWarnSpy = jest.spyOn(console, "warn").mockImplementation(function () {});
156
+ consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(function () {});
157
+ consoleDebugSpy = jest.spyOn(console, "debug").mockImplementation(function () {});
158
+ });
159
+
160
+ afterEach(function () {
161
+ consoleInfoSpy.mockRestore();
162
+ consoleWarnSpy.mockRestore();
163
+ consoleErrorSpy.mockRestore();
164
+ consoleDebugSpy.mockRestore();
165
+ });
166
+
167
+ it("can be created without options and loaded with a datafile later", function () {
168
+ const m = createMessagevisor();
169
+ const direct = new Messagevisor();
170
+
171
+ expect(m.getLocale()).toEqual(null);
172
+ expect(direct.getLocale()).toEqual(null);
173
+ expect(() => m.getDatafile()).toThrow("Datafile not found: no locale is set");
174
+
175
+ m.setDatafile(datafile);
176
+ expect(m.getLocale()).toEqual("en-US");
177
+ expect(m.translate("greeting", { name: "Ada" })).toEqual("Hello {name}");
178
+ });
179
+
180
+ it("reports missing locale before a datafile or locale is available", function () {
181
+ const diagnostics: any[] = [];
182
+ const m = createMessagevisor({
183
+ logLevel: "error",
184
+ onDiagnostic: (diagnostic) => diagnostics.push(diagnostic),
185
+ });
186
+
187
+ expect(() => m.translate("greeting")).toThrow("Locale not set");
188
+ expect(() => m.formatNumber(12)).toThrow("Locale not set");
189
+
190
+ expect(diagnostics).toEqual(
191
+ expect.arrayContaining([
192
+ expect.objectContaining({
193
+ code: "missing_locale",
194
+ message: "Locale not set",
195
+ locale: null,
196
+ }),
197
+ ]),
198
+ );
199
+
200
+ m.setDatafile(datafile);
201
+
202
+ expect(m.getLocale()).toEqual("en-US");
203
+ expect(m.translate("greeting", { name: "Ada" })).toEqual("Hello {name}");
204
+ expect(m.formatNumber(12, "decimalFixed")).toEqual("12.00");
205
+ });
206
+
207
+ it("uses per-call locale without mutating the active instance locale", function () {
208
+ const diagnostics: any[] = [];
209
+ const localeEvents: any[] = [];
210
+ const changeEvents: any[] = [];
211
+ const formatPayloads: any[] = [];
212
+ const transformPayloads: any[] = [];
213
+ const m = createMessagevisor({
214
+ datafile,
215
+ logLevel: "debug",
216
+ onDiagnostic: (diagnostic) => diagnostics.push(diagnostic),
217
+ modules: [
218
+ {
219
+ name: "capture",
220
+ format: (payload) => {
221
+ formatPayloads.push(payload);
222
+ },
223
+ transform: (payload) => {
224
+ transformPayloads.push(payload);
225
+ },
226
+ },
227
+ ],
228
+ });
229
+
230
+ m.setDatafile({
231
+ ...datafile,
232
+ locale: "nl-NL",
233
+ revision: "2",
234
+ messages: {
235
+ greeting: {
236
+ meta: { locale: "nl-NL" },
237
+ deprecated: true,
238
+ deprecationWarning: "Use greeting.new",
239
+ overrides: [
240
+ {
241
+ key: "platform-web",
242
+ segments: "platform-web",
243
+ translation: "Hallo web {name}",
244
+ },
245
+ ],
246
+ },
247
+ },
248
+ translations: {
249
+ greeting: "Hallo {name}",
250
+ },
251
+ });
252
+ m.on("locale_set", (event) => localeEvents.push(event));
253
+ m.on("change", (event) => changeEvents.push(event));
254
+
255
+ expect(m.getLocale()).toEqual("en-US");
256
+ expect(m.translate("greeting", { name: "Ada" }, { locale: "nl-NL" })).toEqual("Hallo {name}");
257
+ expect(
258
+ m.translate("greeting", { name: "Ada" }, { locale: "nl-NL", context: { platform: "web" } }),
259
+ ).toEqual("Hallo web {name}");
260
+ expect(m.formatMessage("Los bericht", {}, { locale: "nl-NL" })).toEqual("Los bericht");
261
+ expect(m.getRawTranslation("greeting", { locale: "nl-NL" })).toEqual("Hallo {name}");
262
+ expect(m.translate("missing.key", undefined, { locale: "nl-NL" })).toEqual("missing.key");
263
+
264
+ expect(m.getLocale()).toEqual("en-US");
265
+ expect(m.getSnapshot().locale).toEqual("en-US");
266
+ expect(localeEvents).toEqual([]);
267
+ expect(changeEvents).toEqual([]);
268
+ expect(
269
+ diagnostics.some(
270
+ (diagnostic) =>
271
+ diagnostic.code === "message_override_matched" && diagnostic.locale === "nl-NL",
272
+ ),
273
+ ).toEqual(true);
274
+ expect(
275
+ diagnostics.some(
276
+ (diagnostic) => diagnostic.code === "missing_translation" && diagnostic.locale === "nl-NL",
277
+ ),
278
+ ).toEqual(true);
279
+ expect(
280
+ diagnostics.some(
281
+ (diagnostic) =>
282
+ diagnostic.code === "deprecated_message" &&
283
+ diagnostic.locale === "nl-NL" &&
284
+ diagnostic.messageKey === "greeting",
285
+ ),
286
+ ).toEqual(true);
287
+ expect(formatPayloads.some((payload) => payload.locale === "nl-NL")).toEqual(true);
288
+ expect(
289
+ formatPayloads.some(
290
+ (payload) => payload.locale === "nl-NL" && payload.meta?.locale === "nl-NL",
291
+ ),
292
+ ).toEqual(true);
293
+ expect(transformPayloads.some((payload) => payload.locale === "nl-NL")).toEqual(true);
294
+ });
295
+
296
+ it("uses per-call locale for defaults and direct formatters before datafiles arrive", function () {
297
+ const cache = createMessagevisorCache();
298
+ const m = createMessagevisor({
299
+ locale: "en-US",
300
+ defaultTranslations: {
301
+ "nl-NL": {
302
+ greeting: "Hallo uit defaults",
303
+ },
304
+ },
305
+ defaultFormats: {
306
+ "en-US": {
307
+ number: {
308
+ short: { minimumFractionDigits: 1, maximumFractionDigits: 1 },
309
+ },
310
+ },
311
+ "nl-NL": {
312
+ number: {
313
+ short: { minimumFractionDigits: 1, maximumFractionDigits: 1 },
314
+ },
315
+ date: {
316
+ short: { year: "numeric", month: "long", day: "numeric" },
317
+ },
318
+ time: {
319
+ short: { hour: "2-digit", minute: "2-digit", hour12: false },
320
+ },
321
+ relative: {
322
+ auto: { numeric: "auto", style: "long" },
323
+ },
324
+ },
325
+ },
326
+ cache,
327
+ logLevel: "fatal",
328
+ });
329
+
330
+ expect(m.translate("greeting", undefined, { locale: "nl-NL" })).toEqual("Hallo uit defaults");
331
+ expect(m.formatNumber(1200, "short")).toEqual("1,200.0");
332
+ expect(m.formatNumber(1200, "short", { locale: "nl-NL" })).toEqual("1.200,0");
333
+ expect(m.formatDate("2026-05-12T08:30:00Z", "short", { locale: "nl-NL" })).toEqual(
334
+ "12 mei 2026",
335
+ );
336
+ expect(
337
+ m.formatTime("2026-05-12T08:30:00Z", "short", { locale: "nl-NL", timeZone: "UTC" }),
338
+ ).toEqual("08:30");
339
+ expect(m.formatRelativeTime(-1, "day", "auto", { locale: "nl-NL" })).toEqual("gisteren");
340
+ expect(m.formatPlural(0, { locale: "ar" })).toEqual("zero");
341
+ expect(m.formatList(["A", "B"], { locale: "nl-NL" })).toContain(" en ");
342
+ expect(m.formatDisplayName("NL", { locale: "nl-NL", type: "region" })).toEqual("Nederland");
343
+ expect(Object.keys(cache.numberFormat)).toHaveLength(2);
344
+ expect(m.getLocale()).toEqual("en-US");
345
+ });
346
+
347
+ it("logs SDK initialization by default and supports log overrides", function () {
348
+ consoleInfoSpy.mockClear();
349
+ createMessagevisor();
350
+
351
+ expect(consoleInfoSpy).toHaveBeenCalledWith(
352
+ "[Messagevisor]",
353
+ "SDK initialized",
354
+ expect.objectContaining({ code: "sdk_initialized", level: "info" }),
355
+ );
356
+
357
+ consoleInfoSpy.mockClear();
358
+ createMessagevisor({ logLevel: "fatal" });
359
+ expect(consoleInfoSpy).not.toHaveBeenCalled();
360
+
361
+ const diagnostics: any[] = [];
362
+ createMessagevisor({
363
+ onDiagnostic: (diagnostic) => diagnostics.push(diagnostic),
364
+ });
365
+
366
+ expect(diagnostics).toEqual([
367
+ expect.objectContaining({
368
+ code: "sdk_initialized",
369
+ level: "info",
370
+ message: "SDK initialized",
371
+ }),
372
+ ]);
373
+ expect(consoleInfoSpy).not.toHaveBeenCalled();
374
+ });
375
+
376
+ it("prints unhandled diagnostics with the Messagevisor prefix", function () {
377
+ const m = createMessagevisor({ datafile });
378
+
379
+ consoleErrorSpy.mockClear();
380
+ m.getRawTranslation("missing.message");
381
+
382
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
383
+ "[Messagevisor]",
384
+ "Missing translation",
385
+ expect.objectContaining({
386
+ code: "missing_translation",
387
+ level: "error",
388
+ locale: "en-US",
389
+ messageKey: "missing.message",
390
+ source: "translation",
391
+ }),
392
+ );
393
+
394
+ const handledDiagnostics: any[] = [];
395
+ const handled = createMessagevisor({
396
+ datafile,
397
+ onDiagnostic: (diagnostic) => handledDiagnostics.push(diagnostic),
398
+ });
399
+
400
+ consoleErrorSpy.mockClear();
401
+ handled.getRawTranslation("missing.message");
402
+
403
+ expect(handledDiagnostics).toEqual([
404
+ expect.objectContaining({ code: "sdk_initialized", level: "info" }),
405
+ expect.objectContaining({
406
+ code: "missing_translation",
407
+ level: "error",
408
+ message: "Missing translation",
409
+ locale: "en-US",
410
+ messageKey: "missing.message",
411
+ source: "translation",
412
+ }),
413
+ ]);
414
+ expect(consoleErrorSpy).not.toHaveBeenCalled();
415
+ });
416
+
417
+ it("prints unhandled warnings with the Messagevisor prefix", function () {
418
+ const originalListFormat = (Intl as any).ListFormat;
419
+ const m = createMessagevisor({ datafile });
420
+
421
+ try {
422
+ (Intl as any).ListFormat = undefined;
423
+ consoleWarnSpy.mockClear();
424
+
425
+ expect(m.formatList(["A", "B"])).toEqual("A, B");
426
+ expect(consoleWarnSpy).toHaveBeenCalledWith(
427
+ "[Messagevisor]",
428
+ "Intl.ListFormat is not available in this environment.",
429
+ expect.objectContaining({ code: "unsupported_formatter", level: "warn" }),
430
+ );
431
+
432
+ const handledWarnings: any[] = [];
433
+ const handled = createMessagevisor({
434
+ datafile,
435
+ onDiagnostic: (diagnostic) => handledWarnings.push(diagnostic),
436
+ });
437
+
438
+ consoleWarnSpy.mockClear();
439
+ handled.formatList(["A", "B"]);
440
+
441
+ expect(handledWarnings).toEqual([
442
+ expect.objectContaining({ code: "sdk_initialized", level: "info" }),
443
+ expect.objectContaining({ code: "unsupported_formatter", level: "warn" }),
444
+ ]);
445
+ expect(consoleWarnSpy).not.toHaveBeenCalled();
446
+ } finally {
447
+ (Intl as any).ListFormat = originalListFormat;
448
+ }
449
+ });
450
+
451
+ it("emits warning diagnostics when deprecated messages are evaluated", function () {
452
+ const deprecatedDatafile: DatafileContent = {
453
+ ...datafile,
454
+ messages: {
455
+ ...datafile.messages,
456
+ greeting: {
457
+ ...datafile.messages.greeting,
458
+ deprecated: true,
459
+ deprecationWarning: "Use welcome.title instead.",
460
+ },
461
+ },
462
+ };
463
+ const m = createMessagevisor({ datafile: deprecatedDatafile });
464
+
465
+ consoleWarnSpy.mockClear();
466
+ expect(m.getRawTranslation("greeting")).toEqual("Hello {name}");
467
+ expect(consoleWarnSpy).toHaveBeenCalledWith(
468
+ "[Messagevisor]",
469
+ "Deprecated message evaluated",
470
+ expect.objectContaining({
471
+ code: "deprecated_message",
472
+ level: "warn",
473
+ message: "Deprecated message evaluated",
474
+ locale: "en-US",
475
+ messageKey: "greeting",
476
+ deprecationWarning: "Use welcome.title instead.",
477
+ source: "translation",
478
+ }),
479
+ );
480
+
481
+ const diagnostics: any[] = [];
482
+ const handled = createMessagevisor({
483
+ datafile: deprecatedDatafile,
484
+ logLevel: "warn",
485
+ onDiagnostic: (diagnostic) => diagnostics.push(diagnostic),
486
+ });
487
+
488
+ consoleWarnSpy.mockClear();
489
+ expect(handled.translate("greeting", { name: "Ada" })).toEqual("Hello {name}");
490
+ expect(diagnostics).toEqual([
491
+ expect.objectContaining({
492
+ code: "deprecated_message",
493
+ level: "warn",
494
+ message: "Deprecated message evaluated",
495
+ locale: "en-US",
496
+ messageKey: "greeting",
497
+ deprecationWarning: "Use welcome.title instead.",
498
+ source: "translation",
499
+ }),
500
+ ]);
501
+ expect(consoleWarnSpy).not.toHaveBeenCalled();
502
+ });
503
+
504
+ it("filters diagnostics by log level", function () {
505
+ const diagnostics: any[] = [];
506
+ const m = createMessagevisor({
507
+ datafile,
508
+ logLevel: "warn",
509
+ onDiagnostic: (diagnostic) => diagnostics.push(diagnostic),
510
+ });
511
+
512
+ expect(diagnostics).toEqual([]);
513
+
514
+ m.getRawTranslation("missing.message");
515
+
516
+ expect(diagnostics).toEqual([
517
+ expect.objectContaining({ code: "missing_translation", level: "error" }),
518
+ ]);
519
+
520
+ const quietDiagnostics: any[] = [];
521
+ const quiet = createMessagevisor({
522
+ datafile,
523
+ logLevel: "fatal",
524
+ onDiagnostic: (diagnostic) => quietDiagnostics.push(diagnostic),
525
+ });
526
+
527
+ quiet.getRawTranslation("missing.message");
528
+ expect(quietDiagnostics).toEqual([]);
529
+ });
530
+
531
+ it("lets modules subscribe to diagnostics with their own log level", function () {
532
+ const moduleDiagnostics: any[] = [];
533
+ const rootDiagnostics: any[] = [];
534
+ const m = createMessagevisor({
535
+ datafile,
536
+ logLevel: "fatal",
537
+ onDiagnostic: (diagnostic) => rootDiagnostics.push(diagnostic),
538
+ modules: [
539
+ {
540
+ name: "observer",
541
+ setup({ onDiagnostic }) {
542
+ onDiagnostic((diagnostic) => moduleDiagnostics.push(diagnostic), {
543
+ logLevel: "error",
544
+ });
545
+ },
546
+ },
547
+ ],
548
+ });
549
+
550
+ m.getRawTranslation("missing.message");
551
+
552
+ expect(rootDiagnostics).toEqual([]);
553
+ expect(moduleDiagnostics).toEqual([
554
+ expect.objectContaining({
555
+ code: "missing_translation",
556
+ level: "error",
557
+ messageKey: "missing.message",
558
+ }),
559
+ ]);
560
+ });
561
+
562
+ it("lets modules read datafile revisions from the setup API", function () {
563
+ const revisions: string[] = [];
564
+ const m = createMessagevisor({ datafile });
565
+
566
+ m.addModule({
567
+ name: "revision-observer",
568
+ setup({ getRevision }) {
569
+ revisions.push(getRevision());
570
+ revisions.push(getRevision("en-US"));
571
+ },
572
+ });
573
+
574
+ expect(revisions).toEqual(["1", "1"]);
575
+ });
576
+
577
+ it("lets modules emit custom diagnostics without receiving their own events", function () {
578
+ const observerDiagnostics: any[] = [];
579
+ const emitterDiagnostics: any[] = [];
580
+ const rootDiagnostics: any[] = [];
581
+
582
+ createMessagevisor({
583
+ onDiagnostic: (diagnostic) => rootDiagnostics.push(diagnostic),
584
+ modules: [
585
+ {
586
+ name: "observer",
587
+ setup({ onDiagnostic }) {
588
+ onDiagnostic((diagnostic) => observerDiagnostics.push(diagnostic), {
589
+ logLevel: "debug",
590
+ });
591
+ },
592
+ },
593
+ {
594
+ name: "datadog",
595
+ setup({ onDiagnostic, reportDiagnostic }) {
596
+ onDiagnostic((diagnostic) => emitterDiagnostics.push(diagnostic), {
597
+ logLevel: "debug",
598
+ });
599
+ reportDiagnostic({
600
+ level: "warn",
601
+ code: "datadog_transport_failed",
602
+ message: "Datadog transport failed",
603
+ });
604
+ },
605
+ },
606
+ ],
607
+ });
608
+
609
+ expect(observerDiagnostics).toEqual([
610
+ expect.objectContaining({
611
+ level: "warn",
612
+ code: "datadog_transport_failed",
613
+ module: "datadog",
614
+ }),
615
+ expect.objectContaining({ code: "sdk_initialized", level: "info" }),
616
+ ]);
617
+ expect(emitterDiagnostics).toEqual([
618
+ expect.objectContaining({ code: "sdk_initialized", level: "info" }),
619
+ ]);
620
+ expect(rootDiagnostics).toEqual([
621
+ expect.objectContaining({
622
+ level: "warn",
623
+ code: "datadog_transport_failed",
624
+ module: "datadog",
625
+ }),
626
+ expect.objectContaining({ code: "sdk_initialized", level: "info" }),
627
+ ]);
628
+ });
629
+
630
+ it("clears module diagnostic subscriptions when modules are removed", function () {
631
+ const diagnostics: any[] = [];
632
+ const m = createMessagevisor({
633
+ datafile,
634
+ logLevel: "fatal",
635
+ modules: [
636
+ {
637
+ name: "observer",
638
+ setup({ onDiagnostic }) {
639
+ onDiagnostic((diagnostic) => diagnostics.push(diagnostic), {
640
+ logLevel: "error",
641
+ });
642
+ },
643
+ },
644
+ ],
645
+ });
646
+
647
+ m.getRawTranslation("first.missing");
648
+ m.removeModule("observer");
649
+ m.getRawTranslation("second.missing");
650
+
651
+ expect(diagnostics).toEqual([
652
+ expect.objectContaining({ code: "missing_translation", messageKey: "first.missing" }),
653
+ ]);
654
+ });
655
+
656
+ it("supports manually unsubscribing module diagnostic subscriptions", function () {
657
+ const diagnostics: any[] = [];
658
+ let unsubscribe: (() => void) | undefined;
659
+ const m = createMessagevisor({
660
+ datafile,
661
+ logLevel: "fatal",
662
+ modules: [
663
+ {
664
+ name: "observer",
665
+ setup({ onDiagnostic }) {
666
+ unsubscribe = onDiagnostic((diagnostic) => diagnostics.push(diagnostic), {
667
+ logLevel: "error",
668
+ });
669
+ },
670
+ },
671
+ ],
672
+ });
673
+
674
+ unsubscribe?.();
675
+ unsubscribe?.();
676
+ m.getRawTranslation("missing.message");
677
+
678
+ expect(diagnostics).toEqual([]);
679
+ });
680
+
681
+ it("clears module diagnostic subscriptions after close", async function () {
682
+ const diagnostics: any[] = [];
683
+ const m = createMessagevisor({
684
+ datafile,
685
+ logLevel: "fatal",
686
+ modules: [
687
+ {
688
+ name: "observer",
689
+ setup({ onDiagnostic }) {
690
+ onDiagnostic((diagnostic) => diagnostics.push(diagnostic), {
691
+ logLevel: "error",
692
+ });
693
+ },
694
+ },
695
+ ],
696
+ });
697
+
698
+ await m.close();
699
+ m.getRawTranslation("missing.after.close");
700
+
701
+ expect(diagnostics).toEqual([]);
702
+ });
703
+
704
+ it("emits error events for module diagnostics", function () {
705
+ const errors: any[] = [];
706
+ const m = createMessagevisor({ logLevel: "fatal" });
707
+
708
+ m.on("error", (event) => errors.push(event));
709
+ m.addModule({
710
+ name: "datadog",
711
+ setup({ reportDiagnostic }) {
712
+ reportDiagnostic({
713
+ level: "error",
714
+ code: "datadog_transport_failed",
715
+ message: "Datadog transport failed",
716
+ });
717
+ },
718
+ });
719
+
720
+ expect(errors).toEqual([
721
+ expect.objectContaining({
722
+ type: "error",
723
+ diagnostic: expect.objectContaining({
724
+ level: "error",
725
+ code: "datadog_transport_failed",
726
+ module: "datadog",
727
+ }),
728
+ }),
729
+ ]);
730
+ });
731
+
732
+ it("returns context, currency, and timeZone", function () {
733
+ const m = createMessagevisor({
734
+ context: { platform: "web" },
735
+ currency: "EUR",
736
+ timeZone: "Europe/Amsterdam",
737
+ });
738
+
739
+ expect(m.getContext()).toEqual({ platform: "web" });
740
+ expect(m.getCurrency()).toEqual("EUR");
741
+ expect(m.getTimeZone()).toEqual("Europe/Amsterdam");
742
+
743
+ m.setContext({ plan: "pro" });
744
+ m.setCurrency("USD");
745
+ m.setTimeZone("UTC");
746
+
747
+ expect(m.getContext()).toEqual({ platform: "web", plan: "pro" });
748
+ expect(m.getCurrency()).toEqual("USD");
749
+ expect(m.getTimeZone()).toEqual("UTC");
750
+ });
751
+
752
+ it("replaces context when requested", function () {
753
+ const m = createMessagevisor({
754
+ context: { platform: "web" },
755
+ });
756
+
757
+ m.setContext({ plan: "pro" }, true);
758
+
759
+ expect(m.getContext()).toEqual({ plan: "pro" });
760
+ });
761
+
762
+ it("shallow merges context without deep merging nested values", function () {
763
+ const m = createMessagevisor({
764
+ context: {
765
+ account: {
766
+ plan: "free",
767
+ region: "eu",
768
+ },
769
+ platform: "web",
770
+ },
771
+ });
772
+
773
+ m.setContext({
774
+ account: {
775
+ plan: "pro",
776
+ },
777
+ });
778
+
779
+ expect(m.getContext()).toEqual({
780
+ account: {
781
+ plan: "pro",
782
+ },
783
+ platform: "web",
784
+ });
785
+ });
786
+
787
+ it("returns isolated snapshots of observable SDK state", function () {
788
+ const m = createMessagevisor({
789
+ datafile,
790
+ context: { platform: "web" },
791
+ currency: "EUR",
792
+ timeZone: "Europe/Amsterdam",
793
+ });
794
+
795
+ const snapshot = m.getSnapshot();
796
+ snapshot.context.platform = "mobile";
797
+ snapshot.datafileLocales.push("nl-NL");
798
+ snapshot.datafileRevisionsByLocale["nl-NL"] = "2";
799
+
800
+ expect(snapshot).toEqual({
801
+ version: 1,
802
+ locale: "en-US",
803
+ direction: "ltr",
804
+ context: { platform: "mobile" },
805
+ currency: "EUR",
806
+ timeZone: "Europe/Amsterdam",
807
+ datafileLocales: ["en-US", "nl-NL"],
808
+ datafileRevisionsByLocale: { "en-US": "1", "nl-NL": "2" },
809
+ });
810
+ expect(m.getSnapshot()).toEqual({
811
+ version: 1,
812
+ locale: "en-US",
813
+ direction: "ltr",
814
+ context: { platform: "web" },
815
+ currency: "EUR",
816
+ timeZone: "Europe/Amsterdam",
817
+ datafileLocales: ["en-US"],
818
+ datafileRevisionsByLocale: { "en-US": "1" },
819
+ });
820
+ });
821
+
822
+ it("notifies subscribers when SDK state changes", function () {
823
+ const m = createMessagevisor({ datafile });
824
+ const changes: string[] = [];
825
+
826
+ const unsubscribe = m.subscribe(() => {
827
+ changes.push(`${m.getSnapshot().version}:${m.getLocale()}`);
828
+ });
829
+
830
+ m.setContext({ platform: "web" });
831
+ m.setCurrency("EUR");
832
+ m.setTimeZone("UTC");
833
+ m.setDatafile({
834
+ ...datafile,
835
+ locale: "nl-NL",
836
+ revision: "2",
837
+ translations: { greeting: "Hallo {name}" },
838
+ messages: { greeting: {} },
839
+ });
840
+ m.setLocale("nl-NL");
841
+ unsubscribe();
842
+ m.setCurrency("USD");
843
+
844
+ expect(changes).toEqual(["2:en-US", "3:en-US", "4:en-US", "5:en-US", "6:nl-NL"]);
845
+ });
846
+
847
+ it("accepts JSON string datafiles in constructor and setDatafile", function () {
848
+ const m = createMessagevisor({ datafile: JSON.stringify(datafile) });
849
+
850
+ expect(m.getLocale()).toEqual("en-US");
851
+ expect(m.getRevision()).toEqual("1");
852
+
853
+ const datafileEvents: any[] = [];
854
+ m.on("datafile_set", (event) => datafileEvents.push(event));
855
+
856
+ m.setDatafile(
857
+ JSON.stringify({
858
+ ...datafile,
859
+ locale: "nl-NL",
860
+ revision: "2",
861
+ translations: { greeting: "Hallo {name}" },
862
+ messages: { greeting: {} },
863
+ }),
864
+ );
865
+
866
+ expect(m.getDatafile("nl-NL").revision).toEqual("2");
867
+ expect(datafileEvents[0].datafile.locale).toEqual("nl-NL");
868
+
869
+ m.setDatafile(
870
+ JSON.stringify({
871
+ ...datafile,
872
+ locale: "nl-NL",
873
+ revision: "3",
874
+ translations: { mergedOnly: "Merged only" },
875
+ messages: { mergedOnly: {} },
876
+ }),
877
+ );
878
+
879
+ expect(m.getDatafile("nl-NL").revision).toEqual("3");
880
+ expect(m.getDatafile("nl-NL").translations.greeting).toEqual("Hallo {name}");
881
+ expect(m.getDatafile("nl-NL").translations.mergedOnly).toEqual("Merged only");
882
+ });
883
+
884
+ it("reports invalid string datafiles without changing SDK state", function () {
885
+ const m = createMessagevisor({ datafile });
886
+ const events: any[] = [];
887
+ const errors: any[] = [];
888
+
889
+ m.on("change", (event) => events.push(event));
890
+ m.on("datafile_set", (event) => events.push(event));
891
+ m.on("error", (event) => errors.push(event));
892
+
893
+ consoleErrorSpy.mockClear();
894
+ m.setDatafile("{not json");
895
+
896
+ expect(m.getLocale()).toEqual("en-US");
897
+ expect(m.getRevision()).toEqual("1");
898
+ expect(events).toEqual([]);
899
+ expect(errors).toHaveLength(1);
900
+ expect(errors[0].version).toEqual(m.getSnapshot().version);
901
+ expect(errors[0].diagnostic).toEqual(
902
+ expect.objectContaining({ code: "invalid_datafile", level: "error" }),
903
+ );
904
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
905
+ "[Messagevisor]",
906
+ "could not parse datafile",
907
+ expect.objectContaining({ code: "invalid_datafile", level: "error" }),
908
+ );
909
+
910
+ m.setDatafile(JSON.stringify({ revision: "2" }));
911
+
912
+ expect(m.getRevision()).toEqual("1");
913
+ expect(events).toEqual([]);
914
+ expect(errors).toHaveLength(2);
915
+ expect(errors[1].diagnostic).toEqual(
916
+ expect.objectContaining({ code: "invalid_datafile", level: "error" }),
917
+ );
918
+ });
919
+
920
+ it("exposes locale direction from the active or requested datafile", function () {
921
+ const m = createMessagevisor({ datafile });
922
+
923
+ m.setDatafile({
924
+ ...datafile,
925
+ locale: "ar-SA",
926
+ direction: "rtl",
927
+ revision: "2",
928
+ translations: { greeting: "مرحبا {name}" },
929
+ messages: { greeting: {} },
930
+ });
931
+
932
+ expect(m.getDirection()).toEqual("ltr");
933
+ expect(m.getDirection("ar-SA")).toEqual("rtl");
934
+
935
+ m.setLocale("ar-SA");
936
+
937
+ expect(m.getDirection()).toEqual("rtl");
938
+ expect(m.getSnapshot().direction).toEqual("rtl");
939
+ });
940
+
941
+ it("emits detailed events with previous and next snapshots", function () {
942
+ const m = createMessagevisor({ datafile });
943
+ const localeEvents: any[] = [];
944
+ const changeEvents: any[] = [];
945
+
946
+ m.setDatafile({
947
+ ...datafile,
948
+ locale: "nl-NL",
949
+ revision: "2",
950
+ translations: { greeting: "Hallo {name}" },
951
+ messages: { greeting: {} },
952
+ });
953
+ m.on("locale_set", (event) => localeEvents.push(event));
954
+ m.on("change", (event) => changeEvents.push(event));
955
+
956
+ m.setLocale("nl-NL");
957
+
958
+ expect(localeEvents).toHaveLength(1);
959
+ expect(changeEvents).toHaveLength(1);
960
+ expect(localeEvents[0].type).toEqual("locale_set");
961
+ expect(changeEvents[0].type).toEqual("change");
962
+ expect(localeEvents[0].version).toEqual(changeEvents[0].version);
963
+ expect(localeEvents[0].previousLocale).toEqual("en-US");
964
+ expect(localeEvents[0].locale).toEqual("nl-NL");
965
+ expect(localeEvents[0].previousSnapshot.locale).toEqual("en-US");
966
+ expect(localeEvents[0].snapshot.locale).toEqual("nl-NL");
967
+ });
968
+
969
+ it("supports unsubscribing from detailed events", function () {
970
+ const m = createMessagevisor();
971
+ const events: string[] = [];
972
+ const unsubscribe = m.on("datafile_set", (event) => {
973
+ events.push(`${event.datafile?.locale}:${event.snapshot.version}`);
974
+ });
975
+
976
+ m.setDatafile(datafile);
977
+ unsubscribe();
978
+ m.setDatafile({ ...datafile, revision: "2" });
979
+
980
+ expect(events).toEqual(["en-US:1"]);
981
+ });
982
+
983
+ it("setDatafile stores a new locale when none exists yet", function () {
984
+ const m = createMessagevisor();
985
+
986
+ m.setDatafile(datafile);
987
+
988
+ expect(m.getLocale()).toEqual("en-US");
989
+ expect(m.translate("greeting", { name: "Ada" })).toEqual("Hello {name}");
990
+ expect(m.getRevision()).toEqual("1");
991
+ });
992
+
993
+ it("setDatafile merges segments, messages, and translations while replacing top-level fields", function () {
994
+ const m = createMessagevisor({ datafile });
995
+
996
+ m.setDatafile({
997
+ ...datafile,
998
+ revision: "2",
999
+ target: "mobile",
1000
+ formats: {
1001
+ number: {
1002
+ scientific: { notation: "scientific" },
1003
+ },
1004
+ },
1005
+ segments: {
1006
+ "platform-ios": {
1007
+ conditions: [{ attribute: "platform", operator: "equals", value: "ios" }],
1008
+ },
1009
+ "platform-web": {
1010
+ conditions: [{ attribute: "platform", operator: "equals", value: "browser" }],
1011
+ },
1012
+ },
1013
+ messages: {
1014
+ greeting: {},
1015
+ mergedOnly: {
1016
+ meta: {
1017
+ source: "merged",
1018
+ },
1019
+ },
1020
+ },
1021
+ translations: {
1022
+ greeting: "Merged hello {name}",
1023
+ mergedOnly: "Merged only",
1024
+ },
1025
+ });
1026
+
1027
+ expect(m.getRevision()).toEqual("2");
1028
+ expect(m.getDatafile().target).toEqual("mobile");
1029
+ expect(m.getDatafile().formats).toEqual({
1030
+ number: {
1031
+ scientific: { notation: "scientific" },
1032
+ },
1033
+ });
1034
+ expect(m.getDatafile().segments["platform-ios"]).toEqual({
1035
+ conditions: [{ attribute: "platform", operator: "equals", value: "ios" }],
1036
+ });
1037
+ expect(m.getDatafile().segments["platform-web"]).toEqual({
1038
+ conditions: [{ attribute: "platform", operator: "equals", value: "browser" }],
1039
+ });
1040
+ expect(m.getDatafile().messages["mergedOnly"]).toEqual({
1041
+ meta: {
1042
+ source: "merged",
1043
+ },
1044
+ });
1045
+ expect(m.translate("greeting", { name: "Ada" })).toEqual("Merged hello {name}");
1046
+ expect(m.translate("mergedOnly")).toEqual("Merged only");
1047
+ expect(m.translate("total", { amount: 12 })).toEqual("Total: {amount, number, ::currency/USD}");
1048
+ });
1049
+
1050
+ it("setDatafile preserves keys that are absent from the incoming locale datafile", function () {
1051
+ const m = createMessagevisor({ datafile });
1052
+
1053
+ m.setDatafile({
1054
+ schemaVersion: "1",
1055
+ messagevisorVersion: "0.0.1",
1056
+ revision: "2",
1057
+ target: "web",
1058
+ locale: "en-US",
1059
+ formats: undefined,
1060
+ segments: {},
1061
+ messages: {
1062
+ mergedOnly: {},
1063
+ },
1064
+ translations: {
1065
+ mergedOnly: "Merged only",
1066
+ },
1067
+ });
1068
+
1069
+ expect(m.translate("greeting", { name: "Ada" })).toEqual("Hello {name}");
1070
+ expect(m.translate("mergedOnly")).toEqual("Merged only");
1071
+ expect(m.getDatafile().segments["platform-web"]).toEqual({
1072
+ conditions: [{ attribute: "platform", operator: "equals", value: "web" }],
1073
+ });
1074
+ });
1075
+
1076
+ it("setDatafile replaces an existing locale datafile when requested", function () {
1077
+ const m = createMessagevisor({ datafile });
1078
+
1079
+ m.setDatafile(
1080
+ {
1081
+ schemaVersion: "1",
1082
+ messagevisorVersion: "0.0.1",
1083
+ revision: "2",
1084
+ target: "web",
1085
+ locale: "en-US",
1086
+ formats: undefined,
1087
+ segments: {},
1088
+ messages: {
1089
+ replacementOnly: {},
1090
+ },
1091
+ translations: {
1092
+ replacementOnly: "Replacement only",
1093
+ },
1094
+ },
1095
+ true,
1096
+ );
1097
+
1098
+ expect(m.getRevision()).toEqual("2");
1099
+ expect(m.getDatafile().translations.greeting).toBeUndefined();
1100
+ expect(m.getDatafile().segments["platform-web"]).toBeUndefined();
1101
+ expect(m.translate("replacementOnly")).toEqual("Replacement only");
1102
+ });
1103
+
1104
+ it("setDatafile supports loading a segment after a message override already exists", function () {
1105
+ const m = createMessagevisor({
1106
+ datafile: {
1107
+ ...datafile,
1108
+ segments: {},
1109
+ messages: {
1110
+ greeting: {
1111
+ overrides: [
1112
+ {
1113
+ key: "platform-web",
1114
+ segments: "platform-web",
1115
+ translation: "Hello web {name}",
1116
+ },
1117
+ ],
1118
+ },
1119
+ },
1120
+ translations: {
1121
+ greeting: "Hello {name}",
1122
+ },
1123
+ },
1124
+ context: { platform: "web" },
1125
+ });
1126
+
1127
+ expect(m.translate("greeting", { name: "Ada" })).toEqual("Hello {name}");
1128
+
1129
+ m.setDatafile({
1130
+ schemaVersion: "1",
1131
+ messagevisorVersion: "0.0.1",
1132
+ revision: "2",
1133
+ target: "web",
1134
+ locale: "en-US",
1135
+ formats: m.getDatafile().formats,
1136
+ segments: {
1137
+ "platform-web": {
1138
+ conditions: [{ attribute: "platform", operator: "equals", value: "web" }],
1139
+ },
1140
+ },
1141
+ messages: {},
1142
+ translations: {},
1143
+ });
1144
+
1145
+ expect(m.translate("greeting", { name: "Ada" })).toEqual("Hello web {name}");
1146
+ });
1147
+
1148
+ it("setDatafile reuses datafile_set and change events with the merged payload", function () {
1149
+ const m = createMessagevisor({ datafile });
1150
+ const datafileEvents: any[] = [];
1151
+ const changeEvents: any[] = [];
1152
+
1153
+ m.on("datafile_set", (event) => datafileEvents.push(event));
1154
+ m.on("change", (event) => changeEvents.push(event));
1155
+
1156
+ m.setDatafile({
1157
+ schemaVersion: "1",
1158
+ messagevisorVersion: "0.0.1",
1159
+ revision: "2",
1160
+ target: "mobile",
1161
+ locale: "en-US",
1162
+ formats: undefined,
1163
+ segments: {},
1164
+ messages: {
1165
+ mergedOnly: {},
1166
+ },
1167
+ translations: {
1168
+ mergedOnly: "Merged only",
1169
+ },
1170
+ });
1171
+
1172
+ expect(datafileEvents).toHaveLength(1);
1173
+ expect(changeEvents).toHaveLength(1);
1174
+ expect(datafileEvents[0].type).toEqual("datafile_set");
1175
+ expect(changeEvents[0].type).toEqual("change");
1176
+ expect(datafileEvents[0].datafile.revision).toEqual("2");
1177
+ expect(datafileEvents[0].datafile.target).toEqual("mobile");
1178
+ expect(datafileEvents[0].datafile.translations["greeting"]).toEqual("Hello {name}");
1179
+ expect(datafileEvents[0].datafile.translations["mergedOnly"]).toEqual("Merged only");
1180
+ expect(datafileEvents[0].snapshot.datafileRevisionsByLocale["en-US"]).toEqual("2");
1181
+ });
1182
+
1183
+ it("closes modules in reverse registration order and awaits async cleanup", async function () {
1184
+ const calls: string[] = [];
1185
+ const m = createMessagevisor({
1186
+ datafile,
1187
+ modules: [
1188
+ {
1189
+ name: "first",
1190
+ close() {
1191
+ calls.push("first");
1192
+ },
1193
+ },
1194
+ {
1195
+ name: "second",
1196
+ async close() {
1197
+ await Promise.resolve();
1198
+ calls.push("second");
1199
+ },
1200
+ },
1201
+ {
1202
+ name: "third",
1203
+ close() {
1204
+ calls.push("third");
1205
+ },
1206
+ },
1207
+ ],
1208
+ });
1209
+
1210
+ await m.close();
1211
+
1212
+ expect(calls).toEqual(["third", "second", "first"]);
1213
+ });
1214
+
1215
+ it("shares the same in-flight close promise and only closes modules once", async function () {
1216
+ const calls: string[] = [];
1217
+ const m = createMessagevisor({
1218
+ datafile,
1219
+ modules: [
1220
+ {
1221
+ name: "only",
1222
+ async close() {
1223
+ await Promise.resolve();
1224
+ calls.push("only");
1225
+ },
1226
+ },
1227
+ ],
1228
+ });
1229
+
1230
+ await Promise.all([m.close(), m.close(), m.close()]);
1231
+
1232
+ expect(calls).toEqual(["only"]);
1233
+
1234
+ await expect(m.close()).resolves.toBeUndefined();
1235
+ expect(calls).toEqual(["only"]);
1236
+ });
1237
+
1238
+ it("does not close modules that were removed before instance close", async function () {
1239
+ const calls: string[] = [];
1240
+ const m = createMessagevisor({
1241
+ datafile,
1242
+ modules: [
1243
+ {
1244
+ name: "stay",
1245
+ close() {
1246
+ calls.push("stay");
1247
+ },
1248
+ },
1249
+ {
1250
+ name: "remove",
1251
+ close() {
1252
+ calls.push("remove");
1253
+ },
1254
+ },
1255
+ ],
1256
+ });
1257
+
1258
+ m.removeModule("remove");
1259
+ await m.close();
1260
+
1261
+ expect(calls).toEqual(["stay"]);
1262
+ });
1263
+
1264
+ it("continues closing remaining modules and rejects with an aggregate error", async function () {
1265
+ const calls: string[] = [];
1266
+ const m = createMessagevisor({
1267
+ datafile,
1268
+ modules: [
1269
+ {
1270
+ name: "first",
1271
+ close() {
1272
+ calls.push("first");
1273
+ },
1274
+ },
1275
+ {
1276
+ name: "broken",
1277
+ close() {
1278
+ calls.push("broken");
1279
+ throw new Error("boom");
1280
+ },
1281
+ },
1282
+ {
1283
+ name: "last",
1284
+ close() {
1285
+ calls.push("last");
1286
+ },
1287
+ },
1288
+ ],
1289
+ });
1290
+
1291
+ await expect(m.close()).rejects.toThrow("One or more Messagevisor modules failed to close.");
1292
+ expect(calls).toEqual(["last", "broken", "first"]);
1293
+ });
1294
+
1295
+ it("clears listeners and modules after close", async function () {
1296
+ const changes: string[] = [];
1297
+ const events: string[] = [];
1298
+ const m = createMessagevisor({
1299
+ datafile,
1300
+ modules: [
1301
+ {
1302
+ name: "suffix",
1303
+ transform({ translation }) {
1304
+ return typeof translation === "string" ? `${translation}!` : undefined;
1305
+ },
1306
+ },
1307
+ ],
1308
+ });
1309
+
1310
+ const unsubscribe = m.subscribe(() => {
1311
+ changes.push("change");
1312
+ });
1313
+ const unsubscribeEvent = m.on("currency_set", () => {
1314
+ events.push("currency");
1315
+ });
1316
+
1317
+ await m.close();
1318
+
1319
+ unsubscribe();
1320
+ unsubscribeEvent();
1321
+
1322
+ expect(m.translate("greeting", { name: "Ada" })).toEqual("Hello {name}");
1323
+ expect(m.formatMessage("Hi {name}", { name: "Ada" })).toEqual("Hi {name}");
1324
+
1325
+ m.setContext({ platform: "web" });
1326
+ m.setCurrency("EUR");
1327
+ m.setTimeZone("UTC");
1328
+ m.setDatafile({ ...datafile, revision: "2" });
1329
+ m.setLocale("en-US");
1330
+ m.addModule({
1331
+ name: "noop",
1332
+ transform() {
1333
+ return "changed";
1334
+ },
1335
+ });
1336
+ m.removeModule("noop");
1337
+
1338
+ expect(changes).toEqual([]);
1339
+ expect(events).toEqual([]);
1340
+
1341
+ const lateUnsubscribe = m.subscribe(() => {
1342
+ changes.push("late");
1343
+ });
1344
+ const lateEventUnsubscribe = m.on("change", () => {
1345
+ events.push("late");
1346
+ });
1347
+ lateUnsubscribe();
1348
+ lateEventUnsubscribe();
1349
+
1350
+ expect(changes).toEqual([]);
1351
+ expect(events).toEqual([]);
1352
+ });
1353
+
1354
+ it("uses the datafile locale as the current locale", function () {
1355
+ const m = createMessagevisor({
1356
+ datafile,
1357
+ locale: "nl-NL",
1358
+ });
1359
+
1360
+ expect(m.getLocale()).toEqual("en-US");
1361
+ });
1362
+
1363
+ it("returns the revision for the active or requested locale datafile", function () {
1364
+ const m = createMessagevisor({ datafile });
1365
+
1366
+ m.setDatafile({
1367
+ ...datafile,
1368
+ locale: "nl-NL",
1369
+ revision: "2",
1370
+ translations: { greeting: "Hallo {name}" },
1371
+ messages: { greeting: {} },
1372
+ });
1373
+
1374
+ expect(m.getRevision()).toEqual("1");
1375
+ expect(m.getRevision("en-US")).toEqual("1");
1376
+ expect(m.getRevision("nl-NL")).toEqual("2");
1377
+
1378
+ m.setLocale("nl-NL");
1379
+
1380
+ expect(m.getRevision()).toEqual("2");
1381
+ });
1382
+
1383
+ it("throws when getting revision without a locale datafile", function () {
1384
+ const m = createMessagevisor();
1385
+
1386
+ expect(() => m.getRevision()).toThrow("Datafile not found: no locale is set");
1387
+ expect(() => m.getRevision("nl-NL")).toThrow("Datafile not found for locale: nl-NL");
1388
+ });
1389
+
1390
+ it("returns raw translations by default", function () {
1391
+ const m = createMessagevisor({ datafile });
1392
+ const translation: string = m.translate("greeting", { name: "Ada" });
1393
+ const aliasTranslation: string = m.t("greeting", { name: "Ada" });
1394
+ const rawTranslation: string = m.getRawTranslation("greeting");
1395
+ const richTranslation: string | RichNode | Array<string | RichNode> = m.translate<RichNode>(
1396
+ "richTerms",
1397
+ {
1398
+ link: (chunks) => ({ type: "link", children: chunks }),
1399
+ },
1400
+ );
1401
+
1402
+ expect(translation).toEqual("Hello {name}");
1403
+ expect(aliasTranslation).toEqual("Hello {name}");
1404
+ expect(rawTranslation).toEqual("Hello {name}");
1405
+ expect(richTranslation).toEqual("Read our <link>terms</link> for <strong>{product}</strong>.");
1406
+ expect((m as any).getTranslation).toEqual(undefined);
1407
+ expect((m as any).getTranslationMessage).toEqual(undefined);
1408
+ });
1409
+
1410
+ it("runs constructor modules for translations after formatting fallback", function () {
1411
+ const payloads: any[] = [];
1412
+ const m = createMessagevisor({
1413
+ datafile,
1414
+ modules: [
1415
+ {
1416
+ name: "suffix",
1417
+ transform(payload) {
1418
+ payloads.push(payload);
1419
+
1420
+ return `${payload.translation}!`;
1421
+ },
1422
+ },
1423
+ ],
1424
+ });
1425
+
1426
+ expect(m.translate("greeting", { name: "Ada" })).toEqual("Hello {name}!");
1427
+ expect(payloads).toHaveLength(1);
1428
+ expect(payloads[0]).toMatchObject({
1429
+ translation: "Hello {name}",
1430
+ locale: "en-US",
1431
+ source: "translation",
1432
+ messageKey: "greeting",
1433
+ });
1434
+ });
1435
+
1436
+ it("runs modules for formatMessage without a message key", function () {
1437
+ const payloads: any[] = [];
1438
+ const m = createMessagevisor({
1439
+ datafile,
1440
+ modules: [
1441
+ {
1442
+ transform(payload) {
1443
+ payloads.push(payload);
1444
+
1445
+ return `${payload.translation} done`;
1446
+ },
1447
+ },
1448
+ ],
1449
+ });
1450
+
1451
+ expect(m.formatMessage("Hello {name}", { name: "Ada" })).toEqual("Hello {name} done");
1452
+ expect(payloads[0]).toMatchObject({
1453
+ translation: "Hello {name}",
1454
+ locale: "en-US",
1455
+ source: "formatMessage",
1456
+ });
1457
+ expect(payloads[0].messageKey).toBeUndefined();
1458
+ });
1459
+
1460
+ it("adds and removes modules by name after creation", function () {
1461
+ const diagnostics: any[] = [];
1462
+ const m = createMessagevisor({
1463
+ datafile,
1464
+ onDiagnostic: (diagnostic) => diagnostics.push(diagnostic),
1465
+ modules: [
1466
+ {
1467
+ name: "wrap",
1468
+ transform: ({ translation }) => `[${translation}]`,
1469
+ },
1470
+ ],
1471
+ });
1472
+
1473
+ m.addModule({
1474
+ name: "shout",
1475
+ transform: ({ translation }) => String(translation).toUpperCase(),
1476
+ });
1477
+ m.addModule({
1478
+ name: "shout",
1479
+ transform: ({ translation }) => `${translation}!`,
1480
+ });
1481
+ m.addModule({
1482
+ transform: ({ translation }) => `${translation}?`,
1483
+ });
1484
+
1485
+ expect(m.translate("greeting", { name: "Ada" })).toEqual("[HELLO {NAME}]?");
1486
+ expect(diagnostics).toEqual(
1487
+ expect.arrayContaining([
1488
+ expect.objectContaining({
1489
+ level: "error",
1490
+ code: "duplicate_module",
1491
+ message: "Duplicate module name",
1492
+ moduleName: "shout",
1493
+ }),
1494
+ ]),
1495
+ );
1496
+
1497
+ m.removeModule("shout");
1498
+ expect(m.translate("greeting", { name: "Ada" })).toEqual("[Hello {name}]?");
1499
+
1500
+ m.removeModule("missing");
1501
+ expect(m.translate("greeting", { name: "Ada" })).toEqual("[Hello {name}]?");
1502
+ });
1503
+
1504
+ it("does not register duplicate module names during initialization", function () {
1505
+ const diagnostics: any[] = [];
1506
+ const m = createMessagevisor({
1507
+ datafile,
1508
+ onDiagnostic: (diagnostic) => diagnostics.push(diagnostic),
1509
+ modules: [
1510
+ {
1511
+ name: "wrap",
1512
+ transform: ({ translation }) => `[${translation}]`,
1513
+ },
1514
+ {
1515
+ name: "wrap",
1516
+ transform: ({ translation }) => `${translation}!`,
1517
+ },
1518
+ ],
1519
+ });
1520
+
1521
+ expect(m.translate("greeting", { name: "Ada" })).toEqual("[Hello {name}]");
1522
+ expect(diagnostics).toEqual(
1523
+ expect.arrayContaining([
1524
+ expect.objectContaining({
1525
+ level: "error",
1526
+ code: "duplicate_module",
1527
+ moduleName: "wrap",
1528
+ }),
1529
+ ]),
1530
+ );
1531
+ });
1532
+
1533
+ it("preserves the previous translation when modules return undefined", function () {
1534
+ const m = createMessagevisor({
1535
+ datafile,
1536
+ modules: [
1537
+ {
1538
+ transform: () => undefined,
1539
+ },
1540
+ {
1541
+ transform: ({ translation }) => `${translation}!`,
1542
+ },
1543
+ ],
1544
+ });
1545
+
1546
+ expect(m.translate("greeting", { name: "Ada" })).toEqual("Hello {name}!");
1547
+ });
1548
+
1549
+ it("lets formatting modules run before transform modules", function () {
1550
+ const m = createMessagevisor({
1551
+ datafile,
1552
+ modules: [
1553
+ {
1554
+ name: "interpolate",
1555
+ format({ translation, values }) {
1556
+ return String(translation).replace("{name}", String(values && values.name));
1557
+ },
1558
+ },
1559
+ {
1560
+ name: "uppercase",
1561
+ format({ translation }) {
1562
+ return String(translation).toUpperCase();
1563
+ },
1564
+ },
1565
+ {
1566
+ transform(payload) {
1567
+ return `${payload.translation}!`;
1568
+ },
1569
+ },
1570
+ ],
1571
+ });
1572
+
1573
+ expect(m.translate("greeting", { name: "Ada" })).toEqual("HELLO ADA!");
1574
+ expect(m.formatMessage("Hi {name}", { name: "Lin" })).toEqual("HI LIN!");
1575
+ });
1576
+
1577
+ it("runs setup for constructor and added modules, and last resolver registration wins", function () {
1578
+ const m = createMessagevisor({
1579
+ datafile,
1580
+ resolveFlag: () => false,
1581
+ resolveVariation: () => "a",
1582
+ modules: [
1583
+ {
1584
+ setup({ setFlagResolver, setVariationResolver }) {
1585
+ setFlagResolver(() => false);
1586
+ setVariationResolver(() => "a");
1587
+ },
1588
+ },
1589
+ {
1590
+ setup({ setFlagResolver, setVariationResolver }) {
1591
+ setFlagResolver((featureKey, context) => {
1592
+ return featureKey === "new-checkout" && context?.platform === "web";
1593
+ });
1594
+ setVariationResolver((experimentKey, context) => {
1595
+ return experimentKey === "checkout-copy" && context?.platform === "web" ? "b" : "a";
1596
+ });
1597
+ },
1598
+ },
1599
+ ],
1600
+ context: { platform: "web" },
1601
+ });
1602
+
1603
+ expect(m.translate("featureGate")).toEqual("Feature enabled");
1604
+ expect(m.translate("experimentGate")).toEqual("Experiment B");
1605
+
1606
+ const raw = createMessagevisor({
1607
+ datafile,
1608
+ context: { platform: "web" },
1609
+ });
1610
+
1611
+ raw.addModule({
1612
+ setup({ setFlagResolver, setVariationResolver }) {
1613
+ setFlagResolver(() => true);
1614
+ setVariationResolver(() => "b");
1615
+ },
1616
+ });
1617
+
1618
+ expect(raw.translate("featureGate")).toEqual("Feature enabled");
1619
+ expect(raw.translate("experimentGate")).toEqual("Experiment B");
1620
+ });
1621
+
1622
+ it("runs modules for overrides and missing key fallbacks without double-running", function () {
1623
+ const calls: any[] = [];
1624
+ const m = createMessagevisor({
1625
+ datafile,
1626
+ context: { platform: "web" },
1627
+ modules: [
1628
+ {
1629
+ transform(payload) {
1630
+ calls.push(payload);
1631
+
1632
+ return payload.translation;
1633
+ },
1634
+ },
1635
+ ],
1636
+ });
1637
+
1638
+ expect(m.translate("greeting", { name: "Ada" })).toEqual("Hello web {name}");
1639
+ expect(m.translate("richTerms", { product: "Messagevisor" })).toEqual(
1640
+ "Read our <link>terms</link> for <strong>{product}</strong>.",
1641
+ );
1642
+ expect(m.translate("missing.key")).toEqual("missing.key");
1643
+
1644
+ expect(calls.map((call) => call.messageKey)).toEqual(["greeting", "richTerms", "missing.key"]);
1645
+ expect(calls).toHaveLength(3);
1646
+ });
1647
+
1648
+ it("returns selected raw translation messages before formatting", function () {
1649
+ const m = createMessagevisor({ datafile });
1650
+
1651
+ expect(m.getRawTranslation("greeting")).toEqual("Hello {name}");
1652
+ expect(
1653
+ m.getRawTranslation("greeting", {
1654
+ context: { platform: "web" },
1655
+ }),
1656
+ ).toEqual("Hello web {name}");
1657
+ expect(m.getRawTranslation("missing.key")).toEqual("missing.key");
1658
+ });
1659
+
1660
+ it("reports missing translation messages and keeps the message key fallback by default", function () {
1661
+ const diagnostics: any[] = [];
1662
+ const m = createMessagevisor({
1663
+ datafile,
1664
+ context: { platform: "web", plan: "free" },
1665
+ onDiagnostic: (diagnostic) => diagnostics.push(diagnostic),
1666
+ });
1667
+
1668
+ expect(m.translate("missing.key", undefined, { context: { plan: "pro" } })).toEqual(
1669
+ "missing.key",
1670
+ );
1671
+ expect(m.getRawTranslation("missing.raw")).toEqual("missing.raw");
1672
+
1673
+ const missingMessages = diagnostics.filter(
1674
+ (diagnostic) => diagnostic.code === "missing_translation",
1675
+ );
1676
+ expect(missingMessages).toHaveLength(2);
1677
+ expect(missingMessages[0]).toMatchObject({
1678
+ level: "error",
1679
+ message: "Missing translation",
1680
+ messageKey: "missing.key",
1681
+ locale: "en-US",
1682
+ source: "translation",
1683
+ });
1684
+ expect(missingMessages[1]).toMatchObject({
1685
+ level: "error",
1686
+ message: "Missing translation",
1687
+ messageKey: "missing.raw",
1688
+ locale: "en-US",
1689
+ source: "translation",
1690
+ });
1691
+ });
1692
+
1693
+ it("keeps message key fallback when onDiagnostic observes a missing translation", function () {
1694
+ const m = createMessagevisor({
1695
+ datafile,
1696
+ onDiagnostic: () => "ignored" as any,
1697
+ });
1698
+
1699
+ expect(m.translate("missing.greeting", { name: "Ada" })).toEqual("missing.greeting");
1700
+ });
1701
+
1702
+ it("uses per-call default translations after datafile and locale defaults", function () {
1703
+ const diagnostics: any[] = [];
1704
+ const m = createMessagevisor({
1705
+ datafile,
1706
+ defaultTranslations: {
1707
+ "en-US": {
1708
+ fallbackOnly: "Fallback only {name}",
1709
+ },
1710
+ },
1711
+ onDiagnostic: (diagnostic) => diagnostics.push(diagnostic),
1712
+ });
1713
+
1714
+ expect(
1715
+ m.translate("greeting", { name: "Ada" }, { defaultTranslation: "Default {name}" }),
1716
+ ).toEqual("Hello {name}");
1717
+ expect(
1718
+ m.translate("fallbackOnly", { name: "Ada" }, { defaultTranslation: "Default {name}" }),
1719
+ ).toEqual("Fallback only {name}");
1720
+ expect(
1721
+ m.translate("missing.default", { name: "Ada" }, { defaultTranslation: "Default {name}" }),
1722
+ ).toEqual("Default {name}");
1723
+ expect(m.getRawTranslation("missing.raw", { defaultTranslation: "Default raw" })).toEqual(
1724
+ "Default raw",
1725
+ );
1726
+ expect(
1727
+ diagnostics
1728
+ .filter((diagnostic) => diagnostic.code === "missing_translation")
1729
+ .map((diagnostic) => ({
1730
+ messageKey: diagnostic.messageKey,
1731
+ message: diagnostic.message,
1732
+ })),
1733
+ ).toEqual([
1734
+ { messageKey: "missing.default", message: "Missing translation" },
1735
+ { messageKey: "missing.raw", message: "Missing translation" },
1736
+ ]);
1737
+ });
1738
+
1739
+ it("uses empty defaultTranslations entries as explicit values", function () {
1740
+ const diagnostics: any[] = [];
1741
+ const m = createMessagevisor({
1742
+ datafile,
1743
+ defaultTranslations: {
1744
+ "en-US": {
1745
+ emptyDefault: "",
1746
+ },
1747
+ },
1748
+ onDiagnostic: (diagnostic) => diagnostics.push(diagnostic),
1749
+ });
1750
+
1751
+ expect(m.translate("emptyDefault", undefined, { defaultTranslation: "Default" })).toEqual("");
1752
+ expect(diagnostics.some((diagnostic) => diagnostic.code === "missing_translation")).toEqual(
1753
+ false,
1754
+ );
1755
+ });
1756
+
1757
+ it("uses empty defaultTranslation when explicitly provided", function () {
1758
+ const m = createMessagevisor({
1759
+ datafile,
1760
+ });
1761
+
1762
+ expect(m.getRawTranslation("missing.empty", { defaultTranslation: "" })).toEqual("");
1763
+ });
1764
+
1765
+ it("does not report existing translation messages as missing", function () {
1766
+ const diagnostics: any[] = [];
1767
+ const m = createMessagevisor({
1768
+ datafile,
1769
+ onDiagnostic: (diagnostic) => diagnostics.push(diagnostic),
1770
+ });
1771
+
1772
+ expect(m.translate("greeting", { name: "Ada" })).toEqual("Hello {name}");
1773
+ expect(diagnostics.some((diagnostic) => diagnostic.code === "missing_translation")).toEqual(
1774
+ false,
1775
+ );
1776
+ });
1777
+
1778
+ it("returns raw translation messages selected by feature and experiment conditions", function () {
1779
+ const m = createMessagevisor({
1780
+ datafile,
1781
+ resolveFlag: (featureKey) => featureKey === "new-checkout",
1782
+ resolveVariation: (experimentKey) => (experimentKey === "checkout-copy" ? "b" : "control"),
1783
+ });
1784
+
1785
+ expect(m.getRawTranslation("featureGate")).toEqual("Feature enabled");
1786
+ expect(m.getRawTranslation("experimentGate")).toEqual("Experiment B");
1787
+ });
1788
+
1789
+ it("keeps rich text tags and ICU placeholders untouched by default", function () {
1790
+ const m = createMessagevisor({ datafile });
1791
+
1792
+ expect(
1793
+ m.translate("richTerms", {
1794
+ product: "Messagevisor",
1795
+ link: (chunks) => `[${chunks.join("")}]`,
1796
+ strong: (chunks) => chunks.join("").toUpperCase(),
1797
+ }),
1798
+ ).toEqual("Read our <link>terms</link> for <strong>{product}</strong>.");
1799
+ });
1800
+
1801
+ it("supports sparse message metadata in datafiles", function () {
1802
+ const m = createMessagevisor({
1803
+ datafile: {
1804
+ ...datafile,
1805
+ messages: {},
1806
+ translations: {
1807
+ greeting: "Hello {name}",
1808
+ },
1809
+ },
1810
+ });
1811
+
1812
+ expect(m.translate("greeting", { name: "Ada" })).toEqual("Hello {name}");
1813
+ });
1814
+
1815
+ it("passes message meta to modules for keyed translations only", function () {
1816
+ const formatPayloads: Array<{ messageKey?: string; meta?: Record<string, unknown> }> = [];
1817
+ const transformPayloads: Array<{ messageKey?: string; meta?: Record<string, unknown> }> = [];
1818
+ const m = createMessagevisor({
1819
+ datafile: {
1820
+ ...datafile,
1821
+ messages: {
1822
+ ...datafile.messages,
1823
+ greeting: {
1824
+ ...datafile.messages.greeting,
1825
+ meta: {
1826
+ tags: ["auth", "entry"],
1827
+ analytics: {
1828
+ event: "signin_impression",
1829
+ },
1830
+ },
1831
+ },
1832
+ },
1833
+ },
1834
+ modules: [
1835
+ {
1836
+ format(payload) {
1837
+ formatPayloads.push({
1838
+ messageKey: payload.messageKey,
1839
+ meta: payload.meta as Record<string, unknown> | undefined,
1840
+ });
1841
+ },
1842
+ transform(payload) {
1843
+ transformPayloads.push({
1844
+ messageKey: payload.messageKey,
1845
+ meta: payload.meta as Record<string, unknown> | undefined,
1846
+ });
1847
+ },
1848
+ },
1849
+ ],
1850
+ });
1851
+
1852
+ m.translate("greeting", { name: "Ada" });
1853
+ m.formatMessage("Hello {name}", { name: "Ada" });
1854
+
1855
+ expect(formatPayloads).toEqual([
1856
+ {
1857
+ messageKey: "greeting",
1858
+ meta: {
1859
+ tags: ["auth", "entry"],
1860
+ analytics: {
1861
+ event: "signin_impression",
1862
+ },
1863
+ },
1864
+ },
1865
+ {
1866
+ messageKey: undefined,
1867
+ meta: undefined,
1868
+ },
1869
+ ]);
1870
+ expect(transformPayloads).toEqual([
1871
+ {
1872
+ messageKey: "greeting",
1873
+ meta: {
1874
+ tags: ["auth", "entry"],
1875
+ analytics: {
1876
+ event: "signin_impression",
1877
+ },
1878
+ },
1879
+ },
1880
+ {
1881
+ messageKey: undefined,
1882
+ meta: undefined,
1883
+ },
1884
+ ]);
1885
+ });
1886
+
1887
+ it("passes a destructurable module API to format and transform hooks", function () {
1888
+ const diagnostics: any[] = [];
1889
+ const m = createMessagevisor({
1890
+ datafile,
1891
+ onDiagnostic: (diagnostic) => diagnostics.push(diagnostic),
1892
+ modules: [
1893
+ {
1894
+ name: "runtime",
1895
+ format(payload, api) {
1896
+ api!.reportDiagnostic({
1897
+ level: "warn",
1898
+ code: "runtime_format_seen",
1899
+ message: "Runtime format seen",
1900
+ locale: payload.locale,
1901
+ messageKey: payload.messageKey,
1902
+ source: payload.source,
1903
+ });
1904
+ },
1905
+ transform(payload, api) {
1906
+ api!.reportDiagnostic({
1907
+ level: "warn",
1908
+ code: "runtime_transform_seen",
1909
+ message: "Runtime transform seen",
1910
+ locale: payload.locale,
1911
+ messageKey: payload.messageKey,
1912
+ source: payload.source,
1913
+ });
1914
+ },
1915
+ },
1916
+ ],
1917
+ });
1918
+
1919
+ m.translate("greeting", { name: "Ada" });
1920
+
1921
+ expect(diagnostics).toEqual(
1922
+ expect.arrayContaining([
1923
+ expect.objectContaining({
1924
+ code: "runtime_format_seen",
1925
+ module: "runtime",
1926
+ messageKey: "greeting",
1927
+ }),
1928
+ expect.objectContaining({
1929
+ code: "runtime_transform_seen",
1930
+ module: "runtime",
1931
+ messageKey: "greeting",
1932
+ }),
1933
+ ]),
1934
+ );
1935
+ });
1936
+
1937
+ it("emits error events for diagnostics reported from runtime hooks", function () {
1938
+ const events: any[] = [];
1939
+ const m = createMessagevisor({
1940
+ datafile,
1941
+ logLevel: "fatal",
1942
+ modules: [
1943
+ {
1944
+ name: "runtime",
1945
+ transform(payload, api) {
1946
+ api!.reportDiagnostic({
1947
+ level: "error",
1948
+ code: "runtime_transform_failed",
1949
+ message: "Runtime transform failed",
1950
+ locale: payload.locale,
1951
+ messageKey: payload.messageKey,
1952
+ source: payload.source,
1953
+ });
1954
+ },
1955
+ },
1956
+ ],
1957
+ });
1958
+
1959
+ m.on("error", (event) => events.push(event));
1960
+ m.translate("greeting", { name: "Ada" });
1961
+
1962
+ expect(events).toEqual([
1963
+ expect.objectContaining({
1964
+ diagnostic: expect.objectContaining({
1965
+ code: "runtime_transform_failed",
1966
+ module: "runtime",
1967
+ messageKey: "greeting",
1968
+ }),
1969
+ }),
1970
+ ]);
1971
+ });
1972
+
1973
+ it("reuses one module API instance for setup and runtime hooks", function () {
1974
+ const apis: any[] = [];
1975
+ const m = createMessagevisor({
1976
+ datafile,
1977
+ modules: [
1978
+ {
1979
+ name: "runtime",
1980
+ setup(api) {
1981
+ apis.push(api);
1982
+ },
1983
+ format(_payload, api) {
1984
+ apis.push(api);
1985
+ },
1986
+ transform(_payload, api) {
1987
+ apis.push(api);
1988
+ },
1989
+ },
1990
+ ],
1991
+ });
1992
+
1993
+ m.translate("greeting", { name: "Ada" });
1994
+ m.translate("greeting", { name: "Ada" });
1995
+
1996
+ expect(apis).toHaveLength(5);
1997
+ expect(apis.every((api) => api === apis[0])).toEqual(true);
1998
+ });
1999
+
2000
+ it("applies overrides using segments and context", function () {
2001
+ const m = createMessagevisor({
2002
+ datafile,
2003
+ context: { platform: "web" },
2004
+ });
2005
+
2006
+ expect(m.translate("greeting", { name: "Ada" })).toEqual("Hello web {name}");
2007
+ });
2008
+
2009
+ it("emits debug diagnostics for message override evaluation", function () {
2010
+ const diagnostics: any[] = [];
2011
+ const keyedDatafile = {
2012
+ ...datafile,
2013
+ messages: {
2014
+ ...datafile.messages,
2015
+ greeting: {
2016
+ ...datafile.messages.greeting,
2017
+ overrides: datafile.messages.greeting.overrides?.map((override, index) => ({
2018
+ ...override,
2019
+ key: `override-${index}`,
2020
+ })) as any,
2021
+ },
2022
+ },
2023
+ };
2024
+ const m = createMessagevisor({
2025
+ datafile: keyedDatafile,
2026
+ context: { platform: "web" },
2027
+ logLevel: "debug",
2028
+ onDiagnostic: (diagnostic) => diagnostics.push(diagnostic),
2029
+ });
2030
+
2031
+ expect(m.translate("greeting", { name: "Ada" })).toEqual("Hello web {name}");
2032
+
2033
+ expect(diagnostics).toEqual(
2034
+ expect.arrayContaining([
2035
+ expect.objectContaining({
2036
+ level: "debug",
2037
+ code: "message_override_matched",
2038
+ message: "Message override matched",
2039
+ locale: "en-US",
2040
+ messageKey: "greeting",
2041
+ overrideKey: "override-0",
2042
+ }),
2043
+ ]),
2044
+ );
2045
+ expect(
2046
+ diagnostics.filter((diagnostic) => diagnostic.code === "message_override_matched"),
2047
+ ).toHaveLength(1);
2048
+ expect(
2049
+ diagnostics.some((diagnostic) => Object.prototype.hasOwnProperty.call(diagnostic, "details")),
2050
+ ).toEqual(false);
2051
+ });
2052
+
2053
+ it("does not emit override debug diagnostics when no message override matches", function () {
2054
+ const diagnostics: any[] = [];
2055
+ const m = createMessagevisor({
2056
+ datafile,
2057
+ context: { platform: "mobile" },
2058
+ logLevel: "debug",
2059
+ onDiagnostic: (diagnostic) => diagnostics.push(diagnostic),
2060
+ });
2061
+
2062
+ expect(m.translate("greeting", { name: "Ada" })).toEqual("Hello {name}");
2063
+
2064
+ expect(
2065
+ diagnostics.filter((diagnostic) => diagnostic.code.startsWith("message_override_")),
2066
+ ).toEqual([]);
2067
+ });
2068
+
2069
+ it("applies overrides with stringified conditions and group segments from datafiles", function () {
2070
+ const stringifiedDatafile: DatafileContent = {
2071
+ ...datafile,
2072
+ segments: {
2073
+ ...datafile.segments,
2074
+ "plan-pro": {
2075
+ conditions: JSON.stringify({
2076
+ attribute: "plan",
2077
+ operator: "equals",
2078
+ value: "pro",
2079
+ }),
2080
+ },
2081
+ "region-eu": {
2082
+ conditions: JSON.stringify([
2083
+ {
2084
+ attribute: "region",
2085
+ operator: "equals",
2086
+ value: "EU",
2087
+ },
2088
+ ]),
2089
+ },
2090
+ everyone: {
2091
+ conditions: "*",
2092
+ },
2093
+ },
2094
+ messages: {
2095
+ ...datafile.messages,
2096
+ stringifiedCondition: {
2097
+ overrides: [
2098
+ {
2099
+ key: "plan-pro",
2100
+ conditions: JSON.stringify({
2101
+ attribute: "plan",
2102
+ operator: "equals",
2103
+ value: "pro",
2104
+ }),
2105
+ translation: "Condition match",
2106
+ },
2107
+ ],
2108
+ },
2109
+ stringifiedConditionArray: {
2110
+ overrides: [
2111
+ {
2112
+ key: "plan-pro-region-eu",
2113
+ conditions: JSON.stringify([
2114
+ { attribute: "plan", operator: "equals", value: "pro" },
2115
+ { attribute: "region", operator: "equals", value: "EU" },
2116
+ ]),
2117
+ translation: "Condition array match",
2118
+ },
2119
+ ],
2120
+ },
2121
+ stringifiedGroupSegment: {
2122
+ overrides: [
2123
+ {
2124
+ key: "group-and",
2125
+ segments: JSON.stringify({ and: ["plan-pro", "region-eu"] }),
2126
+ translation: "Group segment match",
2127
+ },
2128
+ ],
2129
+ },
2130
+ stringifiedGroupSegmentArray: {
2131
+ overrides: [
2132
+ {
2133
+ key: "group-array",
2134
+ segments: JSON.stringify(["plan-pro", "region-eu"]),
2135
+ translation: "Group segment array match",
2136
+ },
2137
+ ],
2138
+ },
2139
+ segmentWithEveryoneCondition: {
2140
+ overrides: [
2141
+ {
2142
+ key: "everyone",
2143
+ segments: "everyone",
2144
+ translation: "Everyone segment match",
2145
+ },
2146
+ ],
2147
+ },
2148
+ },
2149
+ translations: {
2150
+ ...datafile.translations,
2151
+ stringifiedCondition: "Condition default",
2152
+ stringifiedConditionArray: "Condition array default",
2153
+ stringifiedGroupSegment: "Group segment default",
2154
+ stringifiedGroupSegmentArray: "Group segment array default",
2155
+ segmentWithEveryoneCondition: "Everyone segment default",
2156
+ },
2157
+ };
2158
+ const m = createMessagevisor({
2159
+ datafile: stringifiedDatafile,
2160
+ context: { platform: "web", plan: "pro", region: "EU" },
2161
+ });
2162
+
2163
+ expect(m.translate("stringifiedCondition")).toEqual("Condition match");
2164
+ expect(m.translate("stringifiedConditionArray")).toEqual("Condition array match");
2165
+ expect(m.translate("stringifiedGroupSegment")).toEqual("Group segment match");
2166
+ expect(m.translate("stringifiedGroupSegmentArray")).toEqual("Group segment array match");
2167
+ expect(m.translate("segmentWithEveryoneCondition")).toEqual("Everyone segment match");
2168
+ });
2169
+
2170
+ it("accepts evaluation options as the third argument", function () {
2171
+ const m = createMessagevisor({ datafile });
2172
+
2173
+ expect(
2174
+ m.translate(
2175
+ "greeting",
2176
+ { name: "Ada" },
2177
+ {
2178
+ context: { platform: "web" },
2179
+ },
2180
+ ),
2181
+ ).toEqual("Hello web {name}");
2182
+ });
2183
+
2184
+ it("merges per-call context with instance context and lets per-call context win", function () {
2185
+ const m = createMessagevisor({
2186
+ datafile: {
2187
+ ...datafile,
2188
+ messages: {
2189
+ ...datafile.messages,
2190
+ mergedContext: {
2191
+ overrides: [
2192
+ {
2193
+ key: "enterprise-web",
2194
+ conditions: [
2195
+ { attribute: "platform", operator: "equals", value: "web" },
2196
+ { attribute: "plan", operator: "equals", value: "enterprise" },
2197
+ ] as any,
2198
+ translation: "Merged context",
2199
+ },
2200
+ ],
2201
+ },
2202
+ },
2203
+ translations: {
2204
+ ...datafile.translations,
2205
+ mergedContext: "Default context",
2206
+ },
2207
+ },
2208
+ context: { platform: "web", plan: "pro" },
2209
+ });
2210
+
2211
+ expect(
2212
+ m.translate(
2213
+ "mergedContext",
2214
+ {},
2215
+ {
2216
+ context: { plan: "enterprise" },
2217
+ },
2218
+ ),
2219
+ ).toEqual("Merged context");
2220
+ });
2221
+
2222
+ it("stores and switches locale datafiles", function () {
2223
+ const m = createMessagevisor({ datafile });
2224
+
2225
+ m.setDatafile({
2226
+ ...datafile,
2227
+ locale: "nl-NL",
2228
+ translations: { greeting: "Hallo {name}", total: "Totaal" },
2229
+ messages: { greeting: {}, total: {} },
2230
+ });
2231
+
2232
+ expect(m.getLocale()).toEqual("en-US");
2233
+ expect(m.translate("greeting", { name: "Ada" })).toEqual("Hello {name}");
2234
+
2235
+ m.setLocale("nl-NL");
2236
+ expect(m.translate("greeting", { name: "Ada" })).toEqual("Hallo {name}");
2237
+ });
2238
+
2239
+ it("setDatafile does not switch the active locale when another locale is stored later", function () {
2240
+ const m = createMessagevisor({ datafile });
2241
+
2242
+ m.setDatafile({
2243
+ ...datafile,
2244
+ locale: "nl-NL",
2245
+ revision: "2",
2246
+ translations: { greeting: "Hallo {name}", total: "Totaal" },
2247
+ messages: { greeting: {}, total: {} },
2248
+ });
2249
+
2250
+ expect(m.getLocale()).toEqual("en-US");
2251
+ expect(m.translate("greeting", { name: "Ada" })).toEqual("Hello {name}");
2252
+
2253
+ m.setLocale("nl-NL");
2254
+ expect(m.translate("greeting", { name: "Ada" })).toEqual("Hallo {name}");
2255
+ });
2256
+
2257
+ it("formats numbers and dates with presets", function () {
2258
+ const m = createMessagevisor({ datafile, timeZone: "UTC" });
2259
+
2260
+ expect(m.formatNumber(12, { style: "currency", currency: "USD" })).toContain("12");
2261
+ expect(m.formatNumber(12, "money")).toContain("12");
2262
+ expect(m.formatDate("2025-01-02T00:00:00Z", "short")).toContain("1/2/25");
2263
+ expect(m.formatRelativeTime(-1, "day", "short")).toEqual("yesterday");
2264
+ });
2265
+
2266
+ it("uses call, format style, instance, and fallback precedence for currency", function () {
2267
+ const m = createMessagevisor({ datafile, currency: "CHF" });
2268
+
2269
+ expect(m.formatNumber(12, "money")).toContain("USD");
2270
+ expect(m.formatNumber(12, "money", { currency: "EUR" })).toContain("EUR");
2271
+ expect(
2272
+ m.formatNumber(12, { style: "currency", currency: "GBP", currencyDisplay: "code" }),
2273
+ ).toContain("GBP");
2274
+ expect(m.formatNumber(12, { style: "currency" } as any)).toContain("CHF");
2275
+ expect(m.formatNumber(12, "runtimeMoney")).toContain("CHF");
2276
+ expect(m.formatNumber(12, "runtimeMoney", { currency: "EUR" })).toContain("EUR");
2277
+ expect(m.formatNumber(12, "money", { currency: "EUR" })).toContain("EUR");
2278
+ expect(m.formatNumber(12, "money")).toContain("USD");
2279
+
2280
+ const fallbackOnly = createMessagevisor({ datafile });
2281
+ expect(fallbackOnly.formatNumber(12, { style: "currency", currencyDisplay: "code" })).toContain(
2282
+ "USD",
2283
+ );
2284
+ });
2285
+
2286
+ it("uses call, format style, instance, and fallback precedence for time zones", function () {
2287
+ const startsAt = new Date("2025-01-01T12:00:00Z");
2288
+ const endsAt = new Date("2025-01-01T14:30:00Z");
2289
+ const m = createMessagevisor({ datafile, timeZone: "Asia/Tokyo" });
2290
+
2291
+ expect(m.formatTime(startsAt, "event")).toEqual("12:00 PM");
2292
+ expect(m.formatTime(startsAt, "event", { timeZone: "America/New_York" })).toEqual("7:00 AM");
2293
+ expect(m.formatTime(startsAt, { hour: "numeric", minute: "2-digit" })).toEqual("9:00 PM");
2294
+ expect(
2295
+ m.formatTime(startsAt, {
2296
+ hour: "numeric",
2297
+ minute: "2-digit",
2298
+ timeZone: "Europe/Amsterdam",
2299
+ }),
2300
+ ).toEqual("1:00 PM");
2301
+ expect(m.formatDateTimeRange(startsAt, endsAt, "fullStyle")).toContain(
2302
+ "Wednesday, January 1, 2025",
2303
+ );
2304
+ expect(
2305
+ m.formatDateTimeRange(startsAt, endsAt, "fullStyle", {
2306
+ timeZone: "America/Los_Angeles",
2307
+ }),
2308
+ ).toContain("4:00");
2309
+ });
2310
+
2311
+ it("formats date/time helper variants", function () {
2312
+ const startsAt = new Date("2025-01-01T12:00:00Z");
2313
+ const endsAt = new Date("2025-01-01T14:30:00Z");
2314
+ const m = createMessagevisor({ datafile, timeZone: "UTC" });
2315
+
2316
+ expect(m.formatDate(startsAt, "numeric")).toEqual("01/01/2025");
2317
+ expect(m.formatDate(startsAt, "weekday")).toEqual("Wednesday, January 1, 2025");
2318
+ expect(m.formatTime(startsAt, "short")).toEqual("12:00 PM");
2319
+ expect(m.formatTime(startsAt, "short", { timeZone: "America/New_York" })).toEqual("7:00 AM");
2320
+ expect(m.formatDateTimeRange(startsAt, endsAt, "event")).toEqual(
2321
+ "Jan 1, 2025, 12:00 – 2:30 PM",
2322
+ );
2323
+ expect(m.formatRelativeTime(-1, "day", "short")).toEqual("yesterday");
2324
+ });
2325
+
2326
+ it("supports expanded Intl-backed number and date/time preset options", function () {
2327
+ const startsAt = new Date("2025-01-01T12:00:00Z");
2328
+ const endsAt = new Date("2025-01-01T14:30:00Z");
2329
+ const m = createMessagevisor({ datafile, timeZone: "UTC" });
2330
+
2331
+ expect(m.formatNumber(1200, "compactShort")).toContain("1.2K");
2332
+ expect(m.formatNumber(1200, "compactLong")).toContain("1.2 thousand");
2333
+ expect(m.formatNumber(5, "unitDistance")).toMatch(/5\s?km/);
2334
+ expect(m.formatNumber(5, "signAlways")).toEqual("+5");
2335
+ expect(m.formatNumber(12, "roundingStrip")).toEqual("12");
2336
+ expect(m.formatNumber(1200, "engineering")).toContain("1.2E3");
2337
+ expect(m.formatNumber(1200, "scientific")).toContain("1.2E3");
2338
+
2339
+ expect(m.formatDate(startsAt, "fullStyle")).toContain("Wednesday, January 1, 2025");
2340
+ expect(m.formatDate(startsAt, "arabicNumeric")).not.toEqual("01/01/2025");
2341
+ expect(m.formatTime(startsAt, "fullStyle")).toContain("12:00:00 PM");
2342
+ expect(m.formatTime(startsAt, "period").toLowerCase()).toContain("noon");
2343
+ expect(m.formatDateTimeRange(startsAt, endsAt, "fullStyle")).toContain(
2344
+ "Wednesday, January 1, 2025",
2345
+ );
2346
+ });
2347
+
2348
+ it("uses resolveFlag and resolveVariation for feature and experiment conditions", function () {
2349
+ const m = createMessagevisor({
2350
+ datafile,
2351
+ resolveFlag: (key) => key === "new-checkout",
2352
+ resolveVariation: (key) => (key === "checkout-copy" ? "b" : "a"),
2353
+ });
2354
+
2355
+ expect(m.translate("featureGate")).toEqual("Feature enabled");
2356
+ expect(m.translate("experimentGate")).toEqual("Experiment B");
2357
+ });
2358
+
2359
+ it("supports messages catalogs, diagnostics, caching, and formatter parity helpers", function () {
2360
+ const diagnostics: any[] = [];
2361
+ const cache = createMessagevisorCache();
2362
+ const m = createMessagevisor({
2363
+ locale: "en-US",
2364
+ defaultTranslations: {
2365
+ "en-US": {
2366
+ total: "Total: {amount, number}",
2367
+ },
2368
+ },
2369
+ defaultFormats: {
2370
+ "en-US": {
2371
+ number: {
2372
+ short: { notation: "scientific" },
2373
+ },
2374
+ date: {
2375
+ short: { year: "numeric", month: "short", day: "numeric" },
2376
+ },
2377
+ time: {
2378
+ short: { hour: "numeric", minute: "2-digit", timeZone: "UTC" },
2379
+ },
2380
+ },
2381
+ },
2382
+ onDiagnostic: (diagnostic) => diagnostics.push(diagnostic),
2383
+ cache,
2384
+ });
2385
+
2386
+ expect(m.formatMessage(m.getRawTranslation("total"), { amount: 1200 })).toEqual(
2387
+ "Total: {amount, number}",
2388
+ );
2389
+ expect(m.formatNumberToParts(1200, "short").length).toBeGreaterThan(0);
2390
+ expect(m.formatDateToParts(new Date("2025-01-01T12:00:00Z"), "short").length).toBeGreaterThan(
2391
+ 0,
2392
+ );
2393
+ expect(m.formatTimeToParts(new Date("2025-01-01T12:00:00Z"), "short").length).toBeGreaterThan(
2394
+ 0,
2395
+ );
2396
+ expect(m.formatPlural(1)).toEqual("one");
2397
+ expect(m.formatList(["A", "B", "C"])).toContain("A");
2398
+ expect(m.formatListToParts(["A", "B"]).length).toBeGreaterThan(0);
2399
+ expect(typeof m.formatDisplayName("USD", { type: "currency" })).toBeTruthy();
2400
+ expect(Object.keys(cache.numberFormat).length).toBeGreaterThan(0);
2401
+
2402
+ expect(m.getRawTranslation("missing.message")).toEqual("missing.message");
2403
+ expect(diagnostics.some((diagnostic) => diagnostic.code === "missing_translation")).toEqual(
2404
+ true,
2405
+ );
2406
+ });
2407
+
2408
+ it("uses locale-keyed default translations only when the active locale datafile misses the key", function () {
2409
+ const m = createMessagevisor({
2410
+ datafile,
2411
+ defaultTranslations: {
2412
+ "en-US": {
2413
+ greeting: "Fallback hello {name}",
2414
+ fallbackOnly: "Fallback only {name}",
2415
+ },
2416
+ "nl-NL": {
2417
+ greeting: "Hallo fallback {name}",
2418
+ },
2419
+ },
2420
+ });
2421
+
2422
+ expect(m.translate("greeting", { name: "Ada" })).toEqual("Hello {name}");
2423
+ expect(m.translate("fallbackOnly", { name: "Ada" })).toEqual("Fallback only {name}");
2424
+
2425
+ m.setDatafile({
2426
+ ...datafile,
2427
+ locale: "nl-NL",
2428
+ revision: "2",
2429
+ translations: {
2430
+ greeting: "Hallo {name}",
2431
+ },
2432
+ messages: {
2433
+ greeting: {},
2434
+ },
2435
+ });
2436
+ m.setLocale("nl-NL");
2437
+
2438
+ expect(m.translate("greeting", { name: "Ada" })).toEqual("Hallo {name}");
2439
+ });
2440
+
2441
+ it("uses locale-keyed default formats as defaults for the active locale", function () {
2442
+ const m = createMessagevisor({
2443
+ locale: "en-US",
2444
+ defaultFormats: {
2445
+ "en-US": {
2446
+ number: {
2447
+ short: { minimumFractionDigits: 1, maximumFractionDigits: 1 },
2448
+ shared: { minimumFractionDigits: 3, maximumFractionDigits: 3 },
2449
+ },
2450
+ },
2451
+ "nl-NL": {
2452
+ number: {
2453
+ short: { minimumFractionDigits: 1, maximumFractionDigits: 1 },
2454
+ nlOnly: { minimumFractionDigits: 2, maximumFractionDigits: 2 },
2455
+ },
2456
+ },
2457
+ },
2458
+ });
2459
+
2460
+ expect(m.formatNumber(1200, "short")).toEqual("1,200.0");
2461
+
2462
+ m.setDatafile({
2463
+ ...datafile,
2464
+ locale: "nl-NL",
2465
+ revision: "2",
2466
+ formats: {
2467
+ number: {
2468
+ shared: { minimumFractionDigits: 0, maximumFractionDigits: 0 },
2469
+ },
2470
+ },
2471
+ messages: {
2472
+ greeting: {},
2473
+ },
2474
+ translations: {
2475
+ greeting: "Hallo {name}",
2476
+ },
2477
+ });
2478
+ m.setLocale("nl-NL");
2479
+
2480
+ expect(m.formatNumber(1200, "short")).toContain("1.200,0");
2481
+ expect(m.formatNumber(1200, "shared")).toEqual("1.200");
2482
+ expect(m.formatNumber(1200, "nlOnly")).toEqual("1.200,00");
2483
+ expect(
2484
+ m.formatNumber(1200, "shared", {
2485
+ formats: {
2486
+ number: {
2487
+ shared: { minimumFractionDigits: 4, maximumFractionDigits: 4 },
2488
+ },
2489
+ },
2490
+ }),
2491
+ ).toEqual("1.200,0000");
2492
+ });
2493
+ });