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/lib/Lang.js ADDED
@@ -0,0 +1,126 @@
1
+ import fs from 'node:fs'
2
+ import { globSync } from 'glob'
3
+ import path from 'node:path'
4
+
5
+ /**
6
+ * Handles loading and translation of language strings.
7
+ * @memberof core
8
+ */
9
+ class Lang {
10
+ /**
11
+ * @param {Object} options
12
+ * @param {Object} options.dependencies Key/value map of dependency configs (each with a rootDir)
13
+ * @param {String} options.defaultLang The default language for translations
14
+ * @param {String} options.rootDir The application root directory
15
+ * @param {Function} [options.log] Optional logging function (level, id, ...args)
16
+ */
17
+ constructor ({ dependencies, defaultLang, rootDir, log } = {}) {
18
+ /**
19
+ * The loaded language phrases
20
+ * @type {Object}
21
+ */
22
+ this.phrases = {}
23
+ /**
24
+ * The default language for translations
25
+ * @type {String}
26
+ */
27
+ this.defaultLang = defaultLang
28
+ /**
29
+ * Optional logging function (level, id, ...args)
30
+ * @type {Function}
31
+ */
32
+ this.log = log
33
+ this.loadPhrases(dependencies, rootDir, log)
34
+ }
35
+
36
+ /**
37
+ * Returns the languages supported by the application
38
+ * @type {Array<String>}
39
+ */
40
+ get supportedLanguages () {
41
+ return Object.keys(this.phrases)
42
+ }
43
+
44
+ /**
45
+ * Loads and merges all language phrases from dependencies
46
+ * @param {Object} dependencies Key/value map of dependency configs (each with a rootDir)
47
+ * @param {String} appRootDir The application root directory
48
+ * @param {Function} [log] Optional logging function (level, id, ...args)
49
+ */
50
+ loadPhrases (dependencies = {}, appRootDir, log) {
51
+ const dirs = [
52
+ ...(appRootDir ? [appRootDir] : []),
53
+ ...Object.values(dependencies).map(d => d.rootDir)
54
+ ]
55
+ for (const dir of dirs) {
56
+ const files = globSync('lang/**/*.json', { cwd: dir, absolute: true })
57
+ for (const f of files) {
58
+ try {
59
+ const relative = path.relative(path.join(dir, 'lang'), f)
60
+ const parts = relative.replace(/\.json$/, '').split(path.sep)
61
+ const lang = parts[0]
62
+ const prefix = parts.length > 1 ? parts.slice(1).join('.') + '.' : ''
63
+ if (!this.phrases[lang]) this.phrases[lang] = {}
64
+ const contents = JSON.parse(fs.readFileSync(f, 'utf8'))
65
+ Object.entries(contents).forEach(([k, v]) => { this.phrases[lang][`${prefix}${k}`] = v })
66
+ } catch (e) {
67
+ log?.('error', 'lang', e.message, f)
68
+ }
69
+ }
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Returns translated language string. If key is an Error, translates using
75
+ * the error code as the key and error data for substitution. Non-Error,
76
+ * non-string values are returned unchanged.
77
+ * @param {String} lang The target language (falls back to defaultLang)
78
+ * @param {String|Error} key The unique string key, or an Error to translate
79
+ * @param {Object} data Dynamic data to be inserted into translated string
80
+ * @return {String}
81
+ */
82
+ translate (lang, key, data) {
83
+ if (typeof lang !== 'string') {
84
+ lang = this.defaultLang
85
+ }
86
+ if (key instanceof Error) {
87
+ if (!key.code) return key.message || String(key)
88
+ return this.translate(lang, `error.${key.code}`, key.data ?? key)
89
+ }
90
+ if (typeof key !== 'string') {
91
+ return key
92
+ }
93
+ const s = this.phrases[lang]?.[key]
94
+ if (!s) {
95
+ this.log?.('warn', 'lang', `missing key '${lang}.${key}'`)
96
+ return key
97
+ }
98
+ if (!data) {
99
+ return s
100
+ }
101
+ return this.substituteData(s, lang, data)
102
+ }
103
+
104
+ /**
105
+ * Replaces placeholders in a translated string with data values.
106
+ * Supports ${key} for simple substitution, and $map{key:attrs:delim}
107
+ * for mapping over array values.
108
+ * @param {String} s The translated string
109
+ * @param {String} lang The target language
110
+ * @param {Object} data Key/value pairs to substitute
111
+ * @return {String}
112
+ */
113
+ substituteData (s, lang, data) {
114
+ for (const [k, v] of Object.entries(data)) {
115
+ const items = [v].flat().map(item => item instanceof Error ? this.translate(lang, item) : item)
116
+ s = s.replaceAll(`\${${k}}`, items)
117
+ for (const [match, expr] of s.matchAll(new RegExp(String.raw`\$map{${k}:(.+)}`, 'g'))) {
118
+ const [attrs, delim] = expr.split(':')
119
+ s = s.replace(match, items.map(val => attrs.split(',').map(a => val?.[a] ?? a).join(delim)))
120
+ }
121
+ }
122
+ return s
123
+ }
124
+ }
125
+
126
+ export default Lang
package/lib/Logger.js ADDED
@@ -0,0 +1,149 @@
1
+ import chalk from 'chalk'
2
+ import Hook from './Hook.js'
3
+
4
+ /**
5
+ * Provides console logging with configurable levels, colours, and module-specific overrides.
6
+ * @memberof core
7
+ */
8
+ class Logger {
9
+ static levelColours = {
10
+ error: chalk.red,
11
+ warn: chalk.yellow,
12
+ success: chalk.green,
13
+ info: chalk.cyan,
14
+ debug: chalk.dim,
15
+ verbose: chalk.grey.italic
16
+ }
17
+
18
+ /**
19
+ * Creates a Logger instance from config values
20
+ * @param {Object} options
21
+ * @param {Array<String>} options.levels Log level config strings. An empty array mutes all output.
22
+ * @param {Boolean} options.showTimestamp Whether to show timestamps
23
+ */
24
+ constructor ({ levels = Object.keys(Logger.levelColours), showTimestamp = true } = {}) {
25
+ /**
26
+ * Hook invoked on each message logged
27
+ * @type {Hook}
28
+ */
29
+ this.logHook = new Hook()
30
+ /** @ignore */
31
+ this.config = {
32
+ levels: Object.entries(Logger.levelColours).reduce((m, [level, colour]) => {
33
+ m[level] = {
34
+ enable: Logger.isLevelEnabled(levels, level),
35
+ moduleOverrides: Logger.getModuleOverrides(levels, level),
36
+ lineOverrides: Logger.getLineOverrides(levels, level),
37
+ colour
38
+ }
39
+ return m
40
+ }, {}),
41
+ idOverrides: Logger.getIdOverrides(levels),
42
+ timestamp: showTimestamp,
43
+ mute: levels.length === 0
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Logs a message to the console. When `args[0]` is a string it's treated as
49
+ * a short id for line-level filtering (e.g. `'verbose.server.ADD_ROUTE'`).
50
+ * @param {String} level Severity of the message
51
+ * @param {String} id Identifier for the message (typically the module name)
52
+ * @param {...*} args Arguments to be logged
53
+ */
54
+ log (level, id, ...args) {
55
+ const shortId = typeof args[0] === 'string' ? args[0] : undefined
56
+ if (this.config.mute || !Logger.isLoggingEnabled(this.config.levels, level, id, shortId, this.config.idOverrides)) {
57
+ return
58
+ }
59
+ const colour = this.config.levels[level]?.colour
60
+ const logFunc = console[level] ?? console.log
61
+ const timestamp = this.config.timestamp ? chalk.dim(`${new Date().toISOString()} `) : ''
62
+ logFunc(`${timestamp}${colour ? colour(level) : level} ${chalk.magenta(id)}`, ...args)
63
+ this.logHook.invoke(new Date(), level, id, ...args).catch((error) => {
64
+ console.error('Logger logHook invocation failed:', error)
65
+ })
66
+ }
67
+
68
+ /**
69
+ * Determines whether a specific log level is enabled
70
+ * @param {Array<String>} levelsConfig Array of level configuration strings
71
+ * @param {String} level The log level to check
72
+ * @return {Boolean}
73
+ */
74
+ static isLevelEnabled (levelsConfig, level) {
75
+ return !levelsConfig.includes(`!${level}`) && levelsConfig.includes(level)
76
+ }
77
+
78
+ /**
79
+ * Returns per-level module overrides (e.g. `debug.core` / `!debug.core`).
80
+ * @param {Array<String>} levelsConfig Array of level configuration strings
81
+ * @param {String} level The log level to find overrides for
82
+ * @return {Array<String>}
83
+ */
84
+ static getModuleOverrides (levelsConfig, level) {
85
+ return levelsConfig.filter(l => Logger.matchesLevelPrefix(l, level) && Logger.entrySegmentCount(l) === 2)
86
+ }
87
+
88
+ /**
89
+ * Returns per-level line overrides (e.g. `debug.core.LOAD` / `!debug.core.LOAD`).
90
+ * @param {Array<String>} levelsConfig Array of level configuration strings
91
+ * @param {String} level The log level to find overrides for
92
+ * @return {Array<String>}
93
+ */
94
+ static getLineOverrides (levelsConfig, level) {
95
+ return levelsConfig.filter(l => Logger.matchesLevelPrefix(l, level) && Logger.entrySegmentCount(l) >= 3)
96
+ }
97
+
98
+ /**
99
+ * Returns id-wide overrides — entries whose first segment isn't a known level,
100
+ * meaning they apply to that id at every level (e.g. `core` / `!core`).
101
+ * @param {Array<String>} levelsConfig Array of level configuration strings
102
+ * @return {Array<String>}
103
+ */
104
+ static getIdOverrides (levelsConfig) {
105
+ const knownLevels = Object.keys(Logger.levelColours)
106
+ return levelsConfig.filter(entry => {
107
+ const body = entry.startsWith('!') ? entry.slice(1) : entry
108
+ const firstSegment = body.split('.')[0]
109
+ return body.length > 0 && !knownLevels.includes(firstSegment)
110
+ })
111
+ }
112
+
113
+ /** @ignore */
114
+ static matchesLevelPrefix (entry, level) {
115
+ return entry.startsWith(`${level}.`) || entry.startsWith(`!${level}.`)
116
+ }
117
+
118
+ /** @ignore */
119
+ static entrySegmentCount (entry) {
120
+ const body = entry.startsWith('!') ? entry.slice(1) : entry
121
+ return body.split('.').length
122
+ }
123
+
124
+ /**
125
+ * Returns whether a message should be logged. Resolution order, most-specific
126
+ * wins: line-level (`!level.id.shortId`) → per-level module (`!level.id`)
127
+ * → id-wide (`!id`) → global level.
128
+ * @param {Object} configLevels The resolved levels config object
129
+ * @param {String} level Logging level
130
+ * @param {String} id Id of log caller
131
+ * @param {String} [shortId] Optional line-level id (typically `args[0]`)
132
+ * @param {Array<String>} [idOverrides] Id-wide override entries
133
+ * @returns {Boolean}
134
+ */
135
+ static isLoggingEnabled (configLevels, level, id, shortId, idOverrides = []) {
136
+ const { enable, moduleOverrides = [], lineOverrides = [] } = configLevels?.[level] || {}
137
+ if (typeof shortId === 'string') {
138
+ if (lineOverrides.includes(`!${level}.${id}.${shortId}`)) return false
139
+ if (lineOverrides.includes(`${level}.${id}.${shortId}`)) return true
140
+ }
141
+ if (moduleOverrides.includes(`!${level}.${id}`)) return false
142
+ if (moduleOverrides.includes(`${level}.${id}`)) return true
143
+ if (idOverrides.includes(`!${id}`)) return false
144
+ if (idOverrides.includes(id)) return true
145
+ return Boolean(enable)
146
+ }
147
+ }
148
+
149
+ export default Logger
@@ -1,12 +1,10 @@
1
- import minimist from 'minimist'
1
+ import { parseArgs } from 'node:util'
2
2
 
3
3
  /**
4
- * Returns the passed arguments, parsed by minimist for easy access
4
+ * Returns the passed arguments, parsed for easy access
5
5
  * @return {Object} The parsed arguments
6
- * @see {@link https://github.com/substack/minimist#readme}
7
6
  */
8
7
  export function getArgs () {
9
- const args = minimist(process.argv)
10
- args.params = args._.slice(2)
11
- return args
8
+ const { values, positionals } = parseArgs({ strict: false, args: process.argv.slice(2) })
9
+ return { ...values, params: positionals }
12
10
  }
@@ -0,0 +1,7 @@
1
+ export default function (migration) {
2
+ migration.describe('Move adapt-authoring-lang config keys to adapt-authoring-core')
3
+
4
+ migration
5
+ .where('adapt-authoring-lang')
6
+ .replace('defaultLang', 'adapt-authoring-core')
7
+ }
@@ -0,0 +1,9 @@
1
+ export default function (migration) {
2
+ migration.describe('Move adapt-authoring-logger config keys to adapt-authoring-core')
3
+
4
+ migration
5
+ .where('adapt-authoring-logger')
6
+ .replace('levels', 'adapt-authoring-core', 'logLevels')
7
+ .replace('showTimestamp', 'adapt-authoring-core', 'showLogTimestamp')
8
+ .remove('mute', 'dateFormat')
9
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "adapt-authoring-core",
3
- "version": "2.5.0",
3
+ "version": "3.0.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",
@@ -11,34 +11,17 @@
11
11
  },
12
12
  "repository": "github:adapt-security/adapt-authoring-core",
13
13
  "dependencies": {
14
- "fs-extra": "11.3.3",
15
- "glob": "^13.0.0",
16
- "lodash": "^4.17.21",
17
- "minimist": "^1.2.8"
14
+ "adapt-authoring-migrations": "^2.0.0",
15
+ "adapt-schemas": "^3.1.0",
16
+ "chalk": "^5.4.1",
17
+ "glob": "^13.0.0"
18
18
  },
19
19
  "devDependencies": {
20
- "@semantic-release/git": "^10.0.1",
21
- "conventional-changelog-eslint": "^6.0.0",
22
- "semantic-release": "^25.0.2",
20
+ "@adaptlearning/semantic-release-config": "^1.0.0",
21
+ "fs-extra": "^11.3.4",
23
22
  "standard": "^17.1.0"
24
23
  },
25
24
  "release": {
26
- "plugins": [
27
- [
28
- "@semantic-release/commit-analyzer",
29
- {
30
- "preset": "eslint"
31
- }
32
- ],
33
- [
34
- "@semantic-release/release-notes-generator",
35
- {
36
- "preset": "eslint"
37
- }
38
- ],
39
- "@semantic-release/npm",
40
- "@semantic-release/github",
41
- "@semantic-release/git"
42
- ]
25
+ "extends": "@adaptlearning/semantic-release-config"
43
26
  }
44
27
  }
@@ -448,13 +448,8 @@ describe('AbstractModule', () => {
448
448
  assert.equal(result, 'testValue')
449
449
  })
450
450
 
451
- it('should return undefined if config.get throws', async () => {
451
+ it('should return undefined when config is not available', async () => {
452
452
  const mockApp = {
453
- config: {
454
- get: () => {
455
- throw new Error('config error')
456
- }
457
- },
458
453
  dependencyloader: {
459
454
  moduleLoadedHook: {
460
455
  tap: () => {},
@@ -615,57 +610,12 @@ describe('AbstractModule', () => {
615
610
  assert.deepEqual(loggedArgs, ['arg1', 'arg2', 'arg3'])
616
611
  })
617
612
 
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
- }
613
+ it('should silently skip when logger is not available', async () => {
614
+ const mockApp = {}
629
615
  const module = new AbstractModule(mockApp, { name: 'test-mod' })
630
616
  await module.onReady()
631
617
 
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)
618
+ assert.doesNotThrow(() => module.log('warn', 'no logger'))
669
619
  })
670
620
  })
671
621
 
@@ -0,0 +1,62 @@
1
+ import { describe, it } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import AdaptError from '../lib/AdaptError.js'
4
+
5
+ describe('AdaptError', () => {
6
+ describe('constructor', () => {
7
+ it('should set code and default statusCode', () => {
8
+ const error = new AdaptError('TEST_ERROR')
9
+ assert.equal(error.code, 'TEST_ERROR')
10
+ assert.equal(error.statusCode, 500)
11
+ assert.equal(error.isFatal, false)
12
+ })
13
+
14
+ it('should set custom statusCode', () => {
15
+ const error = new AdaptError('NOT_FOUND', 404)
16
+ assert.equal(error.statusCode, 404)
17
+ })
18
+
19
+ it('should set isFatal from metadata', () => {
20
+ const error = new AdaptError('FATAL_ERROR', 500, { isFatal: true })
21
+ assert.equal(error.isFatal, true)
22
+ })
23
+
24
+ it('should default isFatal to false when not in metadata', () => {
25
+ const error = new AdaptError('ERROR', 500, { description: 'test' })
26
+ assert.equal(error.isFatal, false)
27
+ })
28
+
29
+ it('should store metadata', () => {
30
+ const meta = { description: 'test error', data: { id: 'test' } }
31
+ const error = new AdaptError('ERROR', 500, meta)
32
+ assert.deepEqual(error.meta, meta)
33
+ })
34
+
35
+ it('should extend Error', () => {
36
+ const error = new AdaptError('TEST')
37
+ assert.ok(error instanceof Error)
38
+ })
39
+ })
40
+
41
+ describe('#setData()', () => {
42
+ it('should set data and return self for chaining', () => {
43
+ const error = new AdaptError('TEST')
44
+ const result = error.setData({ id: '123' })
45
+ assert.equal(result, error)
46
+ assert.deepEqual(error.data, { id: '123' })
47
+ })
48
+ })
49
+
50
+ describe('#toString()', () => {
51
+ it('should include code without data', () => {
52
+ const error = new AdaptError('TEST')
53
+ assert.ok(error.toString().includes('TEST'))
54
+ })
55
+
56
+ it('should include stringified data when set', () => {
57
+ const error = new AdaptError('TEST')
58
+ error.setData({ id: '123' })
59
+ assert.ok(error.toString().includes('123'))
60
+ })
61
+ })
62
+ })
@@ -0,0 +1,122 @@
1
+ import { describe, it } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import Config from '../lib/Config.js'
4
+ import path from 'path'
5
+
6
+ describe('Config', () => {
7
+ describe('constructor', () => {
8
+ it('should initialise with empty config', () => {
9
+ const config = new Config()
10
+ assert.deepEqual(config.publicAttributes, [])
11
+ })
12
+ })
13
+
14
+ describe('#has()', () => {
15
+ it('should return true for existing keys', () => {
16
+ const config = new Config()
17
+ config._config['test.key'] = 'value'
18
+ assert.equal(config.has('test.key'), true)
19
+ })
20
+
21
+ it('should return false for missing keys', () => {
22
+ const config = new Config()
23
+ assert.equal(config.has('missing.key'), false)
24
+ })
25
+ })
26
+
27
+ describe('#get()', () => {
28
+ it('should return value for existing key', () => {
29
+ const config = new Config()
30
+ config._config['test.key'] = 'value'
31
+ assert.equal(config.get('test.key'), 'value')
32
+ })
33
+
34
+ it('should return undefined for missing key', () => {
35
+ const config = new Config()
36
+ assert.equal(config.get('missing'), undefined)
37
+ })
38
+ })
39
+
40
+ describe('#getPublicConfig()', () => {
41
+ it('should return only public attributes', () => {
42
+ const config = new Config()
43
+ config._config['mod.public'] = 'yes'
44
+ config._config['mod.private'] = 'no'
45
+ config.publicAttributes = ['mod.public']
46
+ const result = config.getPublicConfig()
47
+ assert.deepEqual(result, { 'mod.public': 'yes' })
48
+ })
49
+ })
50
+
51
+ describe('.envVarToConfigKey()', () => {
52
+ it('should convert ADAPT_AUTHORING_ prefixed vars', () => {
53
+ const result = Config.envVarToConfigKey('ADAPT_AUTHORING_CORE__dataDir')
54
+ assert.equal(result, 'adapt-authoring-core.dataDir')
55
+ })
56
+
57
+ it('should prefix non-adapt vars with env.', () => {
58
+ const result = Config.envVarToConfigKey('NODE_ENV')
59
+ assert.equal(result, 'env.NODE_ENV')
60
+ })
61
+ })
62
+
63
+ describe('#storeEnvSettings()', () => {
64
+ it('should store env vars in config', () => {
65
+ const config = new Config()
66
+ process.env.TEST_CONFIG_VAR = 'test_value'
67
+ config.storeEnvSettings()
68
+ assert.equal(config.get('env.TEST_CONFIG_VAR'), 'test_value')
69
+ delete process.env.TEST_CONFIG_VAR
70
+ })
71
+
72
+ it('should parse JSON env values', () => {
73
+ const config = new Config()
74
+ process.env.TEST_JSON_VAR = '42'
75
+ config.storeEnvSettings()
76
+ assert.equal(config.get('env.TEST_JSON_VAR'), 42)
77
+ delete process.env.TEST_JSON_VAR
78
+ })
79
+ })
80
+
81
+ describe('#resolveDirectory()', () => {
82
+ it('should resolve $ROOT', () => {
83
+ const config = new Config()
84
+ config.rootDir = '/app'
85
+ assert.equal(config.resolveDirectory('$ROOT/APP_DATA/data'), path.resolve('/app', 'APP_DATA/data'))
86
+ })
87
+
88
+ it('should resolve $DATA', () => {
89
+ const config = new Config()
90
+ config.rootDir = '/app'
91
+ config._config['adapt-authoring-core.dataDir'] = '/app/APP_DATA/data'
92
+ assert.equal(config.resolveDirectory('$DATA/uploads'), path.resolve('/app/APP_DATA/data', 'uploads'))
93
+ })
94
+
95
+ it('should resolve $TEMP', () => {
96
+ const config = new Config()
97
+ config.rootDir = '/app'
98
+ config._config['adapt-authoring-core.tempDir'] = '/app/APP_DATA/temp'
99
+ assert.equal(config.resolveDirectory('$TEMP/cache'), path.resolve('/app/APP_DATA/temp', 'cache'))
100
+ })
101
+
102
+ it('should not resolve unresolved variables', () => {
103
+ const config = new Config()
104
+ config.rootDir = '/app'
105
+ assert.equal(config.resolveDirectory('$DATA/uploads'), '$DATA/uploads')
106
+ })
107
+
108
+ it('should return non-variable paths unchanged', () => {
109
+ const config = new Config()
110
+ config.rootDir = '/app'
111
+ assert.equal(config.resolveDirectory('/absolute/path'), '/absolute/path')
112
+ })
113
+ })
114
+
115
+ describe('#storeUserSettings()', () => {
116
+ it('should handle missing config file gracefully', async () => {
117
+ const config = new Config()
118
+ config.configFilePath = '/nonexistent/path/config.js'
119
+ await assert.doesNotReject(() => config.storeUserSettings())
120
+ })
121
+ })
122
+ })