Skip to content

Commit f613daa

Browse files
committed
feat: add custom i18n wrapper test case
1 parent 0e4b422 commit f613daa

File tree

10 files changed

+351
-0
lines changed

10 files changed

+351
-0
lines changed
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { appTools, defineConfig } from '@modern-js/app-tools';
2+
import { i18nPlugin } from '@modern-js/plugin-i18n';
3+
4+
export default defineConfig({
5+
performance: {
6+
buildCache: false,
7+
},
8+
server: {
9+
publicDir: './locales',
10+
},
11+
plugins: [
12+
appTools(),
13+
i18nPlugin({
14+
localeDetection: {
15+
languages: ['en', 'zh'],
16+
fallbackLanguage: 'en',
17+
localePathRedirect: true,
18+
i18nextDetector: true,
19+
},
20+
backend: {
21+
enabled: true,
22+
sdk: true,
23+
},
24+
}),
25+
],
26+
});
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
{
2+
"private": true,
3+
"name": "i18n-custom-i18n-wrapper",
4+
"version": "0.0.1",
5+
"scripts": {
6+
"dev": "modern dev",
7+
"build": "modern build"
8+
},
9+
"dependencies": {
10+
"i18next": "25.6.3",
11+
"react-i18next": "15.7.4",
12+
"react": "^19.2.0",
13+
"react-dom": "^19.2.0",
14+
"@modern-js/runtime": "workspace:*",
15+
"@modern-js/plugin-i18n": "workspace:*"
16+
},
17+
"devDependencies": {
18+
"@modern-js/app-tools": "workspace:*"
19+
20+
}
21+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { useModernI18n } from '@modern-js/plugin-i18n/runtime';
2+
import { useEffect, useState } from 'react';
3+
4+
export default function App() {
5+
const { language, changeLanguage, i18nInstance } = useModernI18n();
6+
const [text, setText] = useState('');
7+
8+
// 更新文本的函数
9+
const updateText = () => {
10+
if (i18nInstance) {
11+
setText((i18nInstance as any).t('key'));
12+
}
13+
};
14+
15+
useEffect(() => {
16+
// 初始更新
17+
updateText();
18+
19+
// 监听资源加载和语言变更事件
20+
const loadedHandler = () => {
21+
updateText();
22+
};
23+
const languageChangedHandler = () => {
24+
updateText();
25+
};
26+
27+
if (i18nInstance) {
28+
i18nInstance.on?.('loaded', loadedHandler);
29+
i18nInstance.on?.('languageChanged', languageChangedHandler);
30+
}
31+
32+
return () => {
33+
if (i18nInstance) {
34+
i18nInstance.off?.('loaded', loadedHandler);
35+
i18nInstance.off?.('languageChanged', languageChangedHandler);
36+
}
37+
};
38+
}, [i18nInstance]);
39+
40+
// 当语言改变时,立即更新文本(因为资源可能已经加载)
41+
useEffect(() => {
42+
updateText();
43+
}, [language, i18nInstance]);
44+
45+
return (
46+
<div>
47+
<h1 id="sdk-text">{text}</h1>
48+
<p id="current-lang">Current Language: {language}</p>
49+
<button id="switch-en" onClick={() => changeLanguage('en')}>
50+
Switch to EN
51+
</button>
52+
<button id="switch-zh" onClick={() => changeLanguage('zh')}>
53+
切换到中文
54+
</button>
55+
</div>
56+
);
57+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import i18next from 'i18next';
2+
3+
const instance = i18next.createInstance();
4+
5+
instance.init({
6+
lng: 'en',
7+
fallbackLng: 'en',
8+
resources: {
9+
en: {
10+
translation: {
11+
key: 'Hello World from HTTP',
12+
about: 'About page from HTTP',
13+
},
14+
},
15+
zh: {
16+
translation: {
17+
key: '你好,世界(HTTP)',
18+
about: '关于(HTTP)',
19+
},
20+
},
21+
},
22+
});
23+
24+
export default instance;
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import type { Resources } from '@modern-js/plugin-i18n/runtime';
2+
3+
const baseResources: Resources = {
4+
en: {
5+
translation: {
6+
key: 'Hello World from SDK',
7+
about: 'About page from SDK',
8+
},
9+
},
10+
zh: {
11+
translation: {
12+
key: '你好,世界(SDK)',
13+
about: '关于(SDK)',
14+
},
15+
},
16+
};
17+
18+
export function createMockSdkLoader() {
19+
return async (_options: any): Promise<Resources> => {
20+
console.log('mock sdk loader: fetching resources');
21+
await new Promise(resolve => setTimeout(resolve, 100));
22+
return baseResources;
23+
};
24+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
/// <reference types='@modern-js/app-tools/types' />
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { defineRuntimeConfig } from '@modern-js/runtime';
2+
import { createMockSdkLoader } from './mock-sdk';
3+
import { createStarlingWrapper } from './starlingWrapper';
4+
5+
const starlingWrapper = createStarlingWrapper();
6+
7+
export default defineRuntimeConfig({
8+
i18n: {
9+
i18nInstance: starlingWrapper,
10+
initOptions: {
11+
backend: {
12+
sdk: createMockSdkLoader(),
13+
},
14+
},
15+
},
16+
});
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import i18next from 'i18next';
2+
3+
export function createStarlingWrapper() {
4+
const inner = i18next.createInstance();
5+
6+
const wrapper = {
7+
i18nInstance: inner,
8+
plugins: [] as any[],
9+
ignoreWarning: false,
10+
init: async (config?: any, callback?: any) => {
11+
const result = await inner.init(config, callback);
12+
return { err: null, t: inner.t.bind(inner), ...result };
13+
},
14+
use(plugin: any) {
15+
if (!plugin) {
16+
return this;
17+
}
18+
if (typeof plugin.init === 'function') {
19+
plugin.init(inner);
20+
}
21+
this.plugins.push(plugin);
22+
inner.use(plugin);
23+
return this;
24+
},
25+
setLang(lng: string) {
26+
if (typeof inner.changeLanguage === 'function') {
27+
return inner.changeLanguage(lng);
28+
}
29+
return Promise.resolve();
30+
},
31+
changeLanguage(lng: string) {
32+
return inner.changeLanguage(lng);
33+
},
34+
get language() {
35+
return inner.language;
36+
},
37+
get languages() {
38+
return inner.languages;
39+
},
40+
t(...args: any[]) {
41+
return inner.t.apply(inner, args as any);
42+
},
43+
// 事件系统:转发到内部的 i18next 实例
44+
on(event: string, callback: (...args: any[]) => void) {
45+
if (typeof inner.on === 'function') {
46+
inner.on(event, callback);
47+
}
48+
return this;
49+
},
50+
off(event: string, callback?: (...args: any[]) => void) {
51+
if (typeof inner.off === 'function') {
52+
inner.off(event, callback);
53+
}
54+
return this;
55+
},
56+
emit(event: string, ...args: any[]) {
57+
if (typeof inner.emit === 'function') {
58+
inner.emit(event, ...args);
59+
}
60+
return this;
61+
},
62+
// 其他可能需要的属性
63+
get isInitialized() {
64+
return inner.isInitialized;
65+
},
66+
get options() {
67+
return inner.options;
68+
},
69+
get store() {
70+
return inner.store;
71+
},
72+
get services() {
73+
return inner.services;
74+
},
75+
reloadResources(lng?: string, ns?: string) {
76+
if (typeof inner.reloadResources === 'function') {
77+
return inner.reloadResources(lng, ns);
78+
}
79+
return Promise.resolve();
80+
},
81+
cloneInstance(opts?: any) {
82+
const cloned = inner.cloneInstance(opts);
83+
const clonedWrapper = createStarlingWrapper();
84+
clonedWrapper.i18nInstance = cloned;
85+
clonedWrapper.plugins = [...this.plugins];
86+
clonedWrapper.plugins.forEach((plugin: any) => {
87+
if (typeof plugin.init === 'function') {
88+
plugin.init(cloned);
89+
}
90+
});
91+
return clonedWrapper;
92+
},
93+
} as any;
94+
95+
return wrapper;
96+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import path from 'path';
2+
import puppeteer, { type Browser, type Page } from 'puppeteer';
3+
import {
4+
getPort,
5+
killApp,
6+
launchApp,
7+
launchOptions,
8+
} from '../../../../utils/modernTestUtils';
9+
10+
const projectDir = path.resolve(__dirname, '..');
11+
12+
describe('i18n-custom-i18n-wrapper', () => {
13+
let app: Awaited<ReturnType<typeof launchApp>>;
14+
let page: Page;
15+
let browser: Browser;
16+
let appPort: number;
17+
18+
beforeAll(async () => {
19+
appPort = await getPort();
20+
app = await launchApp(projectDir, appPort);
21+
browser = await puppeteer.launch(launchOptions as any);
22+
page = await browser.newPage();
23+
});
24+
25+
afterAll(async () => {
26+
if (browser) {
27+
await browser.close();
28+
}
29+
if (app) {
30+
await killApp(app);
31+
}
32+
});
33+
34+
const getText = async (selector: string) => {
35+
await page.waitForSelector(selector, { timeout: 5_000 });
36+
const el = await page.$(selector);
37+
return page.evaluate(elm => elm?.textContent?.trim(), el);
38+
};
39+
40+
test('loads HTTP resources first then refresh with SDK', async () => {
41+
await page.goto(`http://localhost:${appPort}/en`, {
42+
waitUntil: ['networkidle0'],
43+
});
44+
45+
const initialText = await getText('#sdk-text');
46+
expect(['Hello World from HTTP', 'Hello World from SDK']).toContain(
47+
initialText,
48+
);
49+
50+
await new Promise(resolve => setTimeout(resolve, 1500));
51+
expect(await getText('#sdk-text')).toBe('Hello World from SDK');
52+
});
53+
54+
test('language switch keeps SDK merge', async () => {
55+
await page.goto(`http://localhost:${appPort}/en`, {
56+
waitUntil: ['networkidle0'],
57+
});
58+
await new Promise(resolve => setTimeout(resolve, 1500));
59+
60+
await page.click('#switch-zh');
61+
await new Promise(resolve => setTimeout(resolve, 1500));
62+
const zhText = await getText('#sdk-text');
63+
expect(['你好,世界(HTTP)', '你好,世界(SDK)']).toContain(zhText);
64+
65+
await page.click('#switch-en');
66+
await new Promise(resolve => setTimeout(resolve, 1500));
67+
const backToEn = await getText('#sdk-text');
68+
expect(['Hello World from HTTP', 'Hello World from SDK']).toContain(
69+
backToEn,
70+
);
71+
});
72+
});
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
"extends": "@modern-js/tsconfig/base",
3+
"compilerOptions": {
4+
"declaration": false,
5+
"jsx": "preserve",
6+
"baseUrl": "./",
7+
"outDir": "dist",
8+
"paths": {
9+
"@/*": ["./src/*"],
10+
"@shared/*": ["./shared/*"]
11+
}
12+
},
13+
"include": ["src", "tests", "modern.config.ts"]
14+
}

0 commit comments

Comments
 (0)