adapt-authoring-core 2.5.0 → 3.0.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/.github/workflows/releases.yml +9 -24
- package/conf/config.schema.json +16 -0
- package/conf/deprecated-lang.json +4 -0
- package/conf/deprecated-logger.json +7 -0
- package/docs/configure-environment.md +78 -0
- package/docs/developer-workflow.md +649 -0
- package/docs/error-handling.md +49 -0
- package/docs/plugins/configuration.js +75 -0
- package/docs/plugins/configuration.md +14 -0
- package/docs/plugins/errors.js +22 -0
- package/docs/plugins/errorsref.md +9 -0
- package/errors/errors.json +87 -0
- package/errors/node-core.json +39 -0
- package/index.js +5 -0
- package/lib/AbstractModule.js +3 -13
- package/lib/AdaptError.js +57 -0
- package/lib/App.js +66 -51
- package/lib/Config.js +226 -0
- package/lib/DataCache.js +6 -0
- package/lib/DependencyLoader.js +46 -103
- package/lib/Errors.js +50 -0
- package/lib/Hook.js +2 -3
- package/lib/Lang.js +126 -0
- package/lib/Logger.js +149 -0
- package/lib/utils/getArgs.js +4 -6
- package/migrations/3.0.0-conf-migrate-lang-config.js +7 -0
- package/migrations/3.0.0-conf-migrate-logger-config.js +9 -0
- package/package.json +8 -25
- package/tests/AbstractModule.spec.js +4 -54
- package/tests/AdaptError.spec.js +62 -0
- package/tests/Config.spec.js +122 -0
- package/tests/DataCache.spec.js +84 -1
- package/tests/DependencyLoader.spec.js +61 -146
- package/tests/Errors.spec.js +91 -0
- package/tests/Lang.spec.js +116 -0
- package/tests/Logger.spec.js +187 -0
- package/tests/utils-getArgs.spec.js +2 -8
- package/tests/App.spec.js +0 -160
package/tests/DataCache.spec.js
CHANGED
|
@@ -1,7 +1,20 @@
|
|
|
1
|
-
import { describe, it } from 'node:test'
|
|
1
|
+
import { afterEach, beforeEach, describe, it } from 'node:test'
|
|
2
2
|
import assert from 'node:assert/strict'
|
|
3
|
+
import App from '../lib/App.js'
|
|
3
4
|
import DataCache from '../lib/DataCache.js'
|
|
4
5
|
|
|
6
|
+
function stubApp (mongodbStub, logCalls) {
|
|
7
|
+
const mockApp = {
|
|
8
|
+
waitForModule: async () => mongodbStub,
|
|
9
|
+
logger: {
|
|
10
|
+
log: (...args) => logCalls.push(args)
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
const original = Object.getOwnPropertyDescriptor(App, 'instance')
|
|
14
|
+
Object.defineProperty(App, 'instance', { get: () => mockApp, configurable: true })
|
|
15
|
+
return () => Object.defineProperty(App, 'instance', original)
|
|
16
|
+
}
|
|
17
|
+
|
|
5
18
|
describe('DataCache', () => {
|
|
6
19
|
describe('#prune()', () => {
|
|
7
20
|
it('should remove expired entries from the cache', () => {
|
|
@@ -36,4 +49,74 @@ describe('DataCache', () => {
|
|
|
36
49
|
assert.deepEqual(instance.cache, {})
|
|
37
50
|
})
|
|
38
51
|
})
|
|
52
|
+
|
|
53
|
+
describe('#get()', () => {
|
|
54
|
+
let findCalls
|
|
55
|
+
let logCalls
|
|
56
|
+
let restore
|
|
57
|
+
const mongoStub = {
|
|
58
|
+
find: async (...args) => {
|
|
59
|
+
findCalls.push(args)
|
|
60
|
+
return [{ _id: 'stub' }]
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
beforeEach(() => {
|
|
65
|
+
findCalls = []
|
|
66
|
+
logCalls = []
|
|
67
|
+
restore = stubApp(mongoStub, logCalls)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
afterEach(() => restore())
|
|
71
|
+
|
|
72
|
+
it('should query the DB and log a miss on first call', async () => {
|
|
73
|
+
const cache = new DataCache({ enable: true, lifespan: 10000 })
|
|
74
|
+
const result = await cache.get({ _id: '1' }, { collectionName: 'users' }, {})
|
|
75
|
+
assert.deepEqual(result, [{ _id: 'stub' }])
|
|
76
|
+
assert.equal(findCalls.length, 1)
|
|
77
|
+
assert.equal(cache.misses, 1)
|
|
78
|
+
assert.equal(cache.hits, 0)
|
|
79
|
+
assert.equal(logCalls[0][0], 'verbose')
|
|
80
|
+
assert.equal(logCalls[0][1], 'datacache')
|
|
81
|
+
assert.equal(logCalls[0][2], 'miss')
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('should return cached data and log a hit on a repeat call', async () => {
|
|
85
|
+
const cache = new DataCache({ enable: true, lifespan: 10000 })
|
|
86
|
+
await cache.get({ _id: '1' }, { collectionName: 'users' }, {})
|
|
87
|
+
await cache.get({ _id: '1' }, { collectionName: 'users' }, {})
|
|
88
|
+
assert.equal(findCalls.length, 1)
|
|
89
|
+
assert.equal(cache.hits, 1)
|
|
90
|
+
assert.equal(cache.misses, 1)
|
|
91
|
+
assert.equal(logCalls[1][2], 'hit')
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('should re-query the DB after an entry expires', async () => {
|
|
95
|
+
const cache = new DataCache({ enable: true, lifespan: 10 })
|
|
96
|
+
await cache.get({ _id: '1' }, { collectionName: 'users' }, {})
|
|
97
|
+
await new Promise(resolve => setTimeout(resolve, 20))
|
|
98
|
+
await cache.get({ _id: '1' }, { collectionName: 'users' }, {})
|
|
99
|
+
assert.equal(findCalls.length, 2)
|
|
100
|
+
assert.equal(cache.hits, 0)
|
|
101
|
+
assert.equal(cache.misses, 2)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('should always query the DB when the cache is disabled', async () => {
|
|
105
|
+
const cache = new DataCache({ enable: false, lifespan: 10000 })
|
|
106
|
+
await cache.get({ _id: '1' }, { collectionName: 'users' }, {})
|
|
107
|
+
await cache.get({ _id: '1' }, { collectionName: 'users' }, {})
|
|
108
|
+
assert.equal(findCalls.length, 2)
|
|
109
|
+
assert.equal(cache.hits, 0)
|
|
110
|
+
assert.equal(cache.misses, 2)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('should treat different queries as distinct cache entries', async () => {
|
|
114
|
+
const cache = new DataCache({ enable: true, lifespan: 10000 })
|
|
115
|
+
await cache.get({ _id: '1' }, { collectionName: 'users' }, {})
|
|
116
|
+
await cache.get({ _id: '2' }, { collectionName: 'users' }, {})
|
|
117
|
+
assert.equal(findCalls.length, 2)
|
|
118
|
+
assert.equal(cache.misses, 2)
|
|
119
|
+
assert.equal(cache.hits, 0)
|
|
120
|
+
})
|
|
121
|
+
})
|
|
39
122
|
})
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, it, before, after } from 'node:test'
|
|
2
2
|
import assert from 'node:assert/strict'
|
|
3
|
+
import AdaptError from '../lib/AdaptError.js'
|
|
3
4
|
import DependencyLoader from '../lib/DependencyLoader.js'
|
|
4
5
|
import fs from 'fs-extra'
|
|
5
6
|
import path from 'path'
|
|
@@ -62,12 +63,11 @@ describe('DependencyLoader', () => {
|
|
|
62
63
|
})
|
|
63
64
|
})
|
|
64
65
|
|
|
65
|
-
it('should call app.logger when available
|
|
66
|
+
it('should call app.logger when available', () => {
|
|
66
67
|
let logged = false
|
|
67
68
|
const mockApp = {
|
|
68
69
|
rootDir: '/test',
|
|
69
70
|
logger: {
|
|
70
|
-
_isReady: true, // Note: Mock uses private property to simulate ready state
|
|
71
71
|
log: () => { logged = true }
|
|
72
72
|
}
|
|
73
73
|
}
|
|
@@ -78,27 +78,11 @@ describe('DependencyLoader', () => {
|
|
|
78
78
|
assert.equal(logged, true)
|
|
79
79
|
})
|
|
80
80
|
|
|
81
|
-
it('should fall back to console.log when logger not ready', () => {
|
|
82
|
-
const mockApp = {
|
|
83
|
-
rootDir: '/test',
|
|
84
|
-
logger: {
|
|
85
|
-
_isReady: false, // Note: Mock uses private property to check ready state
|
|
86
|
-
log: () => {}
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
const loader = new DependencyLoader(mockApp)
|
|
90
|
-
|
|
91
|
-
assert.doesNotThrow(() => {
|
|
92
|
-
loader.log('info', 'test message')
|
|
93
|
-
})
|
|
94
|
-
})
|
|
95
|
-
|
|
96
81
|
it('should pass level and args to app.logger.log', () => {
|
|
97
82
|
let loggedLevel, loggedName, loggedArgs
|
|
98
83
|
const mockApp = {
|
|
99
84
|
rootDir: '/test',
|
|
100
85
|
logger: {
|
|
101
|
-
_isReady: true,
|
|
102
86
|
log: (level, name, ...args) => {
|
|
103
87
|
loggedLevel = level
|
|
104
88
|
loggedName = name
|
|
@@ -115,97 +99,6 @@ describe('DependencyLoader', () => {
|
|
|
115
99
|
})
|
|
116
100
|
})
|
|
117
101
|
|
|
118
|
-
describe('#logError()', () => {
|
|
119
|
-
it('should call log with error level', () => {
|
|
120
|
-
let loggedLevel
|
|
121
|
-
const mockApp = {
|
|
122
|
-
rootDir: '/test',
|
|
123
|
-
logger: {
|
|
124
|
-
_isReady: true,
|
|
125
|
-
log: (level) => { loggedLevel = level }
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
const loader = new DependencyLoader(mockApp)
|
|
129
|
-
|
|
130
|
-
loader.logError('error message')
|
|
131
|
-
|
|
132
|
-
assert.equal(loggedLevel, 'error')
|
|
133
|
-
})
|
|
134
|
-
|
|
135
|
-
it('should pass all arguments through to log', () => {
|
|
136
|
-
let loggedArgs
|
|
137
|
-
const mockApp = {
|
|
138
|
-
rootDir: '/test',
|
|
139
|
-
logger: {
|
|
140
|
-
_isReady: true,
|
|
141
|
-
log: (level, name, ...args) => { loggedArgs = args }
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
const loader = new DependencyLoader(mockApp)
|
|
145
|
-
loader.logError('msg1', 'msg2')
|
|
146
|
-
assert.deepEqual(loggedArgs, ['msg1', 'msg2'])
|
|
147
|
-
})
|
|
148
|
-
})
|
|
149
|
-
|
|
150
|
-
describe('#getConfig()', () => {
|
|
151
|
-
it('should return undefined when config is not ready', () => {
|
|
152
|
-
const mockApp = {
|
|
153
|
-
rootDir: '/test'
|
|
154
|
-
}
|
|
155
|
-
const loader = new DependencyLoader(mockApp)
|
|
156
|
-
|
|
157
|
-
const result = loader.getConfig('someKey')
|
|
158
|
-
|
|
159
|
-
assert.equal(result, undefined)
|
|
160
|
-
})
|
|
161
|
-
|
|
162
|
-
it('should return config value when config is ready', () => {
|
|
163
|
-
const mockApp = {
|
|
164
|
-
rootDir: '/test',
|
|
165
|
-
config: {
|
|
166
|
-
_isReady: true, // Note: Mock uses private property to simulate ready state
|
|
167
|
-
get: (key) => {
|
|
168
|
-
if (key === 'adapt-authoring-core.testKey') return 'testValue'
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
const loader = new DependencyLoader(mockApp)
|
|
173
|
-
|
|
174
|
-
const result = loader.getConfig('testKey')
|
|
175
|
-
|
|
176
|
-
assert.equal(result, 'testValue')
|
|
177
|
-
})
|
|
178
|
-
|
|
179
|
-
it('should return undefined when config exists but is not ready', () => {
|
|
180
|
-
const mockApp = {
|
|
181
|
-
rootDir: '/test',
|
|
182
|
-
config: {
|
|
183
|
-
_isReady: false,
|
|
184
|
-
get: () => 'should not be called'
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
const loader = new DependencyLoader(mockApp)
|
|
188
|
-
|
|
189
|
-
const result = loader.getConfig('someKey')
|
|
190
|
-
|
|
191
|
-
assert.equal(result, undefined)
|
|
192
|
-
})
|
|
193
|
-
|
|
194
|
-
it('should always use adapt-authoring-core prefix for config keys', () => {
|
|
195
|
-
let requestedKey
|
|
196
|
-
const mockApp = {
|
|
197
|
-
rootDir: '/test',
|
|
198
|
-
config: {
|
|
199
|
-
_isReady: true,
|
|
200
|
-
get: (key) => { requestedKey = key }
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
const loader = new DependencyLoader(mockApp)
|
|
204
|
-
loader.getConfig('myKey')
|
|
205
|
-
assert.equal(requestedKey, 'adapt-authoring-core.myKey')
|
|
206
|
-
})
|
|
207
|
-
})
|
|
208
|
-
|
|
209
102
|
describe('#loadConfigs()', () => {
|
|
210
103
|
let testRootDir
|
|
211
104
|
|
|
@@ -339,63 +232,72 @@ describe('DependencyLoader', () => {
|
|
|
339
232
|
})
|
|
340
233
|
|
|
341
234
|
describe('#loadModules()', () => {
|
|
342
|
-
it('should
|
|
235
|
+
it('should add non-fatal failures to failedModules', async () => {
|
|
343
236
|
const mockApp = { rootDir: '/test' }
|
|
344
237
|
const loader = new DependencyLoader(mockApp)
|
|
345
238
|
loader.configs = { 'nonexistent-module': { module: true, name: 'nonexistent-module' } }
|
|
346
239
|
|
|
347
|
-
await
|
|
348
|
-
loader.loadModules(['nonexistent-module']),
|
|
349
|
-
{ name: 'DependencyError' }
|
|
350
|
-
)
|
|
351
|
-
})
|
|
352
|
-
|
|
353
|
-
it('should not throw when module fails with force option', async () => {
|
|
354
|
-
const mockApp = { rootDir: '/test' }
|
|
355
|
-
const loader = new DependencyLoader(mockApp)
|
|
356
|
-
loader.configs = { 'nonexistent-module': { module: true, name: 'nonexistent-module' } }
|
|
357
|
-
|
|
358
|
-
await loader.loadModules(['nonexistent-module'], { force: true })
|
|
240
|
+
await loader.loadModules(['nonexistent-module'])
|
|
359
241
|
|
|
360
242
|
assert.ok(loader.failedModules.includes('nonexistent-module'))
|
|
361
243
|
})
|
|
362
244
|
|
|
363
|
-
it('should
|
|
245
|
+
it('should throw when module throws a fatal error', async () => {
|
|
364
246
|
const mockApp = { rootDir: '/test' }
|
|
365
247
|
const loader = new DependencyLoader(mockApp)
|
|
366
|
-
loader.configs = { '
|
|
367
|
-
loader.peerDependencies = { 'nonexistent-module': ['dependent-mod'] }
|
|
248
|
+
loader.configs = { 'fatal-module': { module: true, name: 'fatal-module' } }
|
|
368
249
|
|
|
369
|
-
|
|
250
|
+
// monkey-patch loadModule to throw a fatal error
|
|
251
|
+
const originalLoadModule = loader.loadModule.bind(loader)
|
|
252
|
+
loader.loadModule = async (name) => {
|
|
253
|
+
if (name === 'fatal-module') {
|
|
254
|
+
const error = new Error('Fatal')
|
|
255
|
+
error.isFatal = true
|
|
256
|
+
throw error
|
|
257
|
+
}
|
|
258
|
+
return originalLoadModule(name)
|
|
259
|
+
}
|
|
370
260
|
|
|
371
|
-
assert.
|
|
261
|
+
await assert.rejects(
|
|
262
|
+
loader.loadModules(['fatal-module']),
|
|
263
|
+
(err) => {
|
|
264
|
+
assert.equal(err.isFatal, true)
|
|
265
|
+
return true
|
|
266
|
+
}
|
|
267
|
+
)
|
|
372
268
|
})
|
|
373
269
|
|
|
374
|
-
it('should
|
|
270
|
+
it('should throw when error.cause is fatal', async () => {
|
|
375
271
|
const mockApp = { rootDir: '/test' }
|
|
376
272
|
const loader = new DependencyLoader(mockApp)
|
|
377
|
-
loader.configs = { '
|
|
273
|
+
loader.configs = { 'fatal-module': { module: true, name: 'fatal-module' } }
|
|
274
|
+
|
|
275
|
+
loader.loadModule = async () => {
|
|
276
|
+
const cause = new Error('Root cause')
|
|
277
|
+
cause.isFatal = true
|
|
278
|
+
const error = new Error('Wrapper')
|
|
279
|
+
error.cause = cause
|
|
280
|
+
throw error
|
|
281
|
+
}
|
|
378
282
|
|
|
379
283
|
await assert.rejects(
|
|
380
|
-
loader.loadModules(['
|
|
284
|
+
loader.loadModules(['fatal-module']),
|
|
381
285
|
(err) => {
|
|
382
|
-
assert.
|
|
286
|
+
assert.equal(err.cause.isFatal, true)
|
|
383
287
|
return true
|
|
384
288
|
}
|
|
385
289
|
)
|
|
386
290
|
})
|
|
387
291
|
|
|
388
|
-
it('should
|
|
292
|
+
it('should log peer dependency warnings on non-fatal failure', async () => {
|
|
389
293
|
const mockApp = { rootDir: '/test' }
|
|
390
294
|
const loader = new DependencyLoader(mockApp)
|
|
391
295
|
loader.configs = { 'nonexistent-module': { module: true, name: 'nonexistent-module' } }
|
|
296
|
+
loader.peerDependencies = { 'nonexistent-module': ['dependent-mod'] }
|
|
392
297
|
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
} catch (err) {
|
|
397
|
-
assert.ok(err.cause)
|
|
398
|
-
}
|
|
298
|
+
await loader.loadModules(['nonexistent-module'])
|
|
299
|
+
|
|
300
|
+
assert.ok(loader.failedModules.includes('nonexistent-module'))
|
|
399
301
|
})
|
|
400
302
|
|
|
401
303
|
it('should handle empty module list', async () => {
|
|
@@ -410,13 +312,18 @@ describe('DependencyLoader', () => {
|
|
|
410
312
|
|
|
411
313
|
describe('#loadModule()', () => {
|
|
412
314
|
it('should throw when module already exists', async () => {
|
|
413
|
-
const mockApp = {
|
|
315
|
+
const mockApp = {
|
|
316
|
+
rootDir: '/test',
|
|
317
|
+
errors: {
|
|
318
|
+
DEP_ALREADY_LOADED: new AdaptError('DEP_ALREADY_LOADED')
|
|
319
|
+
}
|
|
320
|
+
}
|
|
414
321
|
const loader = new DependencyLoader(mockApp)
|
|
415
322
|
loader.instances = { 'existing-module': {} }
|
|
416
323
|
|
|
417
324
|
await assert.rejects(
|
|
418
325
|
loader.loadModule('existing-module'),
|
|
419
|
-
{
|
|
326
|
+
{ code: 'DEP_ALREADY_LOADED' }
|
|
420
327
|
)
|
|
421
328
|
})
|
|
422
329
|
|
|
@@ -443,19 +350,29 @@ describe('DependencyLoader', () => {
|
|
|
443
350
|
|
|
444
351
|
describe('#waitForModule()', () => {
|
|
445
352
|
it('should throw for missing module', async () => {
|
|
446
|
-
const mockApp = {
|
|
353
|
+
const mockApp = {
|
|
354
|
+
rootDir: '/test',
|
|
355
|
+
errors: {
|
|
356
|
+
DEP_MISSING: new AdaptError('DEP_MISSING')
|
|
357
|
+
}
|
|
358
|
+
}
|
|
447
359
|
const loader = new DependencyLoader(mockApp)
|
|
448
360
|
loader._configsLoaded = true
|
|
449
361
|
loader.configs = {}
|
|
450
362
|
|
|
451
363
|
await assert.rejects(
|
|
452
364
|
loader.waitForModule('adapt-authoring-missing'),
|
|
453
|
-
{
|
|
365
|
+
{ code: 'DEP_MISSING' }
|
|
454
366
|
)
|
|
455
367
|
})
|
|
456
368
|
|
|
457
369
|
it('should throw for failed module', async () => {
|
|
458
|
-
const mockApp = {
|
|
370
|
+
const mockApp = {
|
|
371
|
+
rootDir: '/test',
|
|
372
|
+
errors: {
|
|
373
|
+
DEP_FAILED: new AdaptError('DEP_FAILED')
|
|
374
|
+
}
|
|
375
|
+
}
|
|
459
376
|
const loader = new DependencyLoader(mockApp)
|
|
460
377
|
loader._configsLoaded = true
|
|
461
378
|
loader.configs = { 'adapt-authoring-failed': { name: 'adapt-authoring-failed' } }
|
|
@@ -463,7 +380,7 @@ describe('DependencyLoader', () => {
|
|
|
463
380
|
|
|
464
381
|
await assert.rejects(
|
|
465
382
|
loader.waitForModule('adapt-authoring-failed'),
|
|
466
|
-
{
|
|
383
|
+
{ code: 'DEP_FAILED' }
|
|
467
384
|
)
|
|
468
385
|
})
|
|
469
386
|
|
|
@@ -601,7 +518,6 @@ describe('DependencyLoader', () => {
|
|
|
601
518
|
const mockApp = {
|
|
602
519
|
rootDir: '/test',
|
|
603
520
|
logger: {
|
|
604
|
-
_isReady: true,
|
|
605
521
|
log: (level, name, ...args) => {
|
|
606
522
|
if (typeof args[0] === 'object' && !Array.isArray(args[0]) && typeof args[0] !== 'string') {
|
|
607
523
|
loggedTimes = args[0]
|
|
@@ -628,7 +544,6 @@ describe('DependencyLoader', () => {
|
|
|
628
544
|
const mockApp = {
|
|
629
545
|
rootDir: '/test',
|
|
630
546
|
logger: {
|
|
631
|
-
_isReady: true,
|
|
632
547
|
log: (level, name, ...args) => {
|
|
633
548
|
if (typeof args[0] === 'string' && args[0] === 'LOAD') {
|
|
634
549
|
loggedMessage = args[1]
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { describe, it, before, after } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import Errors from '../lib/Errors.js'
|
|
4
|
+
import AdaptError from '../lib/AdaptError.js'
|
|
5
|
+
import fs from 'fs-extra'
|
|
6
|
+
import path from 'path'
|
|
7
|
+
import { fileURLToPath } from 'url'
|
|
8
|
+
|
|
9
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
10
|
+
|
|
11
|
+
describe('Errors', () => {
|
|
12
|
+
let testDir
|
|
13
|
+
|
|
14
|
+
before(async () => {
|
|
15
|
+
testDir = path.join(__dirname, 'data', 'errors-test')
|
|
16
|
+
const errorsDir = path.join(testDir, 'errors')
|
|
17
|
+
await fs.ensureDir(errorsDir)
|
|
18
|
+
await fs.writeJson(path.join(errorsDir, 'test.json'), {
|
|
19
|
+
TEST_ERROR: {
|
|
20
|
+
description: 'A test error',
|
|
21
|
+
statusCode: 400,
|
|
22
|
+
data: { id: 'The item ID' }
|
|
23
|
+
},
|
|
24
|
+
FATAL_ERROR: {
|
|
25
|
+
description: 'A fatal error',
|
|
26
|
+
statusCode: 500,
|
|
27
|
+
isFatal: true
|
|
28
|
+
}
|
|
29
|
+
})
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
after(async () => {
|
|
33
|
+
await fs.remove(testDir)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
describe('constructor', () => {
|
|
37
|
+
it('should load error definitions from dependencies', () => {
|
|
38
|
+
const deps = { test: { name: 'test', rootDir: testDir } }
|
|
39
|
+
const errors = new Errors({ dependencies: deps })
|
|
40
|
+
assert.ok(errors.TEST_ERROR)
|
|
41
|
+
assert.ok(errors.FATAL_ERROR)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('should return AdaptError instances', () => {
|
|
45
|
+
const deps = { test: { name: 'test', rootDir: testDir } }
|
|
46
|
+
const errors = new Errors({ dependencies: deps })
|
|
47
|
+
assert.ok(errors.TEST_ERROR instanceof AdaptError)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('should return fresh instances on each access', () => {
|
|
51
|
+
const deps = { test: { name: 'test', rootDir: testDir } }
|
|
52
|
+
const errors = new Errors({ dependencies: deps })
|
|
53
|
+
assert.notEqual(errors.TEST_ERROR, errors.TEST_ERROR)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('should set statusCode from definition', () => {
|
|
57
|
+
const deps = { test: { name: 'test', rootDir: testDir } }
|
|
58
|
+
const errors = new Errors({ dependencies: deps })
|
|
59
|
+
assert.equal(errors.TEST_ERROR.statusCode, 400)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('should set isFatal from definition', () => {
|
|
63
|
+
const deps = { test: { name: 'test', rootDir: testDir } }
|
|
64
|
+
const errors = new Errors({ dependencies: deps })
|
|
65
|
+
assert.equal(errors.FATAL_ERROR.isFatal, true)
|
|
66
|
+
assert.equal(errors.TEST_ERROR.isFatal, false)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('should warn on duplicate error codes', () => {
|
|
70
|
+
const dupDir = path.join(__dirname, 'data', 'errors-dup')
|
|
71
|
+
const errorsDir = path.join(dupDir, 'errors')
|
|
72
|
+
fs.ensureDirSync(errorsDir)
|
|
73
|
+
fs.writeJsonSync(path.join(errorsDir, 'dup.json'), {
|
|
74
|
+
TEST_ERROR: { description: 'duplicate', statusCode: 500 }
|
|
75
|
+
})
|
|
76
|
+
const deps = {
|
|
77
|
+
test: { name: 'test', rootDir: testDir },
|
|
78
|
+
dup: { name: 'dup', rootDir: dupDir }
|
|
79
|
+
}
|
|
80
|
+
let warned = false
|
|
81
|
+
new Errors({ dependencies: deps, log: () => { warned = true } }) // eslint-disable-line no-new
|
|
82
|
+
assert.ok(warned)
|
|
83
|
+
fs.removeSync(dupDir)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('should handle empty dependencies', () => {
|
|
87
|
+
const errors = new Errors({ dependencies: {} })
|
|
88
|
+
assert.deepEqual(Object.keys(errors), [])
|
|
89
|
+
})
|
|
90
|
+
})
|
|
91
|
+
})
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { describe, it, before, after } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import Lang from '../lib/Lang.js'
|
|
4
|
+
import fs from 'fs-extra'
|
|
5
|
+
import path from 'path'
|
|
6
|
+
import { fileURLToPath } from 'url'
|
|
7
|
+
|
|
8
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
9
|
+
|
|
10
|
+
function createLang (phrases, defaultLang = 'en') {
|
|
11
|
+
const lang = new Lang({ dependencies: {}, defaultLang, rootDir: __dirname })
|
|
12
|
+
lang.phrases = phrases
|
|
13
|
+
return lang
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe('Lang', () => {
|
|
17
|
+
let testDir
|
|
18
|
+
|
|
19
|
+
before(async () => {
|
|
20
|
+
testDir = path.join(__dirname, 'data', 'lang-test')
|
|
21
|
+
const langDir = path.join(testDir, 'lang')
|
|
22
|
+
await fs.ensureDir(langDir)
|
|
23
|
+
await fs.writeJson(path.join(langDir, 'en.json'), {
|
|
24
|
+
'app.name': 'Test App',
|
|
25
|
+
'app.greeting': 'Hello ${name}', // eslint-disable-line no-template-curly-in-string
|
|
26
|
+
'error.TEST_ERROR': 'A test error occurred'
|
|
27
|
+
})
|
|
28
|
+
await fs.writeJson(path.join(langDir, 'fr.json'), {
|
|
29
|
+
'app.name': 'Application Test'
|
|
30
|
+
})
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
after(async () => {
|
|
34
|
+
await fs.remove(testDir)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
describe('#loadPhrases()', () => {
|
|
38
|
+
it('should load phrases from dependencies', async () => {
|
|
39
|
+
const lang = new Lang()
|
|
40
|
+
await lang.loadPhrases({ test: { rootDir: testDir } }, testDir)
|
|
41
|
+
assert.ok(lang.phrases.en)
|
|
42
|
+
assert.equal(lang.phrases.en['app.name'], 'Test App')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('should load multiple languages', async () => {
|
|
46
|
+
const lang = new Lang()
|
|
47
|
+
await lang.loadPhrases({ test: { rootDir: testDir } }, testDir)
|
|
48
|
+
assert.ok(lang.phrases.fr)
|
|
49
|
+
assert.equal(lang.phrases.fr['app.name'], 'Application Test')
|
|
50
|
+
})
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
describe('#supportedLanguages', () => {
|
|
54
|
+
it('should return loaded language keys', async () => {
|
|
55
|
+
const lang = new Lang()
|
|
56
|
+
await lang.loadPhrases({ test: { rootDir: testDir } }, testDir)
|
|
57
|
+
const languages = lang.supportedLanguages
|
|
58
|
+
assert.ok(languages.includes('en'))
|
|
59
|
+
assert.ok(languages.includes('fr'))
|
|
60
|
+
})
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
describe('#translate()', () => {
|
|
64
|
+
it('should return translated string', () => {
|
|
65
|
+
const lang = createLang({ en: { hello: 'Hello' } })
|
|
66
|
+
assert.equal(lang.translate('en', 'hello'), 'Hello')
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('should substitute data placeholders', () => {
|
|
70
|
+
// eslint-disable-next-line no-template-curly-in-string
|
|
71
|
+
const lang = createLang({ en: { greeting: 'Hello ${name}' } })
|
|
72
|
+
assert.equal(lang.translate('en', 'greeting', { name: 'World' }), 'Hello World')
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('should fall back to default lang when lang is not a string', () => {
|
|
76
|
+
const lang = createLang({ en: { hello: 'Hello' } })
|
|
77
|
+
assert.equal(lang.translate(undefined, 'hello'), 'Hello')
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('should return key and warn when key is missing', () => {
|
|
81
|
+
let warned = false
|
|
82
|
+
const lang = createLang({ en: {} })
|
|
83
|
+
lang.log = () => { warned = true }
|
|
84
|
+
assert.equal(lang.translate('en', 'missing.key'), 'missing.key')
|
|
85
|
+
assert.ok(warned)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('should return non-error, non-string values unchanged', () => {
|
|
89
|
+
const lang = createLang({})
|
|
90
|
+
assert.equal(lang.translate('en', 42), 42)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('should translate an error using its code', () => {
|
|
94
|
+
const lang = createLang({ en: { 'error.TEST': 'Translated error' } })
|
|
95
|
+
const error = new Error('TEST')
|
|
96
|
+
error.code = 'TEST'
|
|
97
|
+
assert.equal(lang.translate('en', error), 'Translated error')
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('should fall back to error message when error has no code', () => {
|
|
101
|
+
const lang = createLang({ en: {} })
|
|
102
|
+
lang.log = () => {}
|
|
103
|
+
assert.equal(lang.translate('en', new Error('boom')), 'boom')
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('should substitute a code-less error in data using its message', () => {
|
|
107
|
+
// eslint-disable-next-line no-template-curly-in-string
|
|
108
|
+
const lang = createLang({ en: { 'error.OUTER': 'Failed: ${cause}' } })
|
|
109
|
+
lang.log = () => {}
|
|
110
|
+
const outer = new Error('OUTER')
|
|
111
|
+
outer.code = 'OUTER'
|
|
112
|
+
outer.data = { cause: new Error('disk full') }
|
|
113
|
+
assert.equal(lang.translate('en', outer), 'Failed: disk full')
|
|
114
|
+
})
|
|
115
|
+
})
|
|
116
|
+
})
|