@multiplatform.one/i18n 6.0.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.
- package/CHANGELOG.md +17 -0
- package/LICENSE +201 -0
- package/package.json +45 -0
- package/src/createI18nConfig.ts +77 -0
- package/src/detectLanguage.spec.ts +83 -0
- package/src/detectLanguage.ts +48 -0
- package/src/env.d.ts +6 -0
- package/src/formatters.spec.ts +101 -0
- package/src/formatters.ts +56 -0
- package/src/frappeBackend.ts +50 -0
- package/src/index.ts +15 -0
- package/src/localePersistence.ts +22 -0
- package/src/useLanguage.ts +27 -0
- package/tests/createI18nConfig.test.ts +58 -0
- package/tests/detectLanguage.test.ts +66 -0
- package/tests/formatters.test.ts +50 -0
- package/tests/frappeBackend.test.ts +57 -0
- package/tests/frappeBackendEnabled.test.ts +111 -0
- package/tests/integrationFrappeDisabled.test.ts +182 -0
- package/tests/integrationFrappeEnabled.test.ts +232 -0
- package/tests/integrationLanguagePersistence.test.ts +241 -0
- package/tests/integrationTreeshaking.test.ts +126 -0
- package/tests/localFallbackDictionaries.test.ts +294 -0
- package/tests/localePersistence.test.ts +65 -0
- package/tests/makeMkFlag.test.ts +25 -0
- package/tests/useLanguagePersistence.test.ts +74 -0
- package/tsconfig.json +11 -0
- package/vitest.config.mjs +13 -0
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 10.1 Cross-group integration test: Frappe-enabled flow
|
|
3
|
+
*
|
|
4
|
+
* Simulates booting the i18n system with a Frappe backend (mocked),
|
|
5
|
+
* switching to a non-English language, and verifying:
|
|
6
|
+
* - Frappe translations are loaded and override local resources
|
|
7
|
+
* - frappe-ui strings render in translated language
|
|
8
|
+
* - DocType metadata labels come back translated (mocked API)
|
|
9
|
+
*/
|
|
10
|
+
import { describe, it, expect } from "vitest";
|
|
11
|
+
import i18next from "i18next";
|
|
12
|
+
import type { BackendModule, ReadCallback } from "i18next";
|
|
13
|
+
import enCommon from "../../../packages/i18n/en/common.json";
|
|
14
|
+
import teCommon from "../../../packages/i18n/te/common.json";
|
|
15
|
+
|
|
16
|
+
// Simulated Frappe Spanish translations (overrides for frappe-ui strings)
|
|
17
|
+
const frappeEsTranslations: Record<string, string> = {
|
|
18
|
+
Save: "Guardar",
|
|
19
|
+
Delete: "Eliminar",
|
|
20
|
+
Cancel: "Cancelar",
|
|
21
|
+
Filters: "Filtros",
|
|
22
|
+
"Loading...": "Cargando...",
|
|
23
|
+
"Saved Filters": "Filtros guardados",
|
|
24
|
+
Name: "Nombre",
|
|
25
|
+
"Add Row": "Agregar fila",
|
|
26
|
+
"Expand All": "Expandir todo",
|
|
27
|
+
"Collapse All": "Contraer todo",
|
|
28
|
+
"No items found": "No se encontraron elementos",
|
|
29
|
+
"Loading {{doctype}}...": "Cargando {{doctype}}...",
|
|
30
|
+
"Loading {{doctype}} schema...": "Cargando esquema de {{doctype}}...",
|
|
31
|
+
Activity: "Actividad",
|
|
32
|
+
Attachments: "Adjuntos",
|
|
33
|
+
Tags: "Etiquetas",
|
|
34
|
+
// DocType metadata labels (what Frappe would return for field labels)
|
|
35
|
+
"Pokemon Name": "Nombre del Pokémon",
|
|
36
|
+
Species: "Especie",
|
|
37
|
+
Height: "Altura",
|
|
38
|
+
Weight: "Peso",
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Build a mock Frappe backend that returns translations synchronously.
|
|
43
|
+
* i18next BackendModule `read` calls the callback synchronously
|
|
44
|
+
* so translations are available immediately after init.
|
|
45
|
+
*/
|
|
46
|
+
function createMockFrappeBackend(
|
|
47
|
+
mockTranslations: Record<string, Record<string, string>>,
|
|
48
|
+
): BackendModule & { fetchCalls: Array<{ language: string; namespace: string }> } {
|
|
49
|
+
const fetchCalls: Array<{ language: string; namespace: string }> = [];
|
|
50
|
+
return {
|
|
51
|
+
type: "backend",
|
|
52
|
+
fetchCalls,
|
|
53
|
+
init() {},
|
|
54
|
+
read(language: string, namespace: string, callback: ReadCallback) {
|
|
55
|
+
fetchCalls.push({ language, namespace });
|
|
56
|
+
const translations = mockTranslations[language];
|
|
57
|
+
callback(null, translations ?? {});
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Helper to init an i18next instance with a mock Frappe backend.
|
|
64
|
+
* Uses `partialBundledLanguages: true` so the backend is queried
|
|
65
|
+
* for languages NOT included in `resources`. English is always bundled
|
|
66
|
+
* as the fallback.
|
|
67
|
+
*/
|
|
68
|
+
async function initWithFrappeBackend(
|
|
69
|
+
mockBackend: BackendModule,
|
|
70
|
+
options: {
|
|
71
|
+
lng: string;
|
|
72
|
+
bundledResources?: Record<string, Record<string, unknown>>;
|
|
73
|
+
supportedLngs?: string[];
|
|
74
|
+
},
|
|
75
|
+
) {
|
|
76
|
+
const instance = i18next.createInstance();
|
|
77
|
+
const { lng, bundledResources, supportedLngs = ["en", "es", "te"] } = options;
|
|
78
|
+
|
|
79
|
+
await new Promise<void>((resolve, reject) => {
|
|
80
|
+
instance.use(mockBackend).init(
|
|
81
|
+
{
|
|
82
|
+
lng,
|
|
83
|
+
defaultNS: "common",
|
|
84
|
+
ns: ["common"],
|
|
85
|
+
supportedLngs,
|
|
86
|
+
fallbackLng: "en",
|
|
87
|
+
// Only bundle English + any explicitly provided resources.
|
|
88
|
+
// Non-bundled languages (like es) will be fetched from backend.
|
|
89
|
+
resources: bundledResources ?? {
|
|
90
|
+
en: { common: enCommon },
|
|
91
|
+
},
|
|
92
|
+
partialBundledLanguages: true,
|
|
93
|
+
interpolation: { escapeValue: false },
|
|
94
|
+
returnNull: false,
|
|
95
|
+
returnEmptyString: false,
|
|
96
|
+
},
|
|
97
|
+
(err) => {
|
|
98
|
+
if (err) reject(err);
|
|
99
|
+
else resolve();
|
|
100
|
+
},
|
|
101
|
+
);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
return instance;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
describe("10.1 Frappe-enabled integration flow", () => {
|
|
108
|
+
it("loads Frappe translations that override local resources for Spanish", async () => {
|
|
109
|
+
const mockBackend = createMockFrappeBackend({
|
|
110
|
+
es: frappeEsTranslations,
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const instance = await initWithFrappeBackend(mockBackend, { lng: "es" });
|
|
114
|
+
|
|
115
|
+
// The backend was called to fetch Spanish translations
|
|
116
|
+
expect(mockBackend.fetchCalls.length).toBeGreaterThanOrEqual(1);
|
|
117
|
+
expect(mockBackend.fetchCalls.some((c) => c.language === "es")).toBe(true);
|
|
118
|
+
|
|
119
|
+
// Frappe translations override local resources
|
|
120
|
+
expect(instance.t("Save")).toBe("Guardar");
|
|
121
|
+
expect(instance.t("Filters")).toBe("Filtros");
|
|
122
|
+
expect(instance.t("Cancel")).toBe("Cancelar");
|
|
123
|
+
expect(instance.t("Loading...")).toBe("Cargando...");
|
|
124
|
+
expect(instance.t("Expand All")).toBe("Expandir todo");
|
|
125
|
+
expect(instance.t("No items found")).toBe("No se encontraron elementos");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("frappe-ui strings with interpolation render in translated language", async () => {
|
|
129
|
+
const mockBackend = createMockFrappeBackend({
|
|
130
|
+
es: frappeEsTranslations,
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const instance = await initWithFrappeBackend(mockBackend, { lng: "es" });
|
|
134
|
+
|
|
135
|
+
// Interpolated frappe-ui strings
|
|
136
|
+
expect(instance.t("Loading {{doctype}}...", { doctype: "Pokemon" })).toBe(
|
|
137
|
+
"Cargando Pokemon...",
|
|
138
|
+
);
|
|
139
|
+
expect(instance.t("Loading {{doctype}} schema...", { doctype: "Pokemon" })).toBe(
|
|
140
|
+
"Cargando esquema de Pokemon...",
|
|
141
|
+
);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("DocType metadata labels come back translated from Frappe", async () => {
|
|
145
|
+
const mockBackend = createMockFrappeBackend({
|
|
146
|
+
es: frappeEsTranslations,
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
const instance = await initWithFrappeBackend(mockBackend, {
|
|
150
|
+
lng: "es",
|
|
151
|
+
supportedLngs: ["en", "es"],
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// DocType field labels translated by Frappe
|
|
155
|
+
expect(instance.t("Pokemon Name")).toBe("Nombre del Pokémon");
|
|
156
|
+
expect(instance.t("Species")).toBe("Especie");
|
|
157
|
+
expect(instance.t("Height")).toBe("Altura");
|
|
158
|
+
expect(instance.t("Weight")).toBe("Peso");
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("falls back to English local resource when Frappe has no translation for a key", async () => {
|
|
162
|
+
const mockBackend = createMockFrappeBackend({
|
|
163
|
+
es: { Save: "Guardar" }, // Only one key translated by Frappe
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
const instance = await initWithFrappeBackend(mockBackend, {
|
|
167
|
+
lng: "es",
|
|
168
|
+
supportedLngs: ["en", "es"],
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// Key from Frappe
|
|
172
|
+
expect(instance.t("Save")).toBe("Guardar");
|
|
173
|
+
// Key NOT in Frappe's dict → falls back to English local resource (identity mapping)
|
|
174
|
+
expect(instance.t("Filters")).toBe("Filters");
|
|
175
|
+
expect(instance.t("common.appName")).toBe("multiplatform.one");
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("switching language triggers a new backend fetch", async () => {
|
|
179
|
+
const mockBackend = createMockFrappeBackend({
|
|
180
|
+
es: frappeEsTranslations,
|
|
181
|
+
te: { Save: "సేవ్ చేయండి", Filters: "ఫిల్టర్లు" },
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// Start in English (bundled), then switch to Spanish (backend)
|
|
185
|
+
const instance = await initWithFrappeBackend(mockBackend, { lng: "en" });
|
|
186
|
+
|
|
187
|
+
// Initially English from bundled resources
|
|
188
|
+
expect(instance.t("Save")).toBe("Save");
|
|
189
|
+
|
|
190
|
+
// Switch to Spanish — triggers backend fetch
|
|
191
|
+
await instance.changeLanguage("es");
|
|
192
|
+
expect(instance.t("Save")).toBe("Guardar");
|
|
193
|
+
expect(mockBackend.fetchCalls.some((c) => c.language === "es")).toBe(true);
|
|
194
|
+
|
|
195
|
+
// Switch to Telugu — triggers another backend fetch
|
|
196
|
+
await instance.changeLanguage("te");
|
|
197
|
+
expect(instance.t("Save")).toBe("సేవ్ చేయండి");
|
|
198
|
+
expect(instance.t("Filters")).toBe("ఫిల్టర్లు");
|
|
199
|
+
expect(mockBackend.fetchCalls.some((c) => c.language === "te")).toBe(true);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("bundled languages use local resources; backend only serves non-bundled languages", async () => {
|
|
203
|
+
// Architecture: en and te are bundled locally. Spanish is not bundled,
|
|
204
|
+
// so Frappe backend provides it. The backend is NOT called for bundled languages.
|
|
205
|
+
const mockBackend = createMockFrappeBackend({
|
|
206
|
+
es: frappeEsTranslations,
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
const instance = await initWithFrappeBackend(mockBackend, {
|
|
210
|
+
lng: "te",
|
|
211
|
+
// Telugu is fully bundled — backend should NOT be called for it
|
|
212
|
+
bundledResources: {
|
|
213
|
+
en: { common: enCommon },
|
|
214
|
+
te: { common: teCommon },
|
|
215
|
+
},
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
// Backend was NOT called for Telugu (it's bundled)
|
|
219
|
+
expect(mockBackend.fetchCalls.some((c) => c.language === "te")).toBe(false);
|
|
220
|
+
|
|
221
|
+
// Telugu translations come from local bundled resources
|
|
222
|
+
expect(instance.t("Save")).toBe("సేవ్ చేయండి");
|
|
223
|
+
expect(instance.t("Filters")).toBe("ఫిల్టర్లు");
|
|
224
|
+
expect(instance.t("Delete")).toBe("తొలగించు");
|
|
225
|
+
|
|
226
|
+
// Switch to Spanish (not bundled) — backend provides translations
|
|
227
|
+
await instance.changeLanguage("es");
|
|
228
|
+
expect(mockBackend.fetchCalls.some((c) => c.language === "es")).toBe(true);
|
|
229
|
+
expect(instance.t("Save")).toBe("Guardar");
|
|
230
|
+
expect(instance.t("Filters")).toBe("Filtros");
|
|
231
|
+
});
|
|
232
|
+
});
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 10.3 Cross-group integration test: Language persistence roundtrip
|
|
3
|
+
*
|
|
4
|
+
* Verifies end-to-end language persistence:
|
|
5
|
+
* - Change language → cookie set → simulate reload → same language restored
|
|
6
|
+
* - Change language → Frappe API receives new language context
|
|
7
|
+
* - SSR detection → correct language from Accept-Language
|
|
8
|
+
*/
|
|
9
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
10
|
+
import i18next from "i18next";
|
|
11
|
+
import { persistLanguage, getPersistedLanguage, parseCookieValue } from "../src/localePersistence";
|
|
12
|
+
import { detectLanguage } from "../src/detectLanguage";
|
|
13
|
+
import enCommon from "../../../packages/i18n/en/common.json";
|
|
14
|
+
import teCommon from "../../../packages/i18n/te/common.json";
|
|
15
|
+
|
|
16
|
+
describe("10.3 Language persistence roundtrip", () => {
|
|
17
|
+
let cookieStore: string;
|
|
18
|
+
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
cookieStore = "";
|
|
21
|
+
Object.defineProperty(globalThis, "document", {
|
|
22
|
+
value: {
|
|
23
|
+
get cookie() {
|
|
24
|
+
return cookieStore;
|
|
25
|
+
},
|
|
26
|
+
set cookie(val: string) {
|
|
27
|
+
const name = val.split("=")[0];
|
|
28
|
+
const parts = cookieStore.split("; ").filter((c) => c && !c.startsWith(`${name}=`));
|
|
29
|
+
parts.push(val.split(";")[0]);
|
|
30
|
+
cookieStore = parts.filter(Boolean).join("; ");
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
writable: true,
|
|
34
|
+
configurable: true,
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
afterEach(() => {
|
|
39
|
+
Object.defineProperty(globalThis, "document", {
|
|
40
|
+
value: undefined,
|
|
41
|
+
writable: true,
|
|
42
|
+
configurable: true,
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("change language → cookie set → simulate reload → same language restored", async () => {
|
|
47
|
+
// Step 1: Initialize i18next with English
|
|
48
|
+
const instance1 = i18next.createInstance();
|
|
49
|
+
await instance1.init({
|
|
50
|
+
lng: "en",
|
|
51
|
+
defaultNS: "common",
|
|
52
|
+
ns: ["common"],
|
|
53
|
+
supportedLngs: ["en", "te"],
|
|
54
|
+
fallbackLng: "en",
|
|
55
|
+
resources: {
|
|
56
|
+
en: { common: enCommon },
|
|
57
|
+
te: { common: teCommon },
|
|
58
|
+
},
|
|
59
|
+
interpolation: { escapeValue: false },
|
|
60
|
+
returnNull: false,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
expect(instance1.language).toBe("en");
|
|
64
|
+
expect(instance1.t("Save")).toBe("Save");
|
|
65
|
+
|
|
66
|
+
// Step 2: Change language to Telugu and persist (simulates useLanguage.changeLanguage)
|
|
67
|
+
persistLanguage("te");
|
|
68
|
+
await instance1.changeLanguage("te");
|
|
69
|
+
|
|
70
|
+
expect(instance1.language).toBe("te");
|
|
71
|
+
expect(instance1.t("Save")).toBe("సేవ్ చేయండి");
|
|
72
|
+
|
|
73
|
+
// Verify cookie is set
|
|
74
|
+
expect(getPersistedLanguage()).toBe("te");
|
|
75
|
+
|
|
76
|
+
// Step 3: Simulate "reload" — create a new i18next instance and read persisted language
|
|
77
|
+
const persistedLang = getPersistedLanguage();
|
|
78
|
+
expect(persistedLang).toBe("te");
|
|
79
|
+
|
|
80
|
+
const instance2 = i18next.createInstance();
|
|
81
|
+
await instance2.init({
|
|
82
|
+
lng: persistedLang!, // Use the persisted language
|
|
83
|
+
defaultNS: "common",
|
|
84
|
+
ns: ["common"],
|
|
85
|
+
supportedLngs: ["en", "te"],
|
|
86
|
+
fallbackLng: "en",
|
|
87
|
+
resources: {
|
|
88
|
+
en: { common: enCommon },
|
|
89
|
+
te: { common: teCommon },
|
|
90
|
+
},
|
|
91
|
+
interpolation: { escapeValue: false },
|
|
92
|
+
returnNull: false,
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// Language is restored from cookie
|
|
96
|
+
expect(instance2.language).toBe("te");
|
|
97
|
+
expect(instance2.t("Save")).toBe("సేవ్ చేయండి");
|
|
98
|
+
expect(instance2.t("Filters")).toBe("ఫిల్టర్లు");
|
|
99
|
+
expect(instance2.t("Delete")).toBe("తొలగించు");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("language change updates cookie that Frappe API can read from request", async () => {
|
|
103
|
+
// Simulate language change
|
|
104
|
+
persistLanguage("es");
|
|
105
|
+
expect(getPersistedLanguage()).toBe("es");
|
|
106
|
+
|
|
107
|
+
// The preferred_language cookie is included in HTTP requests (via credentials: "include")
|
|
108
|
+
// Frappe reads this cookie to determine the user's language preference.
|
|
109
|
+
// Verify the cookie value is parseable from a simulated request cookie header.
|
|
110
|
+
const simulatedCookieHeader = `session_id=abc123; ${cookieStore}`;
|
|
111
|
+
const parsedLang = parseCookieValue(simulatedCookieHeader, "preferred_language");
|
|
112
|
+
expect(parsedLang).toBe("es");
|
|
113
|
+
|
|
114
|
+
// Change language again
|
|
115
|
+
persistLanguage("te");
|
|
116
|
+
const updatedCookieHeader = `session_id=abc123; ${cookieStore}`;
|
|
117
|
+
const updatedLang = parseCookieValue(updatedCookieHeader, "preferred_language");
|
|
118
|
+
expect(updatedLang).toBe("te");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("SSR detects language from cookie in Request headers", () => {
|
|
122
|
+
const supported = ["en", "es", "te"];
|
|
123
|
+
const opts = { supportedLanguages: supported, defaultLanguage: "en" };
|
|
124
|
+
|
|
125
|
+
// User previously set Telugu
|
|
126
|
+
const req = new Request("http://localhost:8000/", {
|
|
127
|
+
headers: {
|
|
128
|
+
cookie: "preferred_language=te; session_id=xyz",
|
|
129
|
+
"accept-language": "en-US,en;q=0.9",
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const detected = detectLanguage(req, opts);
|
|
134
|
+
// Cookie takes precedence over Accept-Language
|
|
135
|
+
expect(detected).toBe("te");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("SSR falls back to Accept-Language when no cookie is set", () => {
|
|
139
|
+
const supported = ["en", "es", "te"];
|
|
140
|
+
const opts = { supportedLanguages: supported, defaultLanguage: "en" };
|
|
141
|
+
|
|
142
|
+
const req = new Request("http://localhost:8000/", {
|
|
143
|
+
headers: {
|
|
144
|
+
"accept-language": "es-MX;q=1.0, en;q=0.5",
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
const detected = detectLanguage(req, opts);
|
|
149
|
+
expect(detected).toBe("es");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("full roundtrip: persist → SSR detect → initialize with correct language", async () => {
|
|
153
|
+
// Step 1: User changes language on the client
|
|
154
|
+
persistLanguage("te");
|
|
155
|
+
|
|
156
|
+
// Step 2: On next request, SSR reads cookie from the request
|
|
157
|
+
const simulatedRequest = new Request("http://localhost:8000/", {
|
|
158
|
+
headers: {
|
|
159
|
+
cookie: cookieStore,
|
|
160
|
+
"accept-language": "en-US,en;q=0.9",
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
const detected = detectLanguage(simulatedRequest, {
|
|
165
|
+
supportedLanguages: ["en", "te", "es"],
|
|
166
|
+
defaultLanguage: "en",
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
expect(detected).toBe("te");
|
|
170
|
+
|
|
171
|
+
// Step 3: SSR initializes i18next with detected language
|
|
172
|
+
const instance = i18next.createInstance();
|
|
173
|
+
await instance.init({
|
|
174
|
+
lng: detected,
|
|
175
|
+
defaultNS: "common",
|
|
176
|
+
ns: ["common"],
|
|
177
|
+
supportedLngs: ["en", "te"],
|
|
178
|
+
fallbackLng: "en",
|
|
179
|
+
resources: {
|
|
180
|
+
en: { common: enCommon },
|
|
181
|
+
te: { common: teCommon },
|
|
182
|
+
},
|
|
183
|
+
interpolation: { escapeValue: false },
|
|
184
|
+
returnNull: false,
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// SSR renders in the user's chosen language
|
|
188
|
+
expect(instance.language).toBe("te");
|
|
189
|
+
expect(instance.t("Save")).toBe("సేవ్ చేయండి");
|
|
190
|
+
expect(instance.t("Filters")).toBe("ఫిల్టర్లు");
|
|
191
|
+
expect(instance.t("common.appName")).toBe("multiplatform.one");
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("language change triggers Frappe API to receive new language context via cookie", async () => {
|
|
195
|
+
// Simulate the sequence:
|
|
196
|
+
// 1. User is in English
|
|
197
|
+
// 2. User switches to Telugu
|
|
198
|
+
// 3. Next HTTP request includes preferred_language=te cookie
|
|
199
|
+
persistLanguage("en");
|
|
200
|
+
expect(getPersistedLanguage()).toBe("en");
|
|
201
|
+
|
|
202
|
+
// Simulate: user clicks "Telugu" in language switcher
|
|
203
|
+
persistLanguage("te");
|
|
204
|
+
expect(getPersistedLanguage()).toBe("te");
|
|
205
|
+
|
|
206
|
+
// Simulate: next API call includes cookies
|
|
207
|
+
// The HttpClient sends requests with credentials: "include",
|
|
208
|
+
// so the preferred_language cookie is automatically included.
|
|
209
|
+
const mockFetch = vi.fn().mockResolvedValue({
|
|
210
|
+
ok: true,
|
|
211
|
+
json: () =>
|
|
212
|
+
Promise.resolve({
|
|
213
|
+
data: [
|
|
214
|
+
{ name: "Pikachu", species: "పికాచు" }, // Telugu metadata from Frappe
|
|
215
|
+
],
|
|
216
|
+
}),
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
// Simulate an API call that would be made after language change
|
|
220
|
+
await mockFetch("/api/resource/Pokemon", {
|
|
221
|
+
credentials: "include",
|
|
222
|
+
headers: {
|
|
223
|
+
"Accept-Language": "te",
|
|
224
|
+
Cookie: cookieStore,
|
|
225
|
+
},
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
// Verify the API call was made with language context
|
|
229
|
+
expect(mockFetch).toHaveBeenCalledWith("/api/resource/Pokemon", {
|
|
230
|
+
credentials: "include",
|
|
231
|
+
headers: expect.objectContaining({
|
|
232
|
+
"Accept-Language": "te",
|
|
233
|
+
}),
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
// The cookie header contains preferred_language=te
|
|
237
|
+
const callArgs = mockFetch.mock.calls[0][1];
|
|
238
|
+
const cookieVal = parseCookieValue(callArgs.headers.Cookie, "preferred_language");
|
|
239
|
+
expect(cookieVal).toBe("te");
|
|
240
|
+
});
|
|
241
|
+
});
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 10.5 Bundle size / tree-shaking verification
|
|
3
|
+
*
|
|
4
|
+
* Verifies that the Frappe backend plugin code is structured to tree-shake
|
|
5
|
+
* out of non-Frappe builds:
|
|
6
|
+
* - createFrappeBackend is guarded by process.env.VITE_FRAPPE_ENABLED
|
|
7
|
+
* - When VITE_FRAPPE_ENABLED=false, createFrappeBackend returns null
|
|
8
|
+
* - createI18nConfig does not attach backend when VITE_FRAPPE_ENABLED=false
|
|
9
|
+
* - The Frappe API URL telltale string is inside the guarded code path
|
|
10
|
+
* - The module source structure enables dead-code elimination
|
|
11
|
+
*/
|
|
12
|
+
import { describe, it, expect } from "vitest";
|
|
13
|
+
import { readFileSync } from "node:fs";
|
|
14
|
+
import { resolve } from "node:path";
|
|
15
|
+
import { createFrappeBackend } from "../src/frappeBackend";
|
|
16
|
+
import { createI18nConfig } from "../src/createI18nConfig";
|
|
17
|
+
import type { BackendModule } from "i18next";
|
|
18
|
+
|
|
19
|
+
describe("10.5 Tree-shaking & bundle verification", () => {
|
|
20
|
+
describe("runtime guards", () => {
|
|
21
|
+
it("createFrappeBackend returns null when VITE_FRAPPE_ENABLED is false", () => {
|
|
22
|
+
// In the test environment, VITE_FRAPPE_ENABLED defaults to "false"
|
|
23
|
+
const backend = createFrappeBackend();
|
|
24
|
+
expect(backend).toBeNull();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("createI18nConfig does not attach backend when disabled", () => {
|
|
28
|
+
const config = createI18nConfig({
|
|
29
|
+
languages: ["en"],
|
|
30
|
+
namespaces: ["common"],
|
|
31
|
+
defaultLanguage: "en",
|
|
32
|
+
defaultNamespace: "common",
|
|
33
|
+
resources: { en: { common: {} } },
|
|
34
|
+
frappeBackend: null,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
expect(config.backend).toBeUndefined();
|
|
38
|
+
expect(config.partialBundledLanguages).toBeUndefined();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("createI18nConfig omits backend even when a non-null backend is passed but flag is false", () => {
|
|
42
|
+
const mockBackend: BackendModule = {
|
|
43
|
+
type: "backend",
|
|
44
|
+
init() {},
|
|
45
|
+
read() {},
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const config = createI18nConfig({
|
|
49
|
+
languages: ["en"],
|
|
50
|
+
namespaces: ["common"],
|
|
51
|
+
defaultLanguage: "en",
|
|
52
|
+
defaultNamespace: "common",
|
|
53
|
+
resources: { en: { common: {} } },
|
|
54
|
+
frappeBackend: mockBackend,
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// Even though a backend was provided, it's not attached because VITE_FRAPPE_ENABLED=false
|
|
58
|
+
expect(config.backend).toBeUndefined();
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe("source code structure enables tree-shaking", () => {
|
|
63
|
+
it("frappeBackend.ts guards the entire backend creation with VITE_FRAPPE_ENABLED check", () => {
|
|
64
|
+
const source = readFileSync(resolve(__dirname, "../src/frappeBackend.ts"), "utf-8");
|
|
65
|
+
|
|
66
|
+
// The guard must be present — this is what allows bundlers to eliminate the code
|
|
67
|
+
expect(source).toContain("process.env.VITE_FRAPPE_ENABLED");
|
|
68
|
+
expect(source).toContain("return null");
|
|
69
|
+
|
|
70
|
+
// The Frappe API URL (telltale string) is INSIDE the guarded code path
|
|
71
|
+
expect(source).toContain("frappe.translate.get_dict");
|
|
72
|
+
|
|
73
|
+
// The guard pattern: returns null early when flag is not "true"
|
|
74
|
+
// This means when the bundler replaces VITE_FRAPPE_ENABLED with "false",
|
|
75
|
+
// the entire backend object literal becomes dead code.
|
|
76
|
+
expect(source).toMatch(/VITE_FRAPPE_ENABLED.*!==.*"true".*return null/s);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("createI18nConfig.ts guards backend attachment with VITE_FRAPPE_ENABLED check", () => {
|
|
80
|
+
const source = readFileSync(resolve(__dirname, "../src/createI18nConfig.ts"), "utf-8");
|
|
81
|
+
|
|
82
|
+
// The config conditionally includes backend only when enabled
|
|
83
|
+
expect(source).toContain("process.env.VITE_FRAPPE_ENABLED");
|
|
84
|
+
expect(source).toContain("partialBundledLanguages");
|
|
85
|
+
|
|
86
|
+
// The guard pattern: if block only executes when VITE_FRAPPE_ENABLED === "true"
|
|
87
|
+
expect(source).toMatch(/VITE_FRAPPE_ENABLED.*===.*"true".*frappeBackend/s);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("frappeBackend.ts does not have side effects at module level", () => {
|
|
91
|
+
const source = readFileSync(resolve(__dirname, "../src/frappeBackend.ts"), "utf-8");
|
|
92
|
+
|
|
93
|
+
// The fetch function is defined but not called at module level.
|
|
94
|
+
// Only createFrappeBackend() is exported, and it returns null when disabled.
|
|
95
|
+
// This ensures Vite/Rollup can tree-shake the entire module.
|
|
96
|
+
expect(source).not.toMatch(/^fetch\(/m);
|
|
97
|
+
expect(source).not.toMatch(/^await /m);
|
|
98
|
+
// The package.json has "sideEffects": false, confirming tree-shaking
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("package.json has sideEffects: false for tree-shaking", () => {
|
|
102
|
+
const pkg = JSON.parse(readFileSync(resolve(__dirname, "../package.json"), "utf-8"));
|
|
103
|
+
expect(pkg.sideEffects).toBe(false);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe("export structure", () => {
|
|
108
|
+
it("index.ts exports createFrappeBackend (tree-shakeable named export)", () => {
|
|
109
|
+
const source = readFileSync(resolve(__dirname, "../src/index.ts"), "utf-8");
|
|
110
|
+
|
|
111
|
+
// Named exports allow tree-shaking — consumers that don't import
|
|
112
|
+
// createFrappeBackend won't include it in their bundle
|
|
113
|
+
expect(source).toContain("createFrappeBackend");
|
|
114
|
+
|
|
115
|
+
// It's a named re-export, not a default export or namespace import
|
|
116
|
+
expect(source).toMatch(/export\s*\{.*createFrappeBackend.*\}/s);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("all i18n exports are named (not default) for optimal tree-shaking", () => {
|
|
120
|
+
const source = readFileSync(resolve(__dirname, "../src/index.ts"), "utf-8");
|
|
121
|
+
|
|
122
|
+
// No default export — all named exports are individually tree-shakeable
|
|
123
|
+
expect(source).not.toContain("export default");
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
});
|