adapt-authoring-core 1.8.0 → 1.9.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.
@@ -0,0 +1,15 @@
1
+ name: Tests
2
+ on: push
3
+ jobs:
4
+ default:
5
+ runs-on: ubuntu-latest
6
+ permissions:
7
+ contents: read
8
+ steps:
9
+ - uses: actions/checkout@v4
10
+ - uses: actions/setup-node@v4
11
+ with:
12
+ node-version: 'lts/*'
13
+ cache: 'npm'
14
+ - run: npm ci
15
+ - run: npm test
@@ -74,6 +74,7 @@ class AbstractModule {
74
74
  if (!error) {
75
75
  this._isReady = true
76
76
  }
77
+ /** @ignore */ this._initError = error
77
78
  this.initTime = Math.round((Date.now() - this._startTime))
78
79
  this.log('verbose', AbstractModule.MODULE_READY, this.initTime)
79
80
  await this.readyHook.invoke(error)
@@ -86,7 +87,10 @@ class AbstractModule {
86
87
  async onReady () {
87
88
  return new Promise((resolve, reject) => {
88
89
  if (this._isReady) {
89
- return this._initError ? reject(this._initError) : resolve(this)
90
+ return resolve(this)
91
+ }
92
+ if (this._initError) {
93
+ return reject(this._initError)
90
94
  }
91
95
  this.readyHook.tap(error => {
92
96
  /** @ignore */this._initError = error
package/lib/Hook.js CHANGED
@@ -58,7 +58,7 @@ class Hook {
58
58
  * @returns Promise
59
59
  */
60
60
  onInvoke () {
61
- return new Promise((resolve, reject) => this._hookObservers.push([resolve, reject]))
61
+ return new Promise((resolve, reject) => this._promiseObservers.push([resolve, reject]))
62
62
  }
63
63
 
64
64
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "adapt-authoring-core",
3
- "version": "1.8.0",
3
+ "version": "1.9.1",
4
4
  "description": "A bundle of reusable 'core' functionality",
5
5
  "homepage": "https://github.com/adapt-security/adapt-authoring-core",
6
6
  "license": "GPL-3.0",
@@ -66,6 +66,69 @@ describe('AbstractModule', () => {
66
66
  await module.readyHook.invoke()
67
67
  assert.equal(hookCalled, true)
68
68
  })
69
+
70
+ it('should use constructor name when pkg is undefined', async () => {
71
+ const mockApp = {
72
+ dependencyloader: {
73
+ moduleLoadedHook: {
74
+ tap: () => {},
75
+ untap: () => {}
76
+ }
77
+ }
78
+ }
79
+ const module = new AbstractModule(mockApp, undefined)
80
+ await module.onReady()
81
+ assert.equal(module.name, 'AbstractModule')
82
+ })
83
+
84
+ it('should use subclass constructor name when pkg.name is empty', async () => {
85
+ const mockApp = {
86
+ dependencyloader: {
87
+ moduleLoadedHook: {
88
+ tap: () => {},
89
+ untap: () => {}
90
+ }
91
+ }
92
+ }
93
+
94
+ class MyCustomModule extends AbstractModule {
95
+ async init () {}
96
+ }
97
+
98
+ const module = new MyCustomModule(mockApp, { name: '' })
99
+ await module.onReady()
100
+ assert.equal(module.name, 'MyCustomModule')
101
+ })
102
+
103
+ it('should set _startTime during construction', async () => {
104
+ const before = Date.now()
105
+ const mockApp = {
106
+ dependencyloader: {
107
+ moduleLoadedHook: {
108
+ tap: () => {},
109
+ untap: () => {}
110
+ }
111
+ }
112
+ }
113
+ const module = new AbstractModule(mockApp, { name: 'test' })
114
+ const after = Date.now()
115
+ await module.onReady()
116
+ assert.ok(module._startTime >= before)
117
+ assert.ok(module._startTime <= after)
118
+ })
119
+
120
+ it('should initialise initTime as undefined', () => {
121
+ const mockApp = {
122
+ dependencyloader: {
123
+ moduleLoadedHook: {
124
+ tap: () => {},
125
+ untap: () => {}
126
+ }
127
+ }
128
+ }
129
+ const module = new AbstractModule(mockApp, { name: 'test' })
130
+ assert.equal(module.initTime, undefined)
131
+ })
69
132
  })
70
133
 
71
134
  describe('#init()', () => {
@@ -111,6 +174,43 @@ describe('AbstractModule', () => {
111
174
  const module = new TestModule(mockApp, { name: 'test' })
112
175
  await assert.rejects(module.onReady(), { message: 'init error' })
113
176
  })
177
+
178
+ it('should handle async init that resolves', async () => {
179
+ const mockApp = {
180
+ dependencyloader: {
181
+ moduleLoadedHook: {
182
+ tap: () => {},
183
+ untap: () => {}
184
+ }
185
+ }
186
+ }
187
+ let completed = false
188
+
189
+ class TestModule extends AbstractModule {
190
+ async init () {
191
+ await new Promise(resolve => setTimeout(resolve, 5))
192
+ completed = true
193
+ }
194
+ }
195
+
196
+ const module = new TestModule(mockApp, { name: 'test' })
197
+ await module.onReady()
198
+ assert.equal(completed, true)
199
+ })
200
+
201
+ it('should default init to a no-op that resolves', async () => {
202
+ const mockApp = {
203
+ dependencyloader: {
204
+ moduleLoadedHook: {
205
+ tap: () => {},
206
+ untap: () => {}
207
+ }
208
+ }
209
+ }
210
+ const module = new AbstractModule(mockApp, { name: 'test' })
211
+ await module.onReady()
212
+ assert.equal(module._isReady, true)
213
+ })
114
214
  })
115
215
 
116
216
  describe('#setReady()', () => {
@@ -192,6 +292,28 @@ describe('AbstractModule', () => {
192
292
 
193
293
  assert.equal(module.initTime, firstInitTime)
194
294
  })
295
+
296
+ it('should calculate initTime even on error', async () => {
297
+ const mockApp = {
298
+ dependencyloader: {
299
+ moduleLoadedHook: {
300
+ tap: () => {},
301
+ untap: () => {}
302
+ }
303
+ }
304
+ }
305
+
306
+ class TestModule extends AbstractModule {
307
+ async init () {
308
+ throw new Error('fail')
309
+ }
310
+ }
311
+
312
+ const module = new TestModule(mockApp, { name: 'test' })
313
+ try { await module.onReady() } catch (e) { /* expected */ }
314
+ assert.equal(typeof module.initTime, 'number')
315
+ assert.ok(module.initTime >= 0)
316
+ })
195
317
  })
196
318
 
197
319
  describe('#onReady()', () => {
@@ -264,6 +386,24 @@ describe('AbstractModule', () => {
264
386
 
265
387
  assert.equal(resolvedModule, module)
266
388
  })
389
+
390
+ // TODO: Bug - onReady() hangs forever after a failed init, because _isReady
391
+ // remains false and readyHook has already been invoked, so the tap never fires.
392
+ // Calling onReady() a second time after a failure will never resolve or reject.
393
+ it('should support multiple concurrent onReady calls', async () => {
394
+ const mockApp = {
395
+ dependencyloader: {
396
+ moduleLoadedHook: {
397
+ tap: () => {},
398
+ untap: () => {}
399
+ }
400
+ }
401
+ }
402
+ const module = new AbstractModule(mockApp, { name: 'test' })
403
+ const [r1, r2] = await Promise.all([module.onReady(), module.onReady()])
404
+ assert.equal(r1, module)
405
+ assert.equal(r2, module)
406
+ })
267
407
  })
268
408
 
269
409
  describe('#getConfig()', () => {
@@ -330,6 +470,28 @@ describe('AbstractModule', () => {
330
470
 
331
471
  assert.equal(result, undefined)
332
472
  })
473
+
474
+ it('should construct key from module name and config key', async () => {
475
+ let requestedKey
476
+ const mockApp = {
477
+ config: {
478
+ get: (key) => { requestedKey = key; return 'value' }
479
+ },
480
+ dependencyloader: {
481
+ moduleLoadedHook: {
482
+ tap: () => {},
483
+ untap: () => {}
484
+ }
485
+ }
486
+ }
487
+ const module = new AbstractModule(mockApp, { name: 'my-module' })
488
+
489
+ await module.onReady()
490
+
491
+ module.getConfig('myKey')
492
+
493
+ assert.equal(requestedKey, 'my-module.myKey')
494
+ })
333
495
  })
334
496
 
335
497
  describe('#log()', () => {
@@ -452,6 +614,59 @@ describe('AbstractModule', () => {
452
614
 
453
615
  assert.deepEqual(loggedArgs, ['arg1', 'arg2', 'arg3'])
454
616
  })
617
+
618
+ it('should queue log and deliver when logger module loads', async () => {
619
+ let loggedLevel
620
+ let tapCallback
621
+ const mockApp = {
622
+ dependencyloader: {
623
+ moduleLoadedHook: {
624
+ tap: (fn) => { tapCallback = fn },
625
+ untap: () => {}
626
+ }
627
+ }
628
+ }
629
+ const module = new AbstractModule(mockApp, { name: 'test-mod' })
630
+ await module.onReady()
631
+
632
+ module.log('warn', 'deferred message')
633
+ assert.ok(tapCallback)
634
+
635
+ mockApp.logger = {
636
+ name: 'adapt-authoring-logger',
637
+ log: (level) => {
638
+ loggedLevel = level
639
+ }
640
+ }
641
+
642
+ tapCallback(null, { name: 'adapt-authoring-logger' })
643
+ assert.equal(loggedLevel, 'warn')
644
+ })
645
+
646
+ it('should not log when loaded module is not the logger', async () => {
647
+ const logCalled = false
648
+ let tapCallback
649
+ const mockApp = {
650
+ dependencyloader: {
651
+ moduleLoadedHook: {
652
+ tap: (fn) => { tapCallback = fn },
653
+ untap: () => {}
654
+ }
655
+ }
656
+ }
657
+ const module = new AbstractModule(mockApp, { name: 'test-mod' })
658
+ await module.onReady()
659
+
660
+ // No logger set yet, so log queues the callback
661
+ module.log('info', 'some message')
662
+ assert.ok(tapCallback)
663
+
664
+ // Now simulate a non-logger module loading - _log checks !this.app.logger
665
+ // which is true (no logger), so it returns false
666
+ const result = tapCallback(null, { name: 'adapt-authoring-other' })
667
+ assert.equal(result, false)
668
+ assert.equal(logCalled, false)
669
+ })
455
670
  })
456
671
 
457
672
  describe('constructor with null pkg', () => {
package/tests/App.spec.js CHANGED
@@ -40,13 +40,30 @@ describe('App', () => {
40
40
  assert.ok(app instanceof App)
41
41
  })
42
42
 
43
- it('should return the same instance on subsequent calls', () => {
43
+ it('should return the same instance on subsequent calls (singleton)', () => {
44
44
  const app1 = App.instance
45
45
  const app2 = App.instance
46
46
  assert.equal(app1, app2)
47
47
  })
48
48
  })
49
49
 
50
+ describe('constructor', () => {
51
+ it('should set name to adapt-authoring-core', () => {
52
+ const app = App.instance
53
+ assert.equal(app.name, 'adapt-authoring-core')
54
+ })
55
+
56
+ it('should set rootDir from ROOT_DIR env var', () => {
57
+ const app = App.instance
58
+ assert.equal(app.rootDir, testRootDir)
59
+ })
60
+
61
+ it('should initialize git info', () => {
62
+ const app = App.instance
63
+ assert.equal(typeof app.git, 'object')
64
+ })
65
+ })
66
+
50
67
  describe('#dependencies', () => {
51
68
  it('should return the dependency configs from dependencyloader', () => {
52
69
  const app = App.instance
@@ -70,6 +87,25 @@ describe('App', () => {
70
87
  app.rootDir = origRootDir
71
88
  assert.deepEqual(info, {})
72
89
  })
90
+
91
+ it('should return object with branch and commit when .git exists', async () => {
92
+ const gitDir = path.join(testRootDir, '.git')
93
+ const refsDir = path.join(gitDir, 'refs', 'heads')
94
+ await fs.ensureDir(refsDir)
95
+ await fs.writeFile(path.join(gitDir, 'HEAD'), 'ref: refs/heads/main\n')
96
+ await fs.writeFile(path.join(refsDir, 'main'), 'abc123def456\n')
97
+
98
+ const app = App.instance
99
+ const origRootDir = app.rootDir
100
+ app.rootDir = testRootDir
101
+ const info = app.getGitInfo()
102
+ app.rootDir = origRootDir
103
+
104
+ assert.equal(info.branch, 'main')
105
+ assert.equal(info.commit, 'abc123def456')
106
+
107
+ await fs.remove(gitDir)
108
+ })
73
109
  })
74
110
 
75
111
  describe('#waitForModule()', () => {
@@ -100,5 +136,25 @@ describe('App', () => {
100
136
  assert.deepEqual(result[0], { name: 'mod-a' })
101
137
  assert.deepEqual(result[1], { name: 'mod-b' })
102
138
  })
139
+
140
+ it('should return single result (not array) for single module', async () => {
141
+ const app = App.instance
142
+ const origWaitForModule = app.dependencyloader.waitForModule.bind(app.dependencyloader)
143
+ app.dependencyloader.waitForModule = async (name) => ({ name })
144
+ const result = await app.waitForModule('single-mod')
145
+ app.dependencyloader.waitForModule = origWaitForModule
146
+
147
+ assert.ok(!Array.isArray(result))
148
+ assert.deepEqual(result, { name: 'single-mod' })
149
+ })
150
+ })
151
+
152
+ describe('#setReady()', () => {
153
+ it('should set _isStarting to false', async () => {
154
+ const app = App.instance
155
+ app._isStarting = true
156
+ await app.setReady()
157
+ assert.equal(app._isStarting, false)
158
+ })
103
159
  })
104
160
  })
@@ -38,6 +38,18 @@ describe('DependencyLoader', () => {
38
38
  assert.equal(typeof loader.configsLoadedHook.invoke, 'function')
39
39
  assert.equal(typeof loader.moduleLoadedHook.invoke, 'function')
40
40
  })
41
+
42
+ it('should tap logProgress into moduleLoadedHook', () => {
43
+ const mockApp = { rootDir: '/test' }
44
+ const loader = new DependencyLoader(mockApp)
45
+ assert.ok(loader.moduleLoadedHook.hasObservers)
46
+ })
47
+
48
+ it('should derive name from constructor name in lowercase', () => {
49
+ const mockApp = { rootDir: '/test' }
50
+ const loader = new DependencyLoader(mockApp)
51
+ assert.equal(loader.name, 'dependencyloader')
52
+ })
41
53
  })
42
54
 
43
55
  describe('#log()', () => {
@@ -80,6 +92,27 @@ describe('DependencyLoader', () => {
80
92
  loader.log('info', 'test message')
81
93
  })
82
94
  })
95
+
96
+ it('should pass level and args to app.logger.log', () => {
97
+ let loggedLevel, loggedName, loggedArgs
98
+ const mockApp = {
99
+ rootDir: '/test',
100
+ logger: {
101
+ _isReady: true,
102
+ log: (level, name, ...args) => {
103
+ loggedLevel = level
104
+ loggedName = name
105
+ loggedArgs = args
106
+ }
107
+ }
108
+ }
109
+ const loader = new DependencyLoader(mockApp)
110
+ loader.log('warn', 'message1', 'message2')
111
+
112
+ assert.equal(loggedLevel, 'warn')
113
+ assert.equal(loggedName, 'dependencyloader')
114
+ assert.deepEqual(loggedArgs, ['message1', 'message2'])
115
+ })
83
116
  })
84
117
 
85
118
  describe('#logError()', () => {
@@ -98,6 +131,20 @@ describe('DependencyLoader', () => {
98
131
 
99
132
  assert.equal(loggedLevel, 'error')
100
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
+ })
101
148
  })
102
149
 
103
150
  describe('#getConfig()', () => {
@@ -128,6 +175,35 @@ describe('DependencyLoader', () => {
128
175
 
129
176
  assert.equal(result, 'testValue')
130
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
+ })
131
207
  })
132
208
 
133
209
  describe('#loadModuleConfig()', () => {
@@ -166,6 +242,37 @@ describe('DependencyLoader', () => {
166
242
  assert.equal(config.essentialType, 'api')
167
243
  assert.equal(config.rootDir, testModuleDir)
168
244
  })
245
+
246
+ it('should override package.json values with adapt-authoring.json values', async () => {
247
+ const overrideDir = path.join(__dirname, 'data', 'override-module')
248
+ await fs.ensureDir(overrideDir)
249
+ await fs.writeJson(path.join(overrideDir, 'package.json'), {
250
+ name: 'pkg-name',
251
+ description: 'from package'
252
+ })
253
+ await fs.writeJson(path.join(overrideDir, 'adapt-authoring.json'), {
254
+ name: 'adapt-name',
255
+ description: 'from adapt'
256
+ })
257
+
258
+ const mockApp = { rootDir: '/test' }
259
+ const loader = new DependencyLoader(mockApp)
260
+ const config = await loader.loadModuleConfig(overrideDir)
261
+
262
+ assert.equal(config.name, 'adapt-name')
263
+ assert.equal(config.description, 'from adapt')
264
+
265
+ await fs.remove(overrideDir)
266
+ })
267
+
268
+ it('should set rootDir to the module directory', async () => {
269
+ const mockApp = { rootDir: '/test' }
270
+ const loader = new DependencyLoader(mockApp)
271
+
272
+ const config = await loader.loadModuleConfig(testModuleDir)
273
+
274
+ assert.equal(config.rootDir, testModuleDir)
275
+ })
169
276
  })
170
277
 
171
278
  describe('#loadModules()', () => {
@@ -200,6 +307,42 @@ describe('DependencyLoader', () => {
200
307
 
201
308
  assert.ok(loader.failedModules.includes('nonexistent-module'))
202
309
  })
310
+
311
+ it('should include module name in DependencyError message', async () => {
312
+ const mockApp = { rootDir: '/test' }
313
+ const loader = new DependencyLoader(mockApp)
314
+ loader.configs = { 'nonexistent-module': { module: true, name: 'nonexistent-module' } }
315
+
316
+ await assert.rejects(
317
+ loader.loadModules(['nonexistent-module']),
318
+ (err) => {
319
+ assert.ok(err.message.includes('nonexistent-module'))
320
+ return true
321
+ }
322
+ )
323
+ })
324
+
325
+ it('should set cause on DependencyError', async () => {
326
+ const mockApp = { rootDir: '/test' }
327
+ const loader = new DependencyLoader(mockApp)
328
+ loader.configs = { 'nonexistent-module': { module: true, name: 'nonexistent-module' } }
329
+
330
+ try {
331
+ await loader.loadModules(['nonexistent-module'])
332
+ assert.fail('should have thrown')
333
+ } catch (err) {
334
+ assert.ok(err.cause)
335
+ }
336
+ })
337
+
338
+ it('should handle empty module list', async () => {
339
+ const mockApp = { rootDir: '/test' }
340
+ const loader = new DependencyLoader(mockApp)
341
+
342
+ await loader.loadModules([])
343
+
344
+ assert.deepEqual(loader.failedModules, [])
345
+ })
203
346
  })
204
347
 
205
348
  describe('#loadModule()', () => {
@@ -223,6 +366,16 @@ describe('DependencyLoader', () => {
223
366
 
224
367
  assert.equal(result, undefined)
225
368
  })
369
+
370
+ it('should not add to instances when config.module is false', async () => {
371
+ const mockApp = { rootDir: '/test' }
372
+ const loader = new DependencyLoader(mockApp)
373
+ loader.configs = { 'non-module': { module: false } }
374
+
375
+ await loader.loadModule('non-module')
376
+
377
+ assert.equal(loader.instances['non-module'], undefined)
378
+ })
226
379
  })
227
380
 
228
381
  describe('#waitForModule()', () => {
@@ -284,6 +437,39 @@ describe('DependencyLoader', () => {
284
437
 
285
438
  assert.equal(result, mockInstance)
286
439
  })
440
+
441
+ it('should not double-prefix names that already have the prefix', async () => {
442
+ const mockApp = { rootDir: '/test' }
443
+ const loader = new DependencyLoader(mockApp)
444
+ loader._configsLoaded = true
445
+ const mockInstance = {
446
+ name: 'adapt-authoring-server',
447
+ _isReady: true,
448
+ onReady: async () => mockInstance
449
+ }
450
+ loader.configs = { 'adapt-authoring-server': { name: 'adapt-authoring-server' } }
451
+ loader.instances = { 'adapt-authoring-server': mockInstance }
452
+
453
+ const result = await loader.waitForModule('adapt-authoring-server')
454
+
455
+ assert.equal(result, mockInstance)
456
+ })
457
+
458
+ it('should resolve via moduleLoadedHook when module not yet loaded', async () => {
459
+ const mockApp = { rootDir: '/test' }
460
+ const loader = new DependencyLoader(mockApp)
461
+ loader._configsLoaded = true
462
+ loader.configs = { 'adapt-authoring-pending': { name: 'adapt-authoring-pending' } }
463
+
464
+ const mockInstance = { name: 'adapt-authoring-pending' }
465
+
466
+ const waitPromise = loader.waitForModule('adapt-authoring-pending')
467
+
468
+ await loader.moduleLoadedHook.invoke(null, mockInstance)
469
+
470
+ const result = await waitPromise
471
+ assert.equal(result, mockInstance)
472
+ })
287
473
  })
288
474
 
289
475
  describe('#logProgress()', () => {
@@ -332,5 +518,75 @@ describe('DependencyLoader', () => {
332
518
  loader.logProgress(null, { name: 'test-module', initTime: 50 })
333
519
  })
334
520
  })
521
+
522
+ it('should count module being loaded as loaded even before _isReady', () => {
523
+ const mockApp = { rootDir: '/test' }
524
+ const loader = new DependencyLoader(mockApp)
525
+ loader.configs = {
526
+ 'mod-a': { name: 'mod-a', module: true },
527
+ 'mod-b': { name: 'mod-b', module: true }
528
+ }
529
+ loader.instances = {}
530
+
531
+ assert.doesNotThrow(() => {
532
+ loader.logProgress(null, { name: 'mod-a', initTime: 10 })
533
+ })
534
+ })
535
+
536
+ it('should log init times at 100% progress', () => {
537
+ let loggedTimes
538
+ const mockApp = {
539
+ rootDir: '/test',
540
+ logger: {
541
+ _isReady: true,
542
+ log: (level, name, ...args) => {
543
+ if (typeof args[0] === 'object' && !Array.isArray(args[0]) && typeof args[0] !== 'string') {
544
+ loggedTimes = args[0]
545
+ }
546
+ }
547
+ }
548
+ }
549
+ const loader = new DependencyLoader(mockApp)
550
+ loader.configs = {
551
+ 'mod-a': { name: 'mod-a', module: true }
552
+ }
553
+ loader.instances = {
554
+ 'mod-a': { name: 'mod-a', _isReady: true, initTime: 42 }
555
+ }
556
+
557
+ loader.logProgress(null, { name: 'mod-a', initTime: 42 })
558
+
559
+ assert.ok(loggedTimes)
560
+ assert.equal(loggedTimes['mod-a'], 42)
561
+ })
562
+
563
+ it('should include failed module names in log output', () => {
564
+ let loggedMessage
565
+ const mockApp = {
566
+ rootDir: '/test',
567
+ logger: {
568
+ _isReady: true,
569
+ log: (level, name, ...args) => {
570
+ if (typeof args[0] === 'string' && args[0] === 'LOAD') {
571
+ loggedMessage = args[1]
572
+ }
573
+ }
574
+ }
575
+ }
576
+ const loader = new DependencyLoader(mockApp)
577
+ loader.configs = {
578
+ 'adapt-authoring-ok': { name: 'adapt-authoring-ok', module: true },
579
+ 'adapt-authoring-bad': { name: 'adapt-authoring-bad', module: true }
580
+ }
581
+ loader.instances = {
582
+ 'adapt-authoring-ok': { name: 'adapt-authoring-ok', _isReady: true, initTime: 10 }
583
+ }
584
+ loader.failedModules = ['adapt-authoring-bad']
585
+
586
+ loader.logProgress(null, { name: 'adapt-authoring-ok', initTime: 10 })
587
+
588
+ assert.ok(loggedMessage)
589
+ assert.ok(loggedMessage.includes('failed'))
590
+ })
335
591
  })
336
592
  })
@@ -13,6 +13,12 @@ describe('Hook', () => {
13
13
  })
14
14
  })
15
15
 
16
+ describe('.Types', () => {
17
+ it('should have exactly two type keys', () => {
18
+ assert.equal(Object.keys(Hook.Types).length, 2)
19
+ })
20
+ })
21
+
16
22
  describe('constructor', () => {
17
23
  it('should create a hook with default options', () => {
18
24
  const hook = new Hook()
@@ -20,6 +26,34 @@ describe('Hook', () => {
20
26
  assert.equal(hook.hasObservers, false)
21
27
  })
22
28
 
29
+ it('should default to parallel type', () => {
30
+ const hook = new Hook()
31
+ assert.equal(hook._options.type, Hook.Types.Parallel)
32
+ })
33
+
34
+ it('should default mutable to false', () => {
35
+ const hook = new Hook()
36
+ assert.equal(hook._options.mutable, false)
37
+ })
38
+
39
+ it('should allow explicit type override even when mutable is true', () => {
40
+ const hook = new Hook({ type: Hook.Types.Parallel, mutable: true })
41
+ // When type is explicitly provided, it overrides the mutable-forced series default
42
+ assert.equal(hook._options.type, Hook.Types.Parallel)
43
+ })
44
+
45
+ it('should handle undefined options', () => {
46
+ const hook = new Hook(undefined)
47
+ assert.equal(hook._options.type, Hook.Types.Parallel)
48
+ assert.equal(hook._options.mutable, false)
49
+ })
50
+
51
+ it('should initialise with empty observer arrays', () => {
52
+ const hook = new Hook()
53
+ assert.deepEqual(hook._hookObservers, [])
54
+ assert.deepEqual(hook._promiseObservers, [])
55
+ })
56
+
23
57
  it('should create a hook that supports parallel execution', async () => {
24
58
  const hook = new Hook()
25
59
  const results = []
@@ -100,8 +134,19 @@ describe('Hook', () => {
100
134
  hook.tap('not a function')
101
135
  hook.tap(null)
102
136
  hook.tap(42)
137
+ hook.tap(undefined)
138
+ hook.tap({})
139
+ hook.tap([])
103
140
  assert.equal(hook.hasObservers, false)
104
141
  })
142
+
143
+ it('should work without scope argument', async () => {
144
+ const hook = new Hook()
145
+ let called = false
146
+ hook.tap(() => { called = true })
147
+ await hook.invoke()
148
+ assert.equal(called, true)
149
+ })
105
150
  })
106
151
 
107
152
  describe('#untap()', () => {
@@ -122,6 +167,23 @@ describe('Hook', () => {
122
167
  // observer still present because tap binds the function
123
168
  assert.equal(hook.hasObservers, true)
124
169
  })
170
+
171
+ it('should remove a directly-pushed observer by reference', () => {
172
+ const hook = new Hook()
173
+ const fn = () => {}
174
+ hook._hookObservers.push(fn)
175
+ assert.equal(hook.hasObservers, true)
176
+ hook.untap(fn)
177
+ assert.equal(hook.hasObservers, false)
178
+ })
179
+
180
+ it('should only remove the first matching reference', () => {
181
+ const hook = new Hook()
182
+ const fn = () => {}
183
+ hook._hookObservers.push(fn, fn)
184
+ hook.untap(fn)
185
+ assert.equal(hook._hookObservers.length, 1)
186
+ })
125
187
  })
126
188
 
127
189
  describe('#invoke()', () => {
@@ -187,6 +249,14 @@ describe('Hook', () => {
187
249
  const result = await hook.invoke()
188
250
  assert.deepEqual(result, [])
189
251
  })
252
+
253
+ it('should handle mixed sync and async observers', async () => {
254
+ const hook = new Hook()
255
+ hook.tap(() => 'sync')
256
+ hook.tap(async () => 'async')
257
+ const result = await hook.invoke()
258
+ assert.deepEqual(result, ['sync', 'async'])
259
+ })
190
260
  })
191
261
 
192
262
  describe('series hooks', () => {
@@ -228,6 +298,28 @@ describe('Hook', () => {
228
298
  hook.tap(() => { throw new Error('series error') })
229
299
  await assert.rejects(hook.invoke(), { message: 'series error' })
230
300
  })
301
+
302
+ it('should stop on first error and not call subsequent observers', async () => {
303
+ const hook = new Hook({ type: Hook.Types.Series })
304
+ const calls = []
305
+ hook.tap(() => { calls.push(1); throw new Error('stop') })
306
+ hook.tap(() => calls.push(2))
307
+ await assert.rejects(hook.invoke())
308
+ assert.deepEqual(calls, [1])
309
+ })
310
+
311
+ it('should return undefined with no observers', async () => {
312
+ const hook = new Hook({ type: Hook.Types.Series })
313
+ const result = await hook.invoke()
314
+ assert.equal(result, undefined)
315
+ })
316
+
317
+ it('should return single observer result', async () => {
318
+ const hook = new Hook({ type: Hook.Types.Series })
319
+ hook.tap(() => 'only')
320
+ const result = await hook.invoke()
321
+ assert.equal(result, 'only')
322
+ })
231
323
  })
232
324
 
233
325
  describe('mutable hooks', () => {
@@ -239,6 +331,16 @@ describe('Hook', () => {
239
331
  await hook.invoke(obj)
240
332
  assert.equal(obj.value, 3)
241
333
  })
334
+
335
+ it('should allow multiple mutations in sequence', async () => {
336
+ const hook = new Hook({ mutable: true })
337
+ const arr = []
338
+ hook.tap((a) => { a.push(1) })
339
+ hook.tap((a) => { a.push(2) })
340
+ hook.tap((a) => { a.push(3) })
341
+ await hook.invoke(arr)
342
+ assert.deepEqual(arr, [1, 2, 3])
343
+ })
242
344
  })
243
345
  })
244
346
 
@@ -249,11 +351,19 @@ describe('Hook', () => {
249
351
  assert.ok(result instanceof Promise)
250
352
  })
251
353
 
252
- it('should add an entry to hook observers', () => {
354
+ it('should add an entry to promise observers', () => {
253
355
  const hook = new Hook()
254
- assert.equal(hook.hasObservers, false)
356
+ assert.equal(hook._promiseObservers.length, 0)
255
357
  hook.onInvoke()
256
- assert.equal(hook.hasObservers, true)
358
+ assert.equal(hook._promiseObservers.length, 1)
359
+ })
360
+
361
+ it('should add a resolve/reject pair as promise observer', () => {
362
+ const hook = new Hook()
363
+ hook.onInvoke()
364
+ const observer = hook._promiseObservers[0]
365
+ assert.ok(Array.isArray(observer))
366
+ assert.equal(observer.length, 2)
257
367
  })
258
368
  })
259
369
  })
@@ -21,6 +21,16 @@ describe('Utils', () => {
21
21
  assert.equal(typeof args, 'object')
22
22
  assert.ok(Array.isArray(args.params))
23
23
  })
24
+
25
+ it('should include the underscore array from minimist', () => {
26
+ const args = Utils.getArgs()
27
+ assert.ok(Array.isArray(args._))
28
+ })
29
+
30
+ it('should derive params by slicing first two entries from _', () => {
31
+ const args = Utils.getArgs()
32
+ assert.deepEqual(args.params, args._.slice(2))
33
+ })
24
34
  })
25
35
 
26
36
  describe('.isObject()', () => {