@gravito/cosmos 3.1.0 → 3.2.1
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 +15 -0
- package/MIGRATION.md +331 -0
- package/package.json +22 -7
- package/scripts/check-coverage.ts +64 -0
- package/src/HMRWatcher.ts +305 -0
- package/src/I18nService.ts +151 -10
- package/src/index.edge.ts +35 -0
- package/src/index.node.ts +20 -0
- package/src/index.ts +14 -0
- package/src/loader.ts +31 -14
- package/src/loaders/ChainedLoader.ts +117 -0
- package/src/loaders/CloudflareKVLoader.ts +194 -0
- package/src/loaders/EdgeKVLoader.ts +248 -0
- package/src/loaders/FileSystemLoader.ts +125 -0
- package/src/loaders/MemoryLoader.ts +161 -0
- package/src/loaders/RemoteLoader.ts +235 -0
- package/src/loaders/TranslationLoader.ts +98 -0
- package/src/loaders/VercelKVLoader.ts +192 -0
- package/src/runtime/detector.ts +97 -0
- package/src/runtime/path-utils.ts +169 -0
- package/tests/unit/detector.test.ts +1 -6
- package/tests/unit/edge-kv-loader.test.ts +202 -0
- package/tests/unit/hmr.test.ts +255 -0
- package/tests/unit/loader.test.ts +57 -29
- package/tests/unit/loaders.test.ts +332 -0
- package/tests/unit/memory-loader.test.ts +130 -0
- package/tests/unit/path-utils.test.ts +135 -0
- package/tests/unit/runtime-detector.test.ts +86 -0
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file packages/cosmos/tests/unit/hmr.test.ts
|
|
3
|
+
* @description 測試 HMR (熱重載) 功能
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
|
|
7
|
+
import { mkdtempSync, writeFileSync } from 'node:fs'
|
|
8
|
+
import { rm, writeFile } from 'node:fs/promises'
|
|
9
|
+
import { tmpdir } from 'node:os'
|
|
10
|
+
import { join } from 'node:path'
|
|
11
|
+
import { HMRWatcher } from '../../src/HMRWatcher'
|
|
12
|
+
|
|
13
|
+
describe('HMRWatcher', () => {
|
|
14
|
+
let tmpDir: string
|
|
15
|
+
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
tmpDir = mkdtempSync(join(tmpdir(), 'gravito-hmr-test-'))
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
afterEach(async () => {
|
|
21
|
+
await rm(tmpDir, { force: true, recursive: true })
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('should detect file changes', async () => {
|
|
25
|
+
const filePath = join(tmpDir, 'zh-TW.json')
|
|
26
|
+
writeFileSync(filePath, JSON.stringify({ test: 'initial' }))
|
|
27
|
+
|
|
28
|
+
const changeEvents: Array<{ locale: string; type: string }> = []
|
|
29
|
+
|
|
30
|
+
const watcher = new HMRWatcher({
|
|
31
|
+
enabled: true,
|
|
32
|
+
watchDirs: [tmpDir],
|
|
33
|
+
debounce: 100,
|
|
34
|
+
verbose: false,
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
watcher.onChange((event) => {
|
|
38
|
+
changeEvents.push({
|
|
39
|
+
locale: event.locale,
|
|
40
|
+
type: event.type,
|
|
41
|
+
})
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
watcher.start()
|
|
45
|
+
|
|
46
|
+
// 等待監視器啟動
|
|
47
|
+
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
48
|
+
|
|
49
|
+
// 修改檔案
|
|
50
|
+
await writeFile(filePath, JSON.stringify({ test: 'updated' }))
|
|
51
|
+
|
|
52
|
+
// 等待防抖和事件處理
|
|
53
|
+
await new Promise((resolve) => setTimeout(resolve, 500))
|
|
54
|
+
|
|
55
|
+
watcher.stop()
|
|
56
|
+
|
|
57
|
+
expect(changeEvents.length).toBeGreaterThan(0)
|
|
58
|
+
expect(changeEvents[0].locale).toBe('zh-TW')
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('should not watch when disabled', async () => {
|
|
62
|
+
const watcher = new HMRWatcher({
|
|
63
|
+
enabled: false,
|
|
64
|
+
watchDirs: [tmpDir],
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
watcher.start()
|
|
68
|
+
expect(watcher.isActive()).toBe(false)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('should filter by file extension', async () => {
|
|
72
|
+
const jsonFile = join(tmpDir, 'en.json')
|
|
73
|
+
const txtFile = join(tmpDir, 'en.txt')
|
|
74
|
+
|
|
75
|
+
writeFileSync(jsonFile, '{}')
|
|
76
|
+
writeFileSync(txtFile, 'text')
|
|
77
|
+
|
|
78
|
+
const changeEvents: string[] = []
|
|
79
|
+
|
|
80
|
+
const watcher = new HMRWatcher({
|
|
81
|
+
enabled: true,
|
|
82
|
+
watchDirs: [tmpDir],
|
|
83
|
+
extensions: ['.json'],
|
|
84
|
+
debounce: 100,
|
|
85
|
+
verbose: false,
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
watcher.onChange((event) => {
|
|
89
|
+
changeEvents.push(event.filePath)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
watcher.start()
|
|
93
|
+
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
94
|
+
|
|
95
|
+
// 修改 JSON 檔案 (應該被檢測到)
|
|
96
|
+
await writeFile(jsonFile, '{"updated": true}')
|
|
97
|
+
await new Promise((resolve) => setTimeout(resolve, 300))
|
|
98
|
+
|
|
99
|
+
// 修改 TXT 檔案 (應該被忽略)
|
|
100
|
+
await writeFile(txtFile, 'updated text')
|
|
101
|
+
await new Promise((resolve) => setTimeout(resolve, 300))
|
|
102
|
+
|
|
103
|
+
watcher.stop()
|
|
104
|
+
|
|
105
|
+
// 只有 JSON 檔案的變更應該被檢測到
|
|
106
|
+
expect(changeEvents.some((path) => path.includes('.json'))).toBe(true)
|
|
107
|
+
expect(changeEvents.some((path) => path.includes('.txt'))).toBe(false)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('should support multiple listeners', async () => {
|
|
111
|
+
const filePath = join(tmpDir, 'test.json')
|
|
112
|
+
writeFileSync(filePath, '{}')
|
|
113
|
+
|
|
114
|
+
let listener1Called = false
|
|
115
|
+
let listener2Called = false
|
|
116
|
+
|
|
117
|
+
const watcher = new HMRWatcher({
|
|
118
|
+
enabled: true,
|
|
119
|
+
watchDirs: [tmpDir],
|
|
120
|
+
debounce: 100,
|
|
121
|
+
verbose: false,
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
watcher.onChange(() => {
|
|
125
|
+
listener1Called = true
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
watcher.onChange(() => {
|
|
129
|
+
listener2Called = true
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
watcher.start()
|
|
133
|
+
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
134
|
+
|
|
135
|
+
await writeFile(filePath, '{"updated": true}')
|
|
136
|
+
await new Promise((resolve) => setTimeout(resolve, 300))
|
|
137
|
+
|
|
138
|
+
watcher.stop()
|
|
139
|
+
|
|
140
|
+
expect(listener1Called).toBe(true)
|
|
141
|
+
expect(listener2Called).toBe(true)
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('should allow removing listeners', async () => {
|
|
145
|
+
const filePath = join(tmpDir, 'test.json')
|
|
146
|
+
writeFileSync(filePath, '{}')
|
|
147
|
+
|
|
148
|
+
let callCount = 0
|
|
149
|
+
const listener = () => {
|
|
150
|
+
callCount++
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const watcher = new HMRWatcher({
|
|
154
|
+
enabled: true,
|
|
155
|
+
watchDirs: [tmpDir],
|
|
156
|
+
debounce: 100,
|
|
157
|
+
verbose: false,
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
watcher.onChange(listener)
|
|
161
|
+
watcher.start()
|
|
162
|
+
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
163
|
+
|
|
164
|
+
// 第一次變更
|
|
165
|
+
await writeFile(filePath, '{"v": 1}')
|
|
166
|
+
await new Promise((resolve) => setTimeout(resolve, 300))
|
|
167
|
+
|
|
168
|
+
const initialCallCount = callCount
|
|
169
|
+
expect(initialCallCount).toBeGreaterThan(0)
|
|
170
|
+
|
|
171
|
+
// 移除監聽器
|
|
172
|
+
watcher.removeListener(listener)
|
|
173
|
+
|
|
174
|
+
// 第二次變更 (不應該觸發)
|
|
175
|
+
await writeFile(filePath, '{"v": 2}')
|
|
176
|
+
await new Promise((resolve) => setTimeout(resolve, 300))
|
|
177
|
+
|
|
178
|
+
watcher.stop()
|
|
179
|
+
|
|
180
|
+
// 移除後,callCount 應該不再增加
|
|
181
|
+
expect(callCount).toBe(initialCallCount)
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
it('should debounce rapid changes', async () => {
|
|
185
|
+
const filePath = join(tmpDir, 'rapid.json')
|
|
186
|
+
writeFileSync(filePath, '{}')
|
|
187
|
+
|
|
188
|
+
let callCount = 0
|
|
189
|
+
|
|
190
|
+
const watcher = new HMRWatcher({
|
|
191
|
+
enabled: true,
|
|
192
|
+
watchDirs: [tmpDir],
|
|
193
|
+
debounce: 200,
|
|
194
|
+
verbose: false,
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
watcher.onChange(() => {
|
|
198
|
+
callCount++
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
watcher.start()
|
|
202
|
+
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
203
|
+
|
|
204
|
+
// 快速連續修改多次
|
|
205
|
+
await writeFile(filePath, '{"v": 1}')
|
|
206
|
+
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
207
|
+
await writeFile(filePath, '{"v": 2}')
|
|
208
|
+
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
209
|
+
await writeFile(filePath, '{"v": 3}')
|
|
210
|
+
|
|
211
|
+
// 等待防抖完成
|
|
212
|
+
await new Promise((resolve) => setTimeout(resolve, 400))
|
|
213
|
+
|
|
214
|
+
watcher.stop()
|
|
215
|
+
|
|
216
|
+
// 由於防抖,應該只觸發一次或少數幾次,而不是每次修改都觸發
|
|
217
|
+
expect(callCount).toBeLessThan(3)
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
it('should handle errors in listeners gracefully', async () => {
|
|
221
|
+
const filePath = join(tmpDir, 'error-test.json')
|
|
222
|
+
writeFileSync(filePath, '{}')
|
|
223
|
+
|
|
224
|
+
const errorListener = () => {
|
|
225
|
+
throw new Error('Listener error')
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
let goodListenerCalled = false
|
|
229
|
+
const goodListener = () => {
|
|
230
|
+
goodListenerCalled = true
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const watcher = new HMRWatcher({
|
|
234
|
+
enabled: true,
|
|
235
|
+
watchDirs: [tmpDir],
|
|
236
|
+
debounce: 100,
|
|
237
|
+
verbose: false,
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
// 註冊會拋出錯誤的監聽器和正常的監聽器
|
|
241
|
+
watcher.onChange(errorListener)
|
|
242
|
+
watcher.onChange(goodListener)
|
|
243
|
+
|
|
244
|
+
watcher.start()
|
|
245
|
+
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
246
|
+
|
|
247
|
+
await writeFile(filePath, '{"updated": true}')
|
|
248
|
+
await new Promise((resolve) => setTimeout(resolve, 300))
|
|
249
|
+
|
|
250
|
+
watcher.stop()
|
|
251
|
+
|
|
252
|
+
// 即使有監聽器出錯,其他監聽器仍應該被呼叫
|
|
253
|
+
expect(goodListenerCalled).toBe(true)
|
|
254
|
+
})
|
|
255
|
+
})
|
|
@@ -1,44 +1,72 @@
|
|
|
1
|
-
import { describe, expect, it,
|
|
1
|
+
import { describe, expect, it, mock, spyOn } from 'bun:test'
|
|
2
2
|
import { mkdtempSync, writeFileSync } from 'node:fs'
|
|
3
3
|
import { rm } from 'node:fs/promises'
|
|
4
4
|
import { tmpdir } from 'node:os'
|
|
5
5
|
import { join } from 'node:path'
|
|
6
|
-
import {
|
|
6
|
+
import { I18nManager } from '../../src/I18nService'
|
|
7
|
+
import * as loader from '../../src/loader'
|
|
7
8
|
|
|
8
|
-
describe('
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
describe('I18n Loading', () => {
|
|
10
|
+
describe('Loading Coalescing', () => {
|
|
11
|
+
it('should coalesce concurrent load requests', async () => {
|
|
12
|
+
const mockLoader = mock(async () => {
|
|
13
|
+
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
14
|
+
return { title: 'Loaded' }
|
|
15
|
+
})
|
|
11
16
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
17
|
+
const manager = new I18nManager({
|
|
18
|
+
defaultLocale: 'en',
|
|
19
|
+
supportedLocales: ['en'],
|
|
20
|
+
lazyLoad: {
|
|
21
|
+
baseDir: '/tmp',
|
|
22
|
+
loader: mockLoader,
|
|
23
|
+
},
|
|
24
|
+
})
|
|
15
25
|
|
|
16
|
-
const
|
|
26
|
+
const p1 = manager.ensureLocale('en')
|
|
27
|
+
const p2 = manager.ensureLocale('en')
|
|
17
28
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
}
|
|
29
|
+
await Promise.all([p1, p2])
|
|
30
|
+
|
|
31
|
+
expect(mockLoader).toHaveBeenCalledTimes(1)
|
|
32
|
+
})
|
|
23
33
|
})
|
|
24
34
|
|
|
25
|
-
|
|
26
|
-
|
|
35
|
+
describe('loadTranslations', () => {
|
|
36
|
+
it('loads json translation files from a directory', async () => {
|
|
37
|
+
const tmpDir = mkdtempSync(join(tmpdir(), 'gravito-i18n-'))
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
writeFileSync(join(tmpDir, 'en.json'), JSON.stringify({ hello: 'Hello' }))
|
|
41
|
+
writeFileSync(join(tmpDir, 'zh.json'), JSON.stringify({ hello: '你好' }))
|
|
42
|
+
|
|
43
|
+
const translations = await loader.loadTranslations(tmpDir)
|
|
44
|
+
|
|
45
|
+
expect(translations.en.hello).toBe('Hello')
|
|
46
|
+
expect(translations.zh.hello).toBe('你好')
|
|
47
|
+
} finally {
|
|
48
|
+
await rm(tmpDir, { force: true, recursive: true })
|
|
49
|
+
}
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('skips invalid json files and returns empty for missing dir', async () => {
|
|
53
|
+
const tmpDir = mkdtempSync(join(tmpdir(), 'gravito-i18n-invalid-'))
|
|
27
54
|
|
|
28
|
-
|
|
29
|
-
|
|
55
|
+
const errorSpy = spyOn(console, 'error').mockImplementation(() => {})
|
|
56
|
+
const warnSpy = spyOn(console, 'warn').mockImplementation(() => {})
|
|
30
57
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
58
|
+
try {
|
|
59
|
+
writeFileSync(join(tmpDir, 'en.json'), '{invalid json}')
|
|
60
|
+
const translations = await loader.loadTranslations(tmpDir)
|
|
61
|
+
expect(translations.en).toBeUndefined()
|
|
35
62
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
63
|
+
const missing = await loader.loadTranslations(join(tmpDir, 'missing'))
|
|
64
|
+
expect(missing).toEqual({})
|
|
65
|
+
} finally {
|
|
66
|
+
errorSpy.mockRestore()
|
|
67
|
+
warnSpy.mockRestore()
|
|
68
|
+
await rm(tmpDir, { force: true, recursive: true })
|
|
69
|
+
}
|
|
70
|
+
})
|
|
43
71
|
})
|
|
44
72
|
})
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file packages/cosmos/tests/unit/loaders.test.ts
|
|
3
|
+
* @description 測試新的 TranslationLoader 實現
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'
|
|
7
|
+
import { mkdtempSync, writeFileSync } from 'node:fs'
|
|
8
|
+
import { rm } from 'node:fs/promises'
|
|
9
|
+
import { tmpdir } from 'node:os'
|
|
10
|
+
import { join } from 'node:path'
|
|
11
|
+
import type { TranslationMap } from '../../src/I18nService'
|
|
12
|
+
import { ChainedLoader } from '../../src/loaders/ChainedLoader'
|
|
13
|
+
import { FileSystemLoader } from '../../src/loaders/FileSystemLoader'
|
|
14
|
+
import { RemoteLoader } from '../../src/loaders/RemoteLoader'
|
|
15
|
+
|
|
16
|
+
describe('TranslationLoaders', () => {
|
|
17
|
+
describe('FileSystemLoader', () => {
|
|
18
|
+
let tmpDir: string
|
|
19
|
+
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
tmpDir = mkdtempSync(join(tmpdir(), 'gravito-cosmos-test-'))
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
afterEach(async () => {
|
|
25
|
+
await rm(tmpDir, { force: true, recursive: true })
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('should load translation from filesystem', async () => {
|
|
29
|
+
writeFileSync(
|
|
30
|
+
join(tmpDir, 'zh-TW.json'),
|
|
31
|
+
JSON.stringify({ welcome: '歡迎', auth: { login: '登入' } })
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
const loader = new FileSystemLoader({ baseDir: tmpDir })
|
|
35
|
+
const translations = await loader.load('zh-TW')
|
|
36
|
+
|
|
37
|
+
expect(translations).not.toBeNull()
|
|
38
|
+
expect(translations?.welcome).toBe('歡迎')
|
|
39
|
+
expect((translations?.auth as TranslationMap)?.login).toBe('登入')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('should return null for missing locale', async () => {
|
|
43
|
+
const loader = new FileSystemLoader({ baseDir: tmpDir })
|
|
44
|
+
const translations = await loader.load('non-existent')
|
|
45
|
+
|
|
46
|
+
expect(translations).toBeNull()
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('should support custom extension', async () => {
|
|
50
|
+
writeFileSync(join(tmpDir, 'en.txt'), JSON.stringify({ hello: 'Hello' }))
|
|
51
|
+
|
|
52
|
+
const loader = new FileSystemLoader({
|
|
53
|
+
baseDir: tmpDir,
|
|
54
|
+
extension: '.txt',
|
|
55
|
+
})
|
|
56
|
+
const translations = await loader.load('en')
|
|
57
|
+
|
|
58
|
+
expect(translations).not.toBeNull()
|
|
59
|
+
expect(translations?.hello).toBe('Hello')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('should support fallback loader', async () => {
|
|
63
|
+
const fallbackLoader = {
|
|
64
|
+
name: 'FallbackLoader',
|
|
65
|
+
load: async () => ({ fallback: 'Fallback Value' }),
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const loader = new FileSystemLoader({ baseDir: tmpDir }).fallback(fallbackLoader)
|
|
69
|
+
const translations = await loader.load('missing')
|
|
70
|
+
|
|
71
|
+
expect(translations).not.toBeNull()
|
|
72
|
+
expect(translations?.fallback).toBe('Fallback Value')
|
|
73
|
+
})
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
describe('RemoteLoader', () => {
|
|
77
|
+
it('should load translation from remote API', async () => {
|
|
78
|
+
// Mock fetch
|
|
79
|
+
const mockFetch = mock((url: string) => {
|
|
80
|
+
if (url.includes('zh-TW')) {
|
|
81
|
+
return Promise.resolve({
|
|
82
|
+
ok: true,
|
|
83
|
+
status: 200,
|
|
84
|
+
headers: new Map([['ETag', '"abc123"']]),
|
|
85
|
+
json: async () => ({ remote: '遠端翻譯' }),
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
return Promise.resolve({
|
|
89
|
+
ok: false,
|
|
90
|
+
status: 404,
|
|
91
|
+
})
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
globalThis.fetch = mockFetch as any
|
|
95
|
+
|
|
96
|
+
const loader = new RemoteLoader({
|
|
97
|
+
url: 'https://api.example.com/i18n/:locale',
|
|
98
|
+
})
|
|
99
|
+
const translations = await loader.load('zh-TW')
|
|
100
|
+
|
|
101
|
+
expect(translations).not.toBeNull()
|
|
102
|
+
expect(translations?.remote).toBe('遠端翻譯')
|
|
103
|
+
expect(mockFetch).toHaveBeenCalledTimes(1)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('should return null on HTTP error', async () => {
|
|
107
|
+
globalThis.fetch = mock(() =>
|
|
108
|
+
Promise.resolve({
|
|
109
|
+
ok: false,
|
|
110
|
+
status: 500,
|
|
111
|
+
})
|
|
112
|
+
) as any
|
|
113
|
+
|
|
114
|
+
const loader = new RemoteLoader({
|
|
115
|
+
url: 'https://api.example.com/i18n/:locale',
|
|
116
|
+
retries: 0, // 不重試以加快測試
|
|
117
|
+
})
|
|
118
|
+
const translations = await loader.load('en')
|
|
119
|
+
|
|
120
|
+
expect(translations).toBeNull()
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('should support ETag caching', async () => {
|
|
124
|
+
let callCount = 0
|
|
125
|
+
globalThis.fetch = mock((_url: string, options?: any) => {
|
|
126
|
+
callCount++
|
|
127
|
+
const ifNoneMatch = options?.headers?.['If-None-Match']
|
|
128
|
+
|
|
129
|
+
// 第一次請求
|
|
130
|
+
if (!ifNoneMatch) {
|
|
131
|
+
return Promise.resolve({
|
|
132
|
+
ok: true,
|
|
133
|
+
status: 200,
|
|
134
|
+
headers: new Map([['ETag', '"v1"']]),
|
|
135
|
+
json: async () => ({ version: '1.0' }),
|
|
136
|
+
})
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// 第二次請求帶 ETag,返回 304
|
|
140
|
+
return Promise.resolve({
|
|
141
|
+
ok: true,
|
|
142
|
+
status: 304,
|
|
143
|
+
headers: new Map([['ETag', '"v1"']]),
|
|
144
|
+
})
|
|
145
|
+
}) as any
|
|
146
|
+
|
|
147
|
+
const loader = new RemoteLoader({
|
|
148
|
+
url: 'https://api.example.com/i18n/:locale',
|
|
149
|
+
etagCache: true,
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
// 第一次載入
|
|
153
|
+
const translations1 = await loader.load('en')
|
|
154
|
+
expect(translations1?.version).toBe('1.0')
|
|
155
|
+
|
|
156
|
+
// 第二次載入 (應該使用快取)
|
|
157
|
+
const translations2 = await loader.load('en')
|
|
158
|
+
expect(translations2?.version).toBe('1.0')
|
|
159
|
+
expect(callCount).toBe(2)
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('should retry on failure', async () => {
|
|
163
|
+
let attemptCount = 0
|
|
164
|
+
globalThis.fetch = mock(() => {
|
|
165
|
+
attemptCount++
|
|
166
|
+
// 前兩次失敗,第三次成功
|
|
167
|
+
if (attemptCount < 3) {
|
|
168
|
+
return Promise.reject(new Error('Network error'))
|
|
169
|
+
}
|
|
170
|
+
return Promise.resolve({
|
|
171
|
+
ok: true,
|
|
172
|
+
status: 200,
|
|
173
|
+
headers: new Map(),
|
|
174
|
+
json: async () => ({ retry: 'success' }),
|
|
175
|
+
})
|
|
176
|
+
}) as any
|
|
177
|
+
|
|
178
|
+
const loader = new RemoteLoader({
|
|
179
|
+
url: 'https://api.example.com/i18n/:locale',
|
|
180
|
+
retries: 3,
|
|
181
|
+
retryDelay: 10, // 短延遲以加快測試
|
|
182
|
+
})
|
|
183
|
+
const translations = await loader.load('en')
|
|
184
|
+
|
|
185
|
+
expect(translations).not.toBeNull()
|
|
186
|
+
expect(translations?.retry).toBe('success')
|
|
187
|
+
expect(attemptCount).toBe(3)
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
it('should support custom headers', async () => {
|
|
191
|
+
let receivedHeaders: Record<string, string> = {}
|
|
192
|
+
|
|
193
|
+
globalThis.fetch = mock((_url: string, options?: any) => {
|
|
194
|
+
receivedHeaders = options?.headers || {}
|
|
195
|
+
return Promise.resolve({
|
|
196
|
+
ok: true,
|
|
197
|
+
status: 200,
|
|
198
|
+
headers: new Map(),
|
|
199
|
+
json: async () => ({}),
|
|
200
|
+
})
|
|
201
|
+
}) as any
|
|
202
|
+
|
|
203
|
+
const loader = new RemoteLoader({
|
|
204
|
+
url: 'https://api.example.com/i18n/:locale',
|
|
205
|
+
headers: {
|
|
206
|
+
Authorization: 'Bearer token123',
|
|
207
|
+
'X-Custom': 'value',
|
|
208
|
+
},
|
|
209
|
+
})
|
|
210
|
+
await loader.load('en')
|
|
211
|
+
|
|
212
|
+
expect(receivedHeaders.Authorization).toBe('Bearer token123')
|
|
213
|
+
expect(receivedHeaders['X-Custom']).toBe('value')
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
it('should support fallback loader when all retries fail', async () => {
|
|
217
|
+
globalThis.fetch = mock(() => Promise.reject(new Error('Network error'))) as any
|
|
218
|
+
|
|
219
|
+
const fallbackLoader = {
|
|
220
|
+
name: 'FallbackLoader',
|
|
221
|
+
load: async () => ({ fallback: 'value' }),
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const loader = new RemoteLoader({
|
|
225
|
+
url: 'https://api.example.com/i18n/:locale',
|
|
226
|
+
retries: 0,
|
|
227
|
+
}).fallback(fallbackLoader)
|
|
228
|
+
|
|
229
|
+
const translations = await loader.load('en')
|
|
230
|
+
expect(translations).not.toBeNull()
|
|
231
|
+
expect(translations?.fallback).toBe('value')
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
it('should clear ETag and translation cache', async () => {
|
|
235
|
+
globalThis.fetch = mock(() =>
|
|
236
|
+
Promise.resolve({
|
|
237
|
+
ok: true,
|
|
238
|
+
status: 200,
|
|
239
|
+
headers: new Map([['ETag', '"test"']]),
|
|
240
|
+
json: async () => ({ cached: true }),
|
|
241
|
+
})
|
|
242
|
+
) as any
|
|
243
|
+
|
|
244
|
+
const loader = new RemoteLoader({
|
|
245
|
+
url: 'https://api.example.com/i18n/:locale',
|
|
246
|
+
etagCache: true,
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
await loader.load('en')
|
|
250
|
+
loader.clearCache()
|
|
251
|
+
|
|
252
|
+
// clearCache 被呼叫後,內部快取應該已清除
|
|
253
|
+
// 這主要是為了覆蓋 clearCache 方法
|
|
254
|
+
expect(loader.name).toBe('RemoteLoader')
|
|
255
|
+
})
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
describe('ChainedLoader', () => {
|
|
259
|
+
it('should try loaders in sequence', async () => {
|
|
260
|
+
const loader1 = {
|
|
261
|
+
name: 'Loader1',
|
|
262
|
+
load: async () => null, // 失敗
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const loader2 = {
|
|
266
|
+
name: 'Loader2',
|
|
267
|
+
load: async () => ({ source: 'loader2' }), // 成功
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const loader3 = {
|
|
271
|
+
name: 'Loader3',
|
|
272
|
+
load: async () => ({ source: 'loader3' }), // 不應該被呼叫
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const chained = new ChainedLoader([loader1, loader2, loader3])
|
|
276
|
+
const translations = await chained.load('en')
|
|
277
|
+
|
|
278
|
+
expect(translations).not.toBeNull()
|
|
279
|
+
expect(translations?.source).toBe('loader2')
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
it('should return null if all loaders fail', async () => {
|
|
283
|
+
const loader1 = {
|
|
284
|
+
name: 'Loader1',
|
|
285
|
+
load: async () => null,
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const loader2 = {
|
|
289
|
+
name: 'Loader2',
|
|
290
|
+
load: async () => null,
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const chained = new ChainedLoader([loader1, loader2])
|
|
294
|
+
const translations = await chained.load('en')
|
|
295
|
+
|
|
296
|
+
expect(translations).toBeNull()
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
it('should support append and prepend', async () => {
|
|
300
|
+
const loader1 = { name: 'L1', load: async () => ({ order: '1' }) }
|
|
301
|
+
const loader2 = { name: 'L2', load: async () => null }
|
|
302
|
+
const loader3 = { name: 'L3', load: async () => null }
|
|
303
|
+
|
|
304
|
+
const chained = new ChainedLoader([loader2])
|
|
305
|
+
chained.prepend(loader3) // [loader3, loader2]
|
|
306
|
+
chained.append(loader1) // [loader3, loader2, loader1]
|
|
307
|
+
|
|
308
|
+
const loaders = chained.getLoaders()
|
|
309
|
+
expect(loaders[0].name).toBe('L3')
|
|
310
|
+
expect(loaders[1].name).toBe('L2')
|
|
311
|
+
expect(loaders[2].name).toBe('L1')
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
it('should support fallback loader', async () => {
|
|
315
|
+
const mainLoader = {
|
|
316
|
+
name: 'MainLoader',
|
|
317
|
+
load: async () => null,
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const fallbackLoader = {
|
|
321
|
+
name: 'FallbackLoader',
|
|
322
|
+
load: async () => ({ fallback: 'used' }),
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const chained = new ChainedLoader([mainLoader]).fallback(fallbackLoader)
|
|
326
|
+
const translations = await chained.load('en')
|
|
327
|
+
|
|
328
|
+
expect(translations).not.toBeNull()
|
|
329
|
+
expect(translations?.fallback).toBe('used')
|
|
330
|
+
})
|
|
331
|
+
})
|
|
332
|
+
})
|