diff --git a/conf/config.schema.json b/conf/config.schema.json index 3d651c4b..3fd8d871 100644 --- a/conf/config.schema.json +++ b/conf/config.schema.json @@ -24,6 +24,22 @@ "type": "string", "isDirectory": true, "default": "$ROOT/APP_DATA/temp" + }, + "logLevels": { + "description": "Configures which log levels should be shown. Accepts generic levels, module-specific levels and not logic (e.g. 'debug', 'debug.core' and '!debug' respectively). Set to an empty array to mute all logging.", + "type": "array", + "items": { "type": "string" }, + "default": ["error", "warn", "success", "info", "debug", "verbose"] + }, + "showLogTimestamp": { + "description": "Whether to prepend a timestamp to each log message", + "type": "boolean", + "default": true + }, + "defaultLang": { + "description": "The default language used by the server", + "type": "string", + "default": "en" } } } diff --git a/conf/deprecated-lang.json b/conf/deprecated-lang.json new file mode 100644 index 00000000..2166f4a6 --- /dev/null +++ b/conf/deprecated-lang.json @@ -0,0 +1,4 @@ +{ + "_module": "adapt-authoring-lang", + "defaultLang": "adapt-authoring-core.defaultLang" +} diff --git a/conf/deprecated-logger.json b/conf/deprecated-logger.json new file mode 100644 index 00000000..51c0195b --- /dev/null +++ b/conf/deprecated-logger.json @@ -0,0 +1,7 @@ +{ + "_module": "adapt-authoring-logger", + "levels": "adapt-authoring-core.logLevels", + "showTimestamp": "adapt-authoring-core.showLogTimestamp", + "mute": null, + "dateFormat": null +} diff --git a/docs/configure-environment.md b/docs/configure-environment.md new file mode 100644 index 00000000..bf3aad99 --- /dev/null +++ b/docs/configure-environment.md @@ -0,0 +1,78 @@ +# Configuring your environment +> For a list of all supported configuration options, see [this page](configuration). + +The authoring tool has been built to allow for multiple configurations for different system environments (e.g. testing, production, development). + +## Set up your environment + +To configure your tool for a specific environment, you must create a config file in `/conf` named according to the env value your system will be using (e.g. `dev.config.js`, `production.config.js`, `helloworld.config.js`). We recommend sticking to something short like `dev`, or `test`, but it's up to you what you name these; just make sure to set the environment variable to the same. + +> The `NODE_ENV` environment variable is used to determine the current environment, so make sure that this is set appropriately when running the application: + +Express.js has a number of [performance enhancing features](https://expressjs.com/en/advanced/best-practice-performance.html#set-node_env-to-production) which are only enabled when the NODE_ENV is set to `production`, so we strongly recommend you use this for your production env name. + +### Creating your config + +Each config file is a JavaScript file which exports a single object. Within this file, settings are grouped by module: + +```Javascript +export default { + 'modulename': { + // settings + } +}; +``` + +See [this page](configuration) for a complete list of all configuration options. + +#### +For convenience, we've bundled a script which will generate a new config file for you automatically. + +You can do this by running the following: +```bash +npx at-confgen [NODE_ENV] +``` + +> If you choose to include the default settings in your configuration, please be aware that once set, these values will not be updated if the defaults change in the future. It is advised therefore that you leave out any settings that you don't wish to change. + +See the [Bin scripts](binscripts#at-confgen) page for more information, included supported flags. + +### Setting your 'env' + +You can do this temporarily using the following: + +**Bash/Mac OS Terminal**: +```bash +$ NODE_ENV=dev npm start +``` +**Windows Powershell/Command Prompt**: +```bash +> set NODE_ENV=dev | npm start +``` + +Please see the documentation for your own operating system for instructions on how to save environment variables in a more permanent way. + +## Using environment variables for configuration + +As an alternative to config files, any configuration option can be set via an environment variable using the following naming convention: + +``` +ADAPT_AUTHORING___ +``` + +- The module name is converted to uppercase with underscores replacing hyphens (e.g. `adapt-authoring-server` becomes `ADAPT_AUTHORING_SERVER`) +- A double underscore (`__`) separates the module name from the property name +- The property name is kept in its original camelCase format + +For example: + +| Environment variable | Config equivalent | +| --- | --- | +| `ADAPT_AUTHORING_SERVER__host` | `adapt-authoring-server.host` | +| `ADAPT_AUTHORING_SERVER__port` | `adapt-authoring-server.port` | +| `ADAPT_AUTHORING_MONGODB__connectionUri` | `adapt-authoring-mongodb.connectionUri` | +| `ADAPT_AUTHORING_AUTH_LOCAL__saltRounds` | `adapt-authoring-auth-local.saltRounds` | + +Values are parsed as JSON where possible, so non-string types like numbers and booleans can be set directly (e.g. `ADAPT_AUTHORING_SERVER__port=5678`). + +Any environment variables that do not start with `ADAPT_AUTHORING_` are available under the `env` namespace (e.g. `NODE_ENV` becomes `env.NODE_ENV`). diff --git a/docs/error-handling.md b/docs/error-handling.md new file mode 100644 index 00000000..f298009d --- /dev/null +++ b/docs/error-handling.md @@ -0,0 +1,49 @@ +# Error handling +Handling errors correctly is a key aspect of writing stable software. This guide will give some tips on how you should deal with errors, as well as the utilities available to make error handling simpler. + +Before going into specifics, it would be useful to discuss application errors in general terms. The errors you experience are likely to fall into one of the following broad groups: +- **Initialisation errors**: i.e. problems during start-up +- **General server errors**: errors which occur outside of user requests, possibly during automated tasks +- **User errors**: errors which are a direct result of a user request + +You will need to deal with each category of error differently. Below are some general tips on handling each type of error. + +## Initialisation errors +Any errors which occur during initialisation should be captured and logged as appropriate. Depending on the type of error, it may or may not be considered fatal to your code. + +Some examples: +- For a database-handler module, failing to connect to the database would be considered a fatal error, as no further actions can be executed. In this case, the code should perform any clean-up and exit. +- For a configuration module, failing to load the user configuration file may not be fatal if the application can run without it (e.g. with default settings). In this case the error should be logged, but the code can continue to initialise post-error. +- For a module which attempts to load a specific file in each module connected to the core system, failing to load a single configuration file may not be an error as such, but rather an expected outcome if the configuration file in question is not something that's required to be defined for every module. In this case, the code can continue and it may not even be necessary to log a message. + +## General server errors +'General server errors' is a broad category which covers other errors that don't take place at either initialisation or as a result of direct user action. Again, depending on the specific error, these may or may not be fatal. + +Some examples: +- For a database-handler module, disconnecting from the database is an expected error, and can be handled and rectified easily. + +## User errors +User errors are any errors which are caused as a direct result of a user performing an action incorrectly. It is even *more* critical with user errors that the error is as specific and descriptive as possible, as the response needs to be informative and instructive to the user that caused the error. Failing to do so will result in an unpleasant user experience. + +Some examples: +- A user uploads a file in an invalid format. This definitely isn't a fatal error, as the code can continue post-error. The returned error should inform the user of the issue, as well as how it can be rectified. + +## Defining errors +Depending on the kinds of error that you're dealing with in your code, it may be useful to include a set of custom error definitions specific to your code. + +Defining useful errors is a critical part of any software system. The error registry makes it easy to define errors for your own modules, and make use of errors defined in other modules. + +## Catching errors + +## Throwing errors +As mentioned above, it is preferable to catch errors internally in your code and re-throw these errors. + +The error registry acts as a central store for all errors defined in the system, and errors can be accessed and thrown from here. For convenience, the errors registry is available directly as a property of the main App instance via `app.errors`: + +```js +try { + // do something here +} catch(e) { + throw this.app.errors.MY_ERROR +} +``` diff --git a/docs/plugins/configuration.js b/docs/plugins/configuration.js new file mode 100644 index 00000000..204ae5e9 --- /dev/null +++ b/docs/plugins/configuration.js @@ -0,0 +1,75 @@ +import fs from 'fs' +import path from 'path' + +export default class Configuration { + async run () { + const schemas = this.loadSchemas() + this.contents = Object.keys(schemas).sort() + this.manualFile = 'configuration.md' + this.replace = { + CODE_EXAMPLE: this.generateCodeExample(schemas), + LIST: this.generateList(schemas) + } + } + + loadSchemas () { + const schemas = {} + Object.values(this.app.dependencies).forEach(c => { + const confDir = path.join(c.rootDir, 'conf') + try { + schemas[c.name] = JSON.parse(fs.readFileSync(path.join(confDir, 'config.schema.json'))) + } catch (e) {} + }) + return schemas + } + + generateCodeExample (schemas) { + let output = '```javascript\nexport default {\n' + this.contents.forEach((name) => { + const schema = schemas[name] + output += ` '${name}': {\n` + Object.entries(schema.properties).forEach(([attr, config]) => { + const required = schema.required && schema.required.includes(attr) + if (config.description) output += ` // ${config.description}\n` + output += ` ${attr}: ${this.defaultToMd(config)}, // ${config.type}, ${required ? 'required' : 'optional'}\n` + }) + output += ' },\n' + }) + output += '};\n```' + return output + } + + generateList (schemas) { + let output = '' + + this.contents.forEach(dep => { + const schema = schemas[dep] + output += `

${dep}

\n\n` + output += '
\n' + Object.entries(schema.properties).forEach(([attr, config]) => { + const required = schema.required && schema.required.includes(attr) + output += '
\n' + output += `
${attr} (${config.type || ''}, ${required ? 'required' : 'optional'})
\n` + output += '
\n' + output += `
${config.description}
\n` + if (!required) { + output += `
Default:
${this.defaultToMd(config)}
\n` + } + output += '
\n' + output += '
\n' + }) + output += '
' + output += '\n\n' + }) + + return output + } + + /** + * Returns a string formatted nicely for markdown + */ + defaultToMd (config) { + const s = JSON.stringify(config.default, null, 2) + return s?.length < 75 ? s : s?.replaceAll('\n', '\n ') + } +} diff --git a/docs/plugins/configuration.md b/docs/plugins/configuration.md new file mode 100644 index 00000000..a920420c --- /dev/null +++ b/docs/plugins/configuration.md @@ -0,0 +1,14 @@ +# Configuration reference +This page lists all configuration options supported by the [core bundle](coremodules) of Adapt authoring modules. For details on how to set up your configuration, including using environment variables, see [Configuring your environment](configure-environment). + +{{{TABLE_OF_CONTENTS}}} + +## Quick reference +See below for an overview of all available configuration options. + +{{{CODE_EXAMPLE}}} + +## Complete reference +See below for a full list of available configuration options. + +{{{LIST}}} diff --git a/docs/plugins/errors.js b/docs/plugins/errors.js new file mode 100644 index 00000000..e60ab5d4 --- /dev/null +++ b/docs/plugins/errors.js @@ -0,0 +1,22 @@ +export default class Errors { + async run () { + this.manualFile = 'errorsref.md' + this.contents = Object.keys(this.app.errors) + this.replace = { ERRORS: this.generateMd() } + } + + generateMd () { + return Object.keys(this.app.errors).reduce((md, k) => { + const e = this.app.errors[k] + return `${md}\n| \`${e.code}\` | ${e.meta.description} | ${e.statusCode} | |` + }, '| Error code | Description | HTTP status code | Supplemental data |\n| - | - | :-: | - |') + } + + dataToMd (data) { + if (!data) return '' + return Object.entries(data).reduce((acc, [k, v]) => { + const nested = typeof v === 'object' ? this.dataToMd(v) : v + return `${acc}
  • \`${k}\`: ${nested}
  • ` + }, '') + } +} diff --git a/docs/plugins/errorsref.md b/docs/plugins/errorsref.md new file mode 100644 index 00000000..fc7dd523 --- /dev/null +++ b/docs/plugins/errorsref.md @@ -0,0 +1,9 @@ +# Errors Reference + +This page documents all errors which are likely to be thrown in the system, along with the appropriate HTTP status code and any supplemental data which is stored with the error. + +Supplemental data can be used at the point that errors are translated to provide more context to a specific error. All data stored with an error can be assumed to be a primitive type for easy printing. + +{{{TABLE_OF_CONTENTS}}} + +{{{ERRORS}}} diff --git a/errors/errors.json b/errors/errors.json index 51095e92..2a3de6d8 100644 --- a/errors/errors.json +++ b/errors/errors.json @@ -1,4 +1,84 @@ { + "FILE_SYNTAX_ERROR": { + "data": { + "path": "Path to the invalid file", + "message": "The error message" + }, + "description": "File contains a syntax error", + "statusCode": 500 + }, + "DEP_ALREADY_LOADED": { + "data": { "module": "The module name" }, + "description": "Module has already been loaded", + "statusCode": 500 + }, + "DEP_FAILED": { + "data": { "module": "The module name" }, + "description": "Required dependency failed to load", + "statusCode": 500, + "isFatal": true + }, + "DEP_INVALID_EXPORT": { + "data": { "module": "The module name" }, + "description": "Module must export a class as its default export", + "statusCode": 500 + }, + "DEP_MISSING": { + "data": { "module": "The module name" }, + "description": "Required module is not installed", + "statusCode": 500, + "isFatal": true + }, + "DEP_NO_ONREADY": { + "data": { "module": "The module name" }, + "description": "Module must define an onReady function", + "statusCode": 500 + }, + "DEP_TIMEOUT": { + "data": { "module": "The module name", "timeout": "The timeout in ms" }, + "description": "Module load exceeded timeout", + "statusCode": 500 + }, + "FUNC_DISABLED": { + "data": { + "name": "The name of the function" + }, + "description": "Function has been disabled", + "statusCode": 500 + }, + "FUNC_NOT_OVERRIDDEN": { + "data": { + "name": "The name of the function" + }, + "description": "Function must be overridden in child class", + "statusCode": 500 + }, + "INVALID_PARAMS": { + "data": { + "params": "The invalid params" + }, + "description": "Invalid parameters have been provided", + "statusCode": 400 + }, + "LOAD_ERROR": { + "description": "Config failed to load", + "statusCode": 500 + }, + "NOT_FOUND": { + "data": { + "id": "An identifier for the missing item", + "type": "Type of the missing item" + }, + "description": "Requested item could not be found", + "statusCode": 404 + }, + "SERVER_ERROR": { + "description": "Generic server error", + "statusCode": 500, + "data": { + "error": "The original error" + } + }, "SPAWN": { "data": { "cmd": "The command", @@ -7,5 +87,12 @@ }, "description": "Error occurred spawning command", "statusCode": 500 + }, + "UNKNOWN_LANG": { + "data": { + "lang": "language" + }, + "description": "unknown language", + "statusCode": 400 } } diff --git a/errors/node-core.json b/errors/node-core.json new file mode 100644 index 00000000..e31cfe9b --- /dev/null +++ b/errors/node-core.json @@ -0,0 +1,39 @@ +{ + "EACCES": { + "description": "An attempt was made to access a file in a way forbidden by its file access permissions", + "statusCode": 500 + }, + "EADDRINUSE": { + "description": "An attempt to bind a server to a local address failed due to another server on the local system already occupying that address", + "statusCode": 500 + }, + "ECONNREFUSED": { + "description": "No connection could be made because the target machine actively refused it", + "statusCode": 500 + }, + "EEXIST": { + "description": "An existing file was the target of an operation that required that the target not exist", + "statusCode": 500, + "data": { + "path": "Path to target file or directory" + } + }, + "ENOENT": { + "description": "No entity (file or directory) could be found by the given path", + "statusCode": 500, + "data": { + "path": "Path to target file or directory" + } + }, + "ENOTEMPTY": { + "description": "A directory with entries was the target of an operation that requires an empty directory", + "statusCode": 500, + "data": { + "path": "Path to target file or directory" + } + }, + "MODULE_NOT_FOUND": { + "description": "A module file could not be resolved while attempting a require() or import operation", + "statusCode": 500 + } +} diff --git a/index.js b/index.js index e0cda4dd..6ddf7042 100644 --- a/index.js +++ b/index.js @@ -1,6 +1,11 @@ export { default as AbstractModule } from './lib/AbstractModule.js' +export { default as AdaptError } from './lib/AdaptError.js' export { default as App } from './lib/App.js' +export { default as Config } from './lib/Config.js' export { default as DataCache } from './lib/DataCache.js' export { default as DependencyLoader } from './lib/DependencyLoader.js' +export { default as Errors } from './lib/Errors.js' export { default as Hook } from './lib/Hook.js' +export { default as Lang } from './lib/Lang.js' +export { default as Logger } from './lib/Logger.js' export { metadataFileName, packageFileName, isObject, getArgs, spawn, readJson, writeJson, toBoolean, ensureDir, escapeRegExp, stringifyValues, loadDependencyFiles } from './lib/Utils.js' diff --git a/lib/AbstractModule.js b/lib/AbstractModule.js index 612214c2..fa2be899 100644 --- a/lib/AbstractModule.js +++ b/lib/AbstractModule.js @@ -105,26 +105,16 @@ class AbstractModule { * @return {*} */ getConfig (key) { - try { - return this.app.config.get(`${this.name}.${key}`) - } catch (e) { - return undefined - } + return this.app.config?.get(`${this.name}.${key}`) } /** - * Log a message using the Logger module + * Log a message using the Logger * @param {String} level Log level of message * @param {...*} rest Arguments to log */ log (level, ...rest) { - const _log = (e, instance) => { - if (!this.app.logger || (instance && instance.name !== this.app.logger.name)) return false - this.app.dependencyloader.moduleLoadedHook.untap(_log) - this.app.logger.log(level, this.name.replace(/^adapt-authoring-/, ''), ...rest) - return true - } - if (!_log()) this.app.dependencyloader.moduleLoadedHook.tap(_log) + this.app.logger?.log(level, this.name.replace(/^adapt-authoring-/, ''), ...rest) } } diff --git a/lib/AdaptError.js b/lib/AdaptError.js new file mode 100644 index 00000000..50fd8954 --- /dev/null +++ b/lib/AdaptError.js @@ -0,0 +1,57 @@ +/** + * A generic error class for use in Adapt applications + * @memberof core + */ +class AdaptError extends Error { + /** + * @constructor + * @param {string} code The human-readable error code + * @param {number} statusCode The HTTP status code + * @param {object} metadata Metadata describing the error + */ + constructor (code, statusCode = 500, metadata = {}) { + super(code) + /** + * The error code + * @type {String} + */ + this.code = code + /** + * The HTTP status code + * @type {String} + */ + this.statusCode = statusCode + /** + * Whether this error should halt the application + * @type {Boolean} + */ + this.isFatal = metadata.isFatal ?? false + /** + * Metadata describing the error + * @type {Object} + */ + this.meta = metadata + } + + /** + * Chainable function to allow setting of data for use in user-friendly error messages later on. + * @param {object} data + * @returns {AdaptError} + * @example + * // note calling this function will also return + * // the error itself to allow for easy error throwing + * throw this.app.errors.MY_ERROR + * .setData({ hello: 'world' }) + */ + setData (data) { + this.data = data + return this + } + + /** @override */ + toString () { + return `${this.constructor.name}: ${this.code} ${this.data ? JSON.stringify(this.data) : ''}` + } +} + +export default AdaptError diff --git a/lib/App.js b/lib/App.js index bad4665f..f1d6711a 100644 --- a/lib/App.js +++ b/lib/App.js @@ -1,7 +1,12 @@ import AbstractModule from './AbstractModule.js' +import Config from './Config.js' import DependencyLoader from './DependencyLoader.js' +import Errors from './Errors.js' +import Lang from './Lang.js' +import Logger from './Logger.js' import fs from 'fs' import path from 'path' +import { runMigrations } from 'adapt-authoring-migrations' import { metadataFileName, packageFileName, getArgs } from './Utils.js' let instance @@ -26,54 +31,85 @@ class App extends AbstractModule { /** @override */ constructor () { + process.env.NODE_ENV ??= 'production' const rootDir = process.env.ROOT_DIR ?? process.cwd() const adaptJson = JSON.parse(fs.readFileSync(path.join(rootDir, metadataFileName))) const packageJson = JSON.parse(fs.readFileSync(path.join(rootDir, packageFileName))) super(null, { ...packageJson, ...adaptJson, name: 'adapt-authoring-core', rootDir }) - this.git = this.getGitInfo() } /** @override */ async init () { - /** - * Reference to the passed arguments (parsed for easy reference) - * @type {Object} - */ - this.args = getArgs() - /** - * Instance of App instance (required by all AbstractModules) - * @type {App} - */ - this.app = this - /** - * Reference to the DependencyLoader instance - * @type {DependencyLoader} - */ - this.dependencyloader = new DependencyLoader(this) + try { + /** + * Instance of App instance (required by all AbstractModules) + * @type {App} + */ + this.app = this + /** + * Reference to the passed arguments (parsed for easy reference) + * @type {Object} + */ + this.args = getArgs() + /** + * Reference to the Config instance + * @type {Config} + */ + this.config = undefined + /** + * Reference to the error registry + * @type {Errors} + */ + this.errors = undefined + /** + * Reference to the Lang instance + * @type {Lang} + */ + this.lang = undefined + /** + * Reference to the Logger instance + * @type {Logger} + */ + this.logger = new Logger() + /** + * Reference to the DependencyLoader instance + * @type {DependencyLoader} + */ + this.dependencyloader = new DependencyLoader(this) + /** + * Git metadata for the application (branch and commit hash) + * @type {Object} + */ + this.git = this.getGitInfo() - /** @ignore */ this._isStarting = false + await this.dependencyloader.loadConfigs() - const configRootDir = this.getConfig('rootDir') - if (configRootDir) /** @ignore */this.rootDir = configRootDir + const options = { + dependencies: this.dependencies, + configFilePath: path.join(this.rootDir, 'conf', `${process.env.NODE_ENV}.config.js`), + rootDir: this.rootDir, + log: (...args) => this.logger.log(...args) + } + + await runMigrations({ ...options, dryRun: this.args['dry-run'] === true }) + + this.config = await new Config({ ...options, appName: this.name }).load() + this.logger = new Logger({ levels: this.getConfig('logLevels'), showTimestamp: this.getConfig('showLogTimestamp') }) + this.errors = new Errors(options) + this.lang = new Lang({ ...options, defaultLang: this.getConfig('defaultLang') }) + + await this.dependencyloader.loadModules() - let startError - try { - await this.start() this.log('verbose', 'GIT', 'INFO', this.git) this.log('verbose', 'DIR', 'rootDir', this.rootDir) this.log('verbose', 'DIR', 'dataDir', this.getConfig('dataDir')) this.log('verbose', 'DIR', 'tempDir', this.getConfig('tempDir')) - } catch (e) { - startError = e + } catch (cause) { + await this.setReady(new Error('Failed to start App', { cause })) + process.exit(1) } const failedMods = this.dependencyloader.failedModules if (failedMods.length) this.log('warn', `${failedMods.length} module${failedMods.length === 1 ? '' : 's'} failed to load: ${failedMods}. See above for details`) - if (startError) { - process.exitCode = 1 - const e = new Error('Failed to start App') - e.cause = startError - throw e - } } /** @@ -101,21 +137,6 @@ class App extends AbstractModule { } } - /** - * Starts the app - * @return {Promise} Resolves when the app has started - */ - async start () { - if (this._isReady) throw new Error('warn', 'cannot start app, already started') - if (this._isStarting) throw new Error('warn', 'cannot start app, already initialising') - - this._isStarting = true - - await this.dependencyloader.load() - - this._isStarting = false - } - /** * Enables waiting for other modules to load * @param {...String} modNames Names of modules to wait for @@ -125,12 +146,6 @@ class App extends AbstractModule { const results = await Promise.all(modNames.map(m => this.dependencyloader.waitForModule(m))) return results.length > 1 ? results : results[0] } - - /** @override */ - setReady (error) { - this._isStarting = false - super.setReady(error) - } } export default App diff --git a/lib/Config.js b/lib/Config.js new file mode 100644 index 00000000..053c06c8 --- /dev/null +++ b/lib/Config.js @@ -0,0 +1,226 @@ +import { Schemas } from 'adapt-schemas' +import fs from 'fs/promises' +import path from 'path' + +/** + * Loads, validates, and provides access to application configuration. + * Configuration is sourced from user settings files, environment variables, and module schema defaults. + * @memberof core + */ +class Config { + /** + * @param {Object} options + * @param {String} options.rootDir Application root directory + * @param {String} options.configFilePath Path to the user configuration file + * @param {Object} options.dependencies Key/value map of dependency configs + * @param {String} options.appName The core module name (for sorting) + * @param {Function} options.log Logging function (level, id, ...args) + */ + constructor ({ rootDir, configFilePath, dependencies = {}, appName = '', log = () => {} } = {}) { + /** @ignore */ + this._config = {} + /** + * Application root directory + * @type {String} + */ + this.rootDir = rootDir + /** + * Path to the user configuration file + * @type {String} + */ + this.configFilePath = configFilePath + /** + * The keys for all attributes marked as public + * @type {Array} + */ + this.publicAttributes = [] + /** @ignore */ + this._dependencies = dependencies + /** @ignore */ + this._appName = appName + /** @ignore */ + this.log = log + } + + /** + * Loads configuration from all sources + * @returns {Promise} + */ + async load () { + await this.storeUserSettings() + this.storeEnvSettings() + this.storeSchemaSettings(this._dependencies, this._appName) + this.log('info', 'config', `using config at ${this.configFilePath}`) + return this + } + + /** + * Determines whether an attribute has a set value + * @param {String} attr Attribute key name + * @return {Boolean} + */ + has (attr) { + return Object.hasOwn(this._config, attr) + } + + /** + * Returns a value for a given attribute + * @param {String} attr Attribute key name + * @return {*} + */ + get (attr) { + return this._config[attr] + } + + /** + * Retrieves all config options marked as 'public' + * @return {Object} + */ + getPublicConfig () { + return this.publicAttributes.reduce((m, a) => { + m[a] = this.get(a) + return m + }, {}) + } + + /** + * Loads the relevant config file into memory + * @return {Promise} + */ + async storeUserSettings () { + let config + try { + await fs.readFile(this.configFilePath) + config = (await import(this.configFilePath)).default + } catch (e) { + this.log('warn', 'config', `Failed to load config at ${this.configFilePath}: ${e}. Will attempt to run with defaults.`) + return + } + Object.entries(config).forEach(([name, c]) => { + Object.entries(c).forEach(([key, val]) => { + this._config[`${name}.${key}`] = val + }) + }) + } + + /** + * Copy env values to config + */ + storeEnvSettings () { + Object.entries(process.env).forEach(([key, val]) => { + try { + val = JSON.parse(val) + } catch {} // ignore parse errors for non-JSON values + this._config[Config.envVarToConfigKey(key)] = val + }) + } + + /** + * Processes all module config schema files + * @param {Object} dependencies Key/value map of dependency configs + * @param {String} appName The core module name (for sorting) + */ + storeSchemaSettings (dependencies, appName) { + const schemas = new Schemas().init() + const isCore = d => d.name === appName + const deps = Object.values(dependencies).sort((a, b) => { + if (isCore(a)) return -1 + if (isCore(b)) return 1 + return a.name.localeCompare(b.name) + }) + const coreDep = deps.find(d => isCore(d)) + if (coreDep) this.processModuleSchema(coreDep, schemas) + + const errors = [] + for (const d of deps.filter(d => !isCore(d))) { + try { + this.processModuleSchema(d, schemas) + } catch (e) { + errors.push(e?.data?.errors ? { modName: e.modName, message: e.data.errors } : { message: String(e) }) + } + } + if (errors.length) { + errors.forEach(e => { + this.log('error', 'config', `${e.modName ? e.modName + ': ' : ''}${e.message}`) + }) + throw new Error('Config validation failed') + } + } + + /** + * Processes and validates a single module config schema + * @param {Object} pkg Package.json data + * @param {Schemas} schemas Schemas library instance + */ + processModuleSchema (pkg, schemas) { + if (!pkg.name || !pkg.rootDir) return + const schemaPath = path.resolve(pkg.rootDir, 'conf/config.schema.json') + let schema + try { + // TODO config schemas should define $id, remove this workaround once they do + schema = schemas.createSchema(schemaPath) + schema.raw.$id = pkg.name + schema.build({ compile: false }) + schema.built.$id = pkg.name + schema.compiledWithDefaults = schemas.validatorWithDefaults.compile(schema.built) + schema.compiled = schemas.validator.compile(schema.built) + } catch (e) { + if (e.code !== 'SCHEMA_LOAD_FAILED') { + this.log('warn', 'config', `${pkg.name}: ${e.message}`) + } + return + } + const dirKeys = new Set() + let data = Object.entries(schema.raw.properties).reduce((m, [k, v]) => { + if (v?._adapt?.isPublic) this.publicAttributes.push(`${pkg.name}.${k}`) + if (v?.isDirectory) dirKeys.add(k) + return { ...m, [k]: this.get(`${pkg.name}.${k}`) } + }, {}) + try { + data = schema.validate(data) + } catch (e) { + e.modName = pkg.name + throw e + } + Object.entries(data).forEach(([key, val]) => { + if (dirKeys.has(key) && typeof val === 'string') { + val = this.resolveDirectory(val) + } + this._config[`${pkg.name}.${key}`] = val + }) + } + + /** + * Resolves directory path variables ($ROOT, $DATA, $TEMP) + * @param {String} value The path string to resolve + * @return {String} + */ + resolveDirectory (value) { + const vars = [ + ['$ROOT', this.rootDir], + ['$DATA', this._config['adapt-authoring-core.dataDir']], + ['$TEMP', this._config['adapt-authoring-core.tempDir']] + ] + for (const [key, replacement] of vars) { + if (value.startsWith(key) && replacement && !replacement.startsWith('$')) { + return path.resolve(replacement, value.replace(key, '').slice(1)) + } + } + return value + } + + /** + * Parses an environment variable key into a format expected by Config + * @param {String} envVar + * @return {String} + */ + static envVarToConfigKey (envVar) { + if (envVar.startsWith('ADAPT_AUTHORING_')) { + const [modPrefix, key] = envVar.split('__') + return `${modPrefix.replace(/_/g, '-').toLowerCase()}.${key}` + } + return `env.${envVar}` + } +} + +export default Config diff --git a/lib/DependencyLoader.js b/lib/DependencyLoader.js index c95aa005..5c4df053 100644 --- a/lib/DependencyLoader.js +++ b/lib/DependencyLoader.js @@ -1,10 +1,8 @@ -/* eslint no-console: 0 */ -import _ from 'lodash' -import fs from 'fs-extra' import { glob } from 'glob' import path from 'path' import Hook from './Hook.js' -import { metadataFileName, packageFileName, stripScope } from './Utils.js' +import { metadataFileName, packageFileName, stripScope, readJson } from './Utils.js' + /** * Handles the loading of Adapt authoring tool module dependencies. * @memberof core @@ -59,47 +57,21 @@ class DependencyLoader { this.moduleLoadedHook.tap(this.logProgress, this) } - /** - * Loads all Adapt module dependencies. Essential modules are loaded first, then non-essential modules (with force mode). - * @return {Promise} - * @throws {Error} When any essential module fails to load - */ - async load () { - await this.loadConfigs() - - const configValues = Object.values(this.configs) - // sort dependencies into priority - const { essential, theRest } = configValues.reduce((m, c) => { - this.app.pkg.essentialApis.includes(c.essentialType) ? m.essential.push(c.name) : m.theRest.push(c.name) - return m - }, { essential: [], theRest: [] }) - // load each set of deps - await this.loadModules(essential) - await this.loadModules(theRest, { force: true }) - - if (this.failedModules.length) { - throw new Error(`Failed to load modules ${this.failedModules.join(', ')}`) - } - } - /** * Loads configuration files for all Adapt dependencies found in node_modules. * @return {Promise} */ async loadConfigs () { /** @ignore */ this._configsLoaded = false + const corePathSegment = `/${this.app.name}/` const files = await glob(`${this.app.rootDir}/node_modules/**/${metadataFileName}`) const deps = files .map(d => d.replace(`${metadataFileName}`, '')) - .sort((a, b) => a.length < b.length ? -1 : 1) - - // sort so that core is loaded first, as other modules may use its config values - const corePathSegment = `/${this.app.name}/` - deps.sort((a, b) => { - if (a.endsWith(corePathSegment)) return -1 - if (b.endsWith(corePathSegment)) return 1 - return 0 - }) + .sort((a, b) => { + if (a.endsWith(corePathSegment)) return -1 + if (b.endsWith(corePathSegment)) return 1 + return a.length - b.length + }) for (const d of deps) { try { const c = await this.loadModuleConfig(d) @@ -112,8 +84,8 @@ class DependencyLoader { } } } catch (e) { - this.logError(`Failed to load config for '${d}', module will not be loaded`) - this.logError(e) + this.log('error', `Failed to load config for '${d}', module will not be loaded`) + this.log('error', e) } } this._configsLoaded = true @@ -126,10 +98,10 @@ class DependencyLoader { * @return {Promise} Resolves with configuration object */ async loadModuleConfig (modDir) { - const pkg = await fs.readJson(path.join(modDir, packageFileName)) + const pkg = await readJson(path.join(modDir, packageFileName)) return { ...pkg, - ...await fs.readJson(path.join(modDir, metadataFileName)), + ...await readJson(path.join(modDir, metadataFileName)), name: stripScope(pkg.name), packageName: pkg.name, rootDir: modDir @@ -144,7 +116,7 @@ class DependencyLoader { */ async loadModule (modName) { if (this.instances[modName]) { - throw new Error('Module already exists') + throw this.app.errors.DEP_ALREADY_LOADED.setData({ module: modName }) } const config = this.configs[modName] @@ -153,54 +125,48 @@ class DependencyLoader { } const { default: ModClass } = await import(config.packageName) - if (!_.isFunction(ModClass)) { - throw new Error('Expected class to be exported') + if (typeof ModClass !== 'function') { + throw this.app.errors.DEP_INVALID_EXPORT.setData({ module: modName }) } const instance = new ModClass(this.app, config) - if (!_.isFunction(instance.onReady)) { - throw new Error('Module must define onReady function') + if (typeof instance.onReady !== 'function') { + throw this.app.errors.DEP_NO_ONREADY.setData({ module: modName }) } try { - // all essential modules will use hard-coded value, as config won't be loaded yet - const timeout = this.getConfig('moduleLoadTimeout') ?? 10000 + const timeout = this.app.getConfig('moduleLoadTimeout') ?? 10000 await Promise.race([ instance.onReady(), - new Promise((resolve, reject) => setTimeout(() => reject(new Error(`${modName} load exceeded timeout (${timeout})`)), timeout)) + new Promise((resolve, reject) => setTimeout(() => reject(this.app.errors.DEP_TIMEOUT.setData({ module: modName, timeout })), timeout)) ]) this.instances[modName] = instance await this.moduleLoadedHook.invoke(null, instance) return instance } catch (e) { - await this.moduleLoadedHook.invoke(e) + await this.moduleLoadedHook.invoke(e, { name: modName }) throw e } } /** - * Loads a list of Adapt modules. Should not need to be called directly. - * @param {Array} modules Module names to load - * @param {Object} [options] Loading options - * @param {boolean} [options.force=false] If true, logs errors and continues loading other modules when a module fails. If false, throws a DependencyError on first failure. - * @return {Promise} Resolves when all modules have loaded (or failed to load in force mode) - * @throws {DependencyError} When a module fails to load and options.force is not true + * Loads Adapt modules. If no list is provided, loads all configured dependencies. + * @param {Array} [modules] Module names to load (defaults to all dependencies) + * @return {Promise} Resolves when all modules have loaded or failed + * @throws {Error} When any module throws a fatal error (error.isFatal or error.cause.isFatal) */ - async loadModules (modules, options = {}) { + async loadModules (modules = Object.values(this.configs).map(c => c.name)) { await Promise.all(modules.map(async m => { try { await this.loadModule(m) } catch (e) { - if (options.force !== true) { - const error = new Error(`Failed to load '${m}'`) - error.name = 'DependencyError' - error.cause = e - throw error + if (e.isFatal || e.cause?.isFatal) { + throw e } - this.logError(`Failed to load '${m}',`, e) + this.log('error', `Failed to load '${m}',`, e) const deps = this.peerDependencies[m] - if (deps && deps.length) { - this.logError('The following modules are peer dependencies, and may not work:') - deps.forEach(d => this.logError(`- ${d}`)) + if (deps?.length) { + this.log('error', 'The following modules are peer dependencies, and may not work:') + deps.forEach(d => this.log('error', `- ${d}`)) } this.failedModules.push(m) } @@ -217,14 +183,12 @@ class DependencyLoader { if (!this._configsLoaded) { await this.configsLoadedHook.onInvoke() } - const longPrefix = 'adapt-authoring-' - if (!modName.startsWith(longPrefix)) modName = `adapt-authoring-${modName}` + if (!modName.startsWith('adapt-authoring-')) modName = `adapt-authoring-${modName}` if (!this.configs[modName]) { - throw new Error(`Missing required module '${modName}'`) + throw this.app.errors.DEP_MISSING.setData({ module: modName }) } - const DependencyError = new Error(`Dependency '${modName}' failed to load`) if (this.failedModules.includes(modName)) { - throw DependencyError + throw this.app.errors.DEP_FAILED.setData({ module: modName }) } const instance = this.instances[modName] if (instance) { @@ -232,8 +196,9 @@ class DependencyLoader { } return new Promise((resolve, reject) => { this.moduleLoadedHook.tap((error, instance) => { - if (error) return reject(DependencyError) - if (instance?.name === modName) resolve(instance) + if (instance?.name !== modName) return + if (error) return reject(this.app.errors.DEP_FAILED.setData({ module: modName })) + resolve(instance) }) }) } @@ -243,9 +208,8 @@ class DependencyLoader { * @param {AbstractModule} instance The last loaded instance */ logProgress (error, instance) { - if (error) { - return - } + if (error) return + const toShort = names => names.map(n => n.replace('adapt-authoring-', '')).join(', ') const loaded = [] const notLoaded = [] @@ -264,42 +228,21 @@ class DependencyLoader { ].filter(Boolean).join(', ')) if (progress === 100) { - const initTimes = Object.entries(this.instances) - .sort((a, b) => a[1].initTime < b[1].initTime ? -1 : a[1].initTime > b[1].initTime ? 1 : 0) - .reduce((memo, [modName, instance]) => Object.assign(memo, { [modName]: instance.initTime }), {}) + const initTimes = Object.fromEntries( + Object.entries(this.instances) + .sort(([, a], [, b]) => a.initTime - b.initTime) + .map(([name, inst]) => [name, inst.initTime]) + ) this.log('verbose', initTimes) } } /** - * Logs a message using the app logger if available, otherwise falls back to console.log + * Logs a message using the app logger * @param {...*} args Arguments to be logged */ log (level, ...args) { - if (this.app.logger?._isReady) { - this.app.logger.log(level, this.name, ...args) - } else { - console.log(...args) - } - } - - /** - * Logs an error message using the app logger if available, otherwise falls back to console.log - * @param {...*} args Arguments to be logged - */ - logError (...args) { - this.log('error', ...args) - } - - /** - * Retrieves a configuration value from this module's config - * @param {string} key - The configuration key to retrieve - * @returns {*|undefined} The configuration value if config is ready, undefined otherwise - */ - getConfig (key) { - if (this.app.config?._isReady) { - return this.app.config.get(`adapt-authoring-core.${key}`) - } + this.app.logger?.log(level, this.name, ...args) } } diff --git a/lib/Errors.js b/lib/Errors.js new file mode 100644 index 00000000..45527842 --- /dev/null +++ b/lib/Errors.js @@ -0,0 +1,50 @@ +import AdaptError from './AdaptError.js' +import fs from 'node:fs' +import { globSync } from 'glob' + +/** + * Loads and stores all error definitions for the application. Errors are accessed via human-readable error codes for better readability when thrown in code. + * @memberof core + */ +class Errors { + /** + * @param {Object} options + * @param {Object} options.dependencies Key/value map of dependency configs (each with a rootDir) + * @param {Function} [options.log] Optional logging function (level, id, ...args) + */ + constructor ({ dependencies, log } = {}) { + const errorDefs = {} + for (const d of Object.values(dependencies)) { + const files = globSync('errors/*.json', { cwd: d.rootDir, absolute: true }) + for (const f of files) { + try { + const contents = JSON.parse(fs.readFileSync(f)) + Object.entries(contents).forEach(([k, v]) => { + if (errorDefs[k]) { + log?.('warn', 'errors', `error code '${k}' already defined`) + return + } + errorDefs[k] = v + }) + } catch (e) { + log?.('warn', 'errors', e.message) + } + } + } + Object.entries(errorDefs) + .sort() + .forEach(([k, { description, statusCode, isFatal, data }]) => { + Object.defineProperty(this, k, { + get: () => { + const metadata = { description } + if (isFatal) metadata.isFatal = true + if (data) metadata.data = data + return new AdaptError(k, statusCode, metadata) + }, + enumerable: true + }) + }) + } +} + +export default Errors diff --git a/lib/Hook.js b/lib/Hook.js index 2dd1c114..65f3d064 100644 --- a/lib/Hook.js +++ b/lib/Hook.js @@ -1,4 +1,3 @@ -import _ from 'lodash' /** * Allows observers to tap into to a specific piece of code, and execute their own arbitrary code * @memberof core @@ -43,7 +42,7 @@ class Hook { * @param {*} scope Sets the scope of the observer */ tap (observer, scope) { - if (_.isFunction(observer)) this._hookObservers.push(observer.bind(scope)) + if (typeof observer === 'function') this._hookObservers.push(observer.bind(scope)) } /** @@ -78,7 +77,7 @@ class Hook { data = await Promise.all(this._hookObservers.map(o => o(...args))) } else { // if not mutable, send a deep copy of the args to avoid any meddling - for (const o of this._hookObservers) data = await o(...this._options.mutable ? args : args.map(a => _.cloneDeep(a))) + for (const o of this._hookObservers) data = await o(...this._options.mutable ? args : args.map(a => structuredClone(a))) } } catch (e) { error = e diff --git a/lib/Lang.js b/lib/Lang.js new file mode 100644 index 00000000..f13ec555 --- /dev/null +++ b/lib/Lang.js @@ -0,0 +1,125 @@ +import fs from 'node:fs' +import { globSync } from 'glob' +import path from 'node:path' + +/** + * Handles loading and translation of language strings. + * @memberof core + */ +class Lang { + /** + * @param {Object} options + * @param {Object} options.dependencies Key/value map of dependency configs (each with a rootDir) + * @param {String} options.defaultLang The default language for translations + * @param {String} options.rootDir The application root directory + * @param {Function} [options.log] Optional logging function (level, id, ...args) + */ + constructor ({ dependencies, defaultLang, rootDir, log } = {}) { + /** + * The loaded language phrases + * @type {Object} + */ + this.phrases = {} + /** + * The default language for translations + * @type {String} + */ + this.defaultLang = defaultLang + /** + * Optional logging function (level, id, ...args) + * @type {Function} + */ + this.log = log + this.loadPhrases(dependencies, rootDir, log) + } + + /** + * Returns the languages supported by the application + * @type {Array} + */ + get supportedLanguages () { + return Object.keys(this.phrases) + } + + /** + * Loads and merges all language phrases from dependencies + * @param {Object} dependencies Key/value map of dependency configs (each with a rootDir) + * @param {String} appRootDir The application root directory + * @param {Function} [log] Optional logging function (level, id, ...args) + */ + loadPhrases (dependencies = {}, appRootDir, log) { + const dirs = [ + ...(appRootDir ? [appRootDir] : []), + ...Object.values(dependencies).map(d => d.rootDir) + ] + for (const dir of dirs) { + const files = globSync('lang/**/*.json', { cwd: dir, absolute: true }) + for (const f of files) { + try { + const relative = path.relative(path.join(dir, 'lang'), f) + const parts = relative.replace(/\.json$/, '').split(path.sep) + const lang = parts[0] + const prefix = parts.length > 1 ? parts.slice(1).join('.') + '.' : '' + if (!this.phrases[lang]) this.phrases[lang] = {} + const contents = JSON.parse(fs.readFileSync(f, 'utf8')) + Object.entries(contents).forEach(([k, v]) => { this.phrases[lang][`${prefix}${k}`] = v }) + } catch (e) { + log?.('error', 'lang', e.message, f) + } + } + } + } + + /** + * Returns translated language string. If key is an Error, translates using + * the error code as the key and error data for substitution. Non-Error, + * non-string values are returned unchanged. + * @param {String} lang The target language (falls back to defaultLang) + * @param {String|Error} key The unique string key, or an Error to translate + * @param {Object} data Dynamic data to be inserted into translated string + * @return {String} + */ + translate (lang, key, data) { + if (typeof lang !== 'string') { + lang = this.defaultLang + } + if (key instanceof Error) { + return this.translate(lang, `error.${key.code}`, key.data ?? key) + } + if (typeof key !== 'string') { + return key + } + const s = this.phrases[lang]?.[key] + if (!s) { + this.log?.('warn', 'lang', `missing key '${lang}.${key}'`) + return key + } + if (!data) { + return s + } + return this.substituteData(s, lang, data) + } + + /** + * Replaces placeholders in a translated string with data values. + * Supports ${key} for simple substitution, and $map{key:attrs:delim} + * for mapping over array values. + * @param {String} s The translated string + * @param {String} lang The target language + * @param {Object} data Key/value pairs to substitute + * @return {String} + */ + substituteData (s, lang, data) { + for (const [k, v] of Object.entries(data)) { + const items = [v].flat().map(item => item instanceof Error ? this.translate(lang, item) : item) + s = s.replaceAll(`\${${k}}`, items) + for (const [match, expr] of s.matchAll(new RegExp(String.raw`\$map{${k}:(.+)}`, 'g'))) { + const [attrs, delim] = expr.split(':') + s = s.replace(match, items.map(val => attrs.split(',').map(a => val?.[a] ?? a).join(delim))) + } + } + return s + } +} + +export default Lang diff --git a/lib/Logger.js b/lib/Logger.js new file mode 100644 index 00000000..5e408099 --- /dev/null +++ b/lib/Logger.js @@ -0,0 +1,101 @@ +import chalk from 'chalk' +import Hook from './Hook.js' + +/** + * Provides console logging with configurable levels, colours, and module-specific overrides. + * @memberof core + */ +class Logger { + static levelColours = { + error: chalk.red, + warn: chalk.yellow, + success: chalk.green, + info: chalk.cyan, + debug: chalk.dim, + verbose: chalk.grey.italic + } + + /** + * Creates a Logger instance from config values + * @param {Object} options + * @param {Array} options.levels Log level config strings. An empty array mutes all output. + * @param {Boolean} options.showTimestamp Whether to show timestamps + */ + constructor ({ levels = Object.keys(Logger.levelColours), showTimestamp = true } = {}) { + /** + * Hook invoked on each message logged + * @type {Hook} + */ + this.logHook = new Hook() + /** @ignore */ + this.config = { + levels: Object.entries(Logger.levelColours).reduce((m, [level, colour]) => { + m[level] = { + enable: Logger.isLevelEnabled(levels, level), + moduleOverrides: Logger.getModuleOverrides(levels, level), + colour + } + return m + }, {}), + timestamp: showTimestamp, + mute: levels.length === 0 + } + } + + /** + * Logs a message to the console + * @param {String} level Severity of the message + * @param {String} id Identifier for the message + * @param {...*} args Arguments to be logged + */ + log (level, id, ...args) { + if (this.config.mute || !Logger.isLoggingEnabled(this.config.levels, level, id)) { + return + } + const colour = this.config.levels[level]?.colour + const logFunc = console[level] ?? console.log + const timestamp = this.config.timestamp ? chalk.dim(`${new Date().toISOString()} `) : '' + logFunc(`${timestamp}${colour ? colour(level) : level} ${chalk.magenta(id)}`, ...args) + this.logHook.invoke(new Date(), level, id, ...args).catch((error) => { + console.error('Logger logHook invocation failed:', error) + }) + } + + /** + * Determines whether a specific log level is enabled + * @param {Array} levelsConfig Array of level configuration strings + * @param {String} level The log level to check + * @return {Boolean} + */ + static isLevelEnabled (levelsConfig, level) { + return !levelsConfig.includes(`!${level}`) && levelsConfig.includes(level) + } + + /** + * Returns module-specific log level overrides + * @param {Array} levelsConfig Array of level configuration strings + * @param {String} level The log level to find overrides for + * @return {Array} + */ + static getModuleOverrides (levelsConfig, level) { + const prefix = `${level}.` + const negPrefix = `!${level}.` + return levelsConfig.filter(l => l.startsWith(prefix) || l.startsWith(negPrefix)) + } + + /** + * Returns whether a message should be logged based on the resolved config + * @param {Object} configLevels The resolved levels config object + * @param {String} level Logging level + * @param {String} id Id of log caller + * @returns {Boolean} + */ + static isLoggingEnabled (configLevels, level, id) { + const { enable, moduleOverrides = [] } = configLevels?.[level] || {} + const isEnabled = enable || moduleOverrides.includes(`${level}.${id}`) + const disableOverride = moduleOverrides.includes(`!${level}.${id}`) + return isEnabled && !disableOverride + } +} + +export default Logger diff --git a/lib/utils/getArgs.js b/lib/utils/getArgs.js index feeb1d60..605dc9a3 100644 --- a/lib/utils/getArgs.js +++ b/lib/utils/getArgs.js @@ -1,12 +1,10 @@ -import minimist from 'minimist' +import { parseArgs } from 'node:util' /** - * Returns the passed arguments, parsed by minimist for easy access + * Returns the passed arguments, parsed for easy access * @return {Object} The parsed arguments - * @see {@link https://github.com/substack/minimist#readme} */ export function getArgs () { - const args = minimist(process.argv) - args.params = args._.slice(2) - return args + const { values, positionals } = parseArgs({ strict: false, args: process.argv.slice(2) }) + return { ...values, params: positionals } } diff --git a/migrations/3.0.0-conf-migrate-lang-config.js b/migrations/3.0.0-conf-migrate-lang-config.js new file mode 100644 index 00000000..cc224870 --- /dev/null +++ b/migrations/3.0.0-conf-migrate-lang-config.js @@ -0,0 +1,7 @@ +export default function (migration) { + migration.describe('Move adapt-authoring-lang config keys to adapt-authoring-core') + + migration + .where('adapt-authoring-lang') + .replace('defaultLang', 'adapt-authoring-core') +} diff --git a/migrations/3.0.0-conf-migrate-logger-config.js b/migrations/3.0.0-conf-migrate-logger-config.js new file mode 100644 index 00000000..e4e79c0e --- /dev/null +++ b/migrations/3.0.0-conf-migrate-logger-config.js @@ -0,0 +1,9 @@ +export default function (migration) { + migration.describe('Move adapt-authoring-logger config keys to adapt-authoring-core') + + migration + .where('adapt-authoring-logger') + .replace('levels', 'adapt-authoring-core', 'logLevels') + .replace('showTimestamp', 'adapt-authoring-core', 'showLogTimestamp') + .remove('mute', 'dateFormat') +} diff --git a/package.json b/package.json index 9e81df83..e7a8e013 100644 --- a/package.json +++ b/package.json @@ -11,13 +11,14 @@ }, "repository": "github:adapt-security/adapt-authoring-core", "dependencies": { - "fs-extra": "11.3.3", - "glob": "^13.0.0", - "lodash": "^4.17.21", - "minimist": "^1.2.8" + "adapt-authoring-migrations": "github:adapt-security/adapt-authoring-migrations#feature/boot-phase-migrations", + "adapt-schemas": "^3.1.0", + "chalk": "^5.4.1", + "glob": "^13.0.0" }, "devDependencies": { "@adaptlearning/semantic-release-config": "^1.0.0", + "fs-extra": "^11.3.4", "standard": "^17.1.0" }, "release": { diff --git a/tests/AbstractModule.spec.js b/tests/AbstractModule.spec.js index bd2c9cb1..134ed9a4 100644 --- a/tests/AbstractModule.spec.js +++ b/tests/AbstractModule.spec.js @@ -448,13 +448,8 @@ describe('AbstractModule', () => { assert.equal(result, 'testValue') }) - it('should return undefined if config.get throws', async () => { + it('should return undefined when config is not available', async () => { const mockApp = { - config: { - get: () => { - throw new Error('config error') - } - }, dependencyloader: { moduleLoadedHook: { tap: () => {}, @@ -615,57 +610,12 @@ describe('AbstractModule', () => { assert.deepEqual(loggedArgs, ['arg1', 'arg2', 'arg3']) }) - it('should queue log and deliver when logger module loads', async () => { - let loggedLevel - let tapCallback - const mockApp = { - dependencyloader: { - moduleLoadedHook: { - tap: (fn) => { tapCallback = fn }, - untap: () => {} - } - } - } + it('should silently skip when logger is not available', async () => { + const mockApp = {} const module = new AbstractModule(mockApp, { name: 'test-mod' }) await module.onReady() - module.log('warn', 'deferred message') - assert.ok(tapCallback) - - mockApp.logger = { - name: 'adapt-authoring-logger', - log: (level) => { - loggedLevel = level - } - } - - tapCallback(null, { name: 'adapt-authoring-logger' }) - assert.equal(loggedLevel, 'warn') - }) - - it('should not log when loaded module is not the logger', async () => { - const logCalled = false - let tapCallback - const mockApp = { - dependencyloader: { - moduleLoadedHook: { - tap: (fn) => { tapCallback = fn }, - untap: () => {} - } - } - } - const module = new AbstractModule(mockApp, { name: 'test-mod' }) - await module.onReady() - - // No logger set yet, so log queues the callback - module.log('info', 'some message') - assert.ok(tapCallback) - - // Now simulate a non-logger module loading - _log checks !this.app.logger - // which is true (no logger), so it returns false - const result = tapCallback(null, { name: 'adapt-authoring-other' }) - assert.equal(result, false) - assert.equal(logCalled, false) + assert.doesNotThrow(() => module.log('warn', 'no logger')) }) }) diff --git a/tests/AdaptError.spec.js b/tests/AdaptError.spec.js new file mode 100644 index 00000000..cdefdf18 --- /dev/null +++ b/tests/AdaptError.spec.js @@ -0,0 +1,62 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import AdaptError from '../lib/AdaptError.js' + +describe('AdaptError', () => { + describe('constructor', () => { + it('should set code and default statusCode', () => { + const error = new AdaptError('TEST_ERROR') + assert.equal(error.code, 'TEST_ERROR') + assert.equal(error.statusCode, 500) + assert.equal(error.isFatal, false) + }) + + it('should set custom statusCode', () => { + const error = new AdaptError('NOT_FOUND', 404) + assert.equal(error.statusCode, 404) + }) + + it('should set isFatal from metadata', () => { + const error = new AdaptError('FATAL_ERROR', 500, { isFatal: true }) + assert.equal(error.isFatal, true) + }) + + it('should default isFatal to false when not in metadata', () => { + const error = new AdaptError('ERROR', 500, { description: 'test' }) + assert.equal(error.isFatal, false) + }) + + it('should store metadata', () => { + const meta = { description: 'test error', data: { id: 'test' } } + const error = new AdaptError('ERROR', 500, meta) + assert.deepEqual(error.meta, meta) + }) + + it('should extend Error', () => { + const error = new AdaptError('TEST') + assert.ok(error instanceof Error) + }) + }) + + describe('#setData()', () => { + it('should set data and return self for chaining', () => { + const error = new AdaptError('TEST') + const result = error.setData({ id: '123' }) + assert.equal(result, error) + assert.deepEqual(error.data, { id: '123' }) + }) + }) + + describe('#toString()', () => { + it('should include code without data', () => { + const error = new AdaptError('TEST') + assert.ok(error.toString().includes('TEST')) + }) + + it('should include stringified data when set', () => { + const error = new AdaptError('TEST') + error.setData({ id: '123' }) + assert.ok(error.toString().includes('123')) + }) + }) +}) diff --git a/tests/App.spec.js b/tests/App.spec.js deleted file mode 100644 index 18c781f7..00000000 --- a/tests/App.spec.js +++ /dev/null @@ -1,160 +0,0 @@ -import { describe, it, before, after } from 'node:test' -import assert from 'node:assert/strict' -import fs from 'fs-extra' -import path from 'path' -import { fileURLToPath } from 'url' -import App from '../lib/App.js' - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) - -describe('App', () => { - let testRootDir - let originalRootDir - - before(async () => { - testRootDir = path.join(__dirname, 'data', 'app-test') - await fs.ensureDir(testRootDir) - await fs.writeJson(path.join(testRootDir, 'package.json'), { - name: 'test-app', - version: '1.0.0' - }) - await fs.writeJson(path.join(testRootDir, 'adapt-authoring.json'), { - essentialApis: [] - }) - originalRootDir = process.env.ROOT_DIR - process.env.ROOT_DIR = testRootDir - }) - - after(async () => { - if (originalRootDir !== undefined) { - process.env.ROOT_DIR = originalRootDir - } else { - delete process.env.ROOT_DIR - } - await fs.remove(testRootDir) - }) - - describe('.instance', () => { - it('should return an App instance', () => { - const app = App.instance - assert.ok(app instanceof App) - }) - - it('should return the same instance on subsequent calls (singleton)', () => { - const app1 = App.instance - const app2 = App.instance - assert.equal(app1, app2) - }) - }) - - describe('constructor', () => { - it('should set name to adapt-authoring-core', () => { - const app = App.instance - assert.equal(app.name, 'adapt-authoring-core') - }) - - it('should set rootDir from ROOT_DIR env var', () => { - const app = App.instance - assert.equal(app.rootDir, testRootDir) - }) - - it('should initialize git info', () => { - const app = App.instance - assert.equal(typeof app.git, 'object') - }) - }) - - describe('#dependencies', () => { - it('should return the dependency configs from dependencyloader', () => { - const app = App.instance - assert.equal(typeof app.dependencies, 'object') - assert.equal(app.dependencies, app.dependencyloader.configs) - }) - }) - - describe('#getGitInfo()', () => { - it('should return an object', () => { - const app = App.instance - const info = app.getGitInfo() - assert.equal(typeof info, 'object') - }) - - it('should return empty object when .git directory does not exist', () => { - const app = App.instance - const origRootDir = app.rootDir - app.rootDir = '/nonexistent/path' - const info = app.getGitInfo() - app.rootDir = origRootDir - assert.deepEqual(info, {}) - }) - - it('should return object with branch and commit when .git exists', async () => { - const gitDir = path.join(testRootDir, '.git') - const refsDir = path.join(gitDir, 'refs', 'heads') - await fs.ensureDir(refsDir) - await fs.writeFile(path.join(gitDir, 'HEAD'), 'ref: refs/heads/main\n') - await fs.writeFile(path.join(refsDir, 'main'), 'abc123def456\n') - - const app = App.instance - const origRootDir = app.rootDir - app.rootDir = testRootDir - const info = app.getGitInfo() - app.rootDir = origRootDir - - assert.equal(info.branch, 'main') - assert.equal(info.commit, 'abc123def456') - - await fs.remove(gitDir) - }) - }) - - describe('#waitForModule()', () => { - it('should delegate to dependencyloader.waitForModule', async () => { - const app = App.instance - let calledWith - const origWaitForModule = app.dependencyloader.waitForModule.bind(app.dependencyloader) - app.dependencyloader.waitForModule = async (name) => { - calledWith = name - return { name } - } - const result = await app.waitForModule('test-mod') - app.dependencyloader.waitForModule = origWaitForModule - - assert.equal(calledWith, 'test-mod') - assert.deepEqual(result, { name: 'test-mod' }) - }) - - it('should return array when multiple module names are passed', async () => { - const app = App.instance - const origWaitForModule = app.dependencyloader.waitForModule.bind(app.dependencyloader) - app.dependencyloader.waitForModule = async (name) => ({ name }) - const result = await app.waitForModule('mod-a', 'mod-b') - app.dependencyloader.waitForModule = origWaitForModule - - assert.ok(Array.isArray(result)) - assert.equal(result.length, 2) - assert.deepEqual(result[0], { name: 'mod-a' }) - assert.deepEqual(result[1], { name: 'mod-b' }) - }) - - it('should return single result (not array) for single module', async () => { - const app = App.instance - const origWaitForModule = app.dependencyloader.waitForModule.bind(app.dependencyloader) - app.dependencyloader.waitForModule = async (name) => ({ name }) - const result = await app.waitForModule('single-mod') - app.dependencyloader.waitForModule = origWaitForModule - - assert.ok(!Array.isArray(result)) - assert.deepEqual(result, { name: 'single-mod' }) - }) - }) - - describe('#setReady()', () => { - it('should set _isStarting to false', async () => { - const app = App.instance - app._isStarting = true - await app.setReady() - assert.equal(app._isStarting, false) - }) - }) -}) diff --git a/tests/Config.spec.js b/tests/Config.spec.js new file mode 100644 index 00000000..a0a7beb8 --- /dev/null +++ b/tests/Config.spec.js @@ -0,0 +1,122 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import Config from '../lib/Config.js' +import path from 'path' + +describe('Config', () => { + describe('constructor', () => { + it('should initialise with empty config', () => { + const config = new Config() + assert.deepEqual(config.publicAttributes, []) + }) + }) + + describe('#has()', () => { + it('should return true for existing keys', () => { + const config = new Config() + config._config['test.key'] = 'value' + assert.equal(config.has('test.key'), true) + }) + + it('should return false for missing keys', () => { + const config = new Config() + assert.equal(config.has('missing.key'), false) + }) + }) + + describe('#get()', () => { + it('should return value for existing key', () => { + const config = new Config() + config._config['test.key'] = 'value' + assert.equal(config.get('test.key'), 'value') + }) + + it('should return undefined for missing key', () => { + const config = new Config() + assert.equal(config.get('missing'), undefined) + }) + }) + + describe('#getPublicConfig()', () => { + it('should return only public attributes', () => { + const config = new Config() + config._config['mod.public'] = 'yes' + config._config['mod.private'] = 'no' + config.publicAttributes = ['mod.public'] + const result = config.getPublicConfig() + assert.deepEqual(result, { 'mod.public': 'yes' }) + }) + }) + + describe('.envVarToConfigKey()', () => { + it('should convert ADAPT_AUTHORING_ prefixed vars', () => { + const result = Config.envVarToConfigKey('ADAPT_AUTHORING_CORE__dataDir') + assert.equal(result, 'adapt-authoring-core.dataDir') + }) + + it('should prefix non-adapt vars with env.', () => { + const result = Config.envVarToConfigKey('NODE_ENV') + assert.equal(result, 'env.NODE_ENV') + }) + }) + + describe('#storeEnvSettings()', () => { + it('should store env vars in config', () => { + const config = new Config() + process.env.TEST_CONFIG_VAR = 'test_value' + config.storeEnvSettings() + assert.equal(config.get('env.TEST_CONFIG_VAR'), 'test_value') + delete process.env.TEST_CONFIG_VAR + }) + + it('should parse JSON env values', () => { + const config = new Config() + process.env.TEST_JSON_VAR = '42' + config.storeEnvSettings() + assert.equal(config.get('env.TEST_JSON_VAR'), 42) + delete process.env.TEST_JSON_VAR + }) + }) + + describe('#resolveDirectory()', () => { + it('should resolve $ROOT', () => { + const config = new Config() + config.rootDir = '/app' + assert.equal(config.resolveDirectory('$ROOT/APP_DATA/data'), path.resolve('/app', 'APP_DATA/data')) + }) + + it('should resolve $DATA', () => { + const config = new Config() + config.rootDir = '/app' + config._config['adapt-authoring-core.dataDir'] = '/app/APP_DATA/data' + assert.equal(config.resolveDirectory('$DATA/uploads'), path.resolve('/app/APP_DATA/data', 'uploads')) + }) + + it('should resolve $TEMP', () => { + const config = new Config() + config.rootDir = '/app' + config._config['adapt-authoring-core.tempDir'] = '/app/APP_DATA/temp' + assert.equal(config.resolveDirectory('$TEMP/cache'), path.resolve('/app/APP_DATA/temp', 'cache')) + }) + + it('should not resolve unresolved variables', () => { + const config = new Config() + config.rootDir = '/app' + assert.equal(config.resolveDirectory('$DATA/uploads'), '$DATA/uploads') + }) + + it('should return non-variable paths unchanged', () => { + const config = new Config() + config.rootDir = '/app' + assert.equal(config.resolveDirectory('/absolute/path'), '/absolute/path') + }) + }) + + describe('#storeUserSettings()', () => { + it('should handle missing config file gracefully', async () => { + const config = new Config() + config.configFilePath = '/nonexistent/path/config.js' + await assert.doesNotReject(() => config.storeUserSettings()) + }) + }) +}) diff --git a/tests/DependencyLoader.spec.js b/tests/DependencyLoader.spec.js index 52e73e98..c41b5260 100644 --- a/tests/DependencyLoader.spec.js +++ b/tests/DependencyLoader.spec.js @@ -1,5 +1,6 @@ import { describe, it, before, after } from 'node:test' import assert from 'node:assert/strict' +import AdaptError from '../lib/AdaptError.js' import DependencyLoader from '../lib/DependencyLoader.js' import fs from 'fs-extra' import path from 'path' @@ -62,12 +63,11 @@ describe('DependencyLoader', () => { }) }) - it('should call app.logger when available and ready', () => { + it('should call app.logger when available', () => { let logged = false const mockApp = { rootDir: '/test', logger: { - _isReady: true, // Note: Mock uses private property to simulate ready state log: () => { logged = true } } } @@ -78,27 +78,11 @@ describe('DependencyLoader', () => { assert.equal(logged, true) }) - it('should fall back to console.log when logger not ready', () => { - const mockApp = { - rootDir: '/test', - logger: { - _isReady: false, // Note: Mock uses private property to check ready state - log: () => {} - } - } - const loader = new DependencyLoader(mockApp) - - assert.doesNotThrow(() => { - loader.log('info', 'test message') - }) - }) - it('should pass level and args to app.logger.log', () => { let loggedLevel, loggedName, loggedArgs const mockApp = { rootDir: '/test', logger: { - _isReady: true, log: (level, name, ...args) => { loggedLevel = level loggedName = name @@ -115,97 +99,6 @@ describe('DependencyLoader', () => { }) }) - describe('#logError()', () => { - it('should call log with error level', () => { - let loggedLevel - const mockApp = { - rootDir: '/test', - logger: { - _isReady: true, - log: (level) => { loggedLevel = level } - } - } - const loader = new DependencyLoader(mockApp) - - loader.logError('error message') - - assert.equal(loggedLevel, 'error') - }) - - it('should pass all arguments through to log', () => { - let loggedArgs - const mockApp = { - rootDir: '/test', - logger: { - _isReady: true, - log: (level, name, ...args) => { loggedArgs = args } - } - } - const loader = new DependencyLoader(mockApp) - loader.logError('msg1', 'msg2') - assert.deepEqual(loggedArgs, ['msg1', 'msg2']) - }) - }) - - describe('#getConfig()', () => { - it('should return undefined when config is not ready', () => { - const mockApp = { - rootDir: '/test' - } - const loader = new DependencyLoader(mockApp) - - const result = loader.getConfig('someKey') - - assert.equal(result, undefined) - }) - - it('should return config value when config is ready', () => { - const mockApp = { - rootDir: '/test', - config: { - _isReady: true, // Note: Mock uses private property to simulate ready state - get: (key) => { - if (key === 'adapt-authoring-core.testKey') return 'testValue' - } - } - } - const loader = new DependencyLoader(mockApp) - - const result = loader.getConfig('testKey') - - assert.equal(result, 'testValue') - }) - - it('should return undefined when config exists but is not ready', () => { - const mockApp = { - rootDir: '/test', - config: { - _isReady: false, - get: () => 'should not be called' - } - } - const loader = new DependencyLoader(mockApp) - - const result = loader.getConfig('someKey') - - assert.equal(result, undefined) - }) - - it('should always use adapt-authoring-core prefix for config keys', () => { - let requestedKey - const mockApp = { - rootDir: '/test', - config: { - _isReady: true, - get: (key) => { requestedKey = key } - } - } - const loader = new DependencyLoader(mockApp) - loader.getConfig('myKey') - assert.equal(requestedKey, 'adapt-authoring-core.myKey') - }) - }) - describe('#loadConfigs()', () => { let testRootDir @@ -339,63 +232,72 @@ describe('DependencyLoader', () => { }) describe('#loadModules()', () => { - it('should throw DependencyError when module fails without force', async () => { + it('should add non-fatal failures to failedModules', async () => { const mockApp = { rootDir: '/test' } const loader = new DependencyLoader(mockApp) loader.configs = { 'nonexistent-module': { module: true, name: 'nonexistent-module' } } - await assert.rejects( - loader.loadModules(['nonexistent-module']), - { name: 'DependencyError' } - ) - }) - - it('should not throw when module fails with force option', async () => { - const mockApp = { rootDir: '/test' } - const loader = new DependencyLoader(mockApp) - loader.configs = { 'nonexistent-module': { module: true, name: 'nonexistent-module' } } - - await loader.loadModules(['nonexistent-module'], { force: true }) + await loader.loadModules(['nonexistent-module']) assert.ok(loader.failedModules.includes('nonexistent-module')) }) - it('should log peer dependency warnings on failure with force', async () => { + it('should throw when module throws a fatal error', async () => { const mockApp = { rootDir: '/test' } const loader = new DependencyLoader(mockApp) - loader.configs = { 'nonexistent-module': { module: true, name: 'nonexistent-module' } } - loader.peerDependencies = { 'nonexistent-module': ['dependent-mod'] } + loader.configs = { 'fatal-module': { module: true, name: 'fatal-module' } } - await loader.loadModules(['nonexistent-module'], { force: true }) + // monkey-patch loadModule to throw a fatal error + const originalLoadModule = loader.loadModule.bind(loader) + loader.loadModule = async (name) => { + if (name === 'fatal-module') { + const error = new Error('Fatal') + error.isFatal = true + throw error + } + return originalLoadModule(name) + } - assert.ok(loader.failedModules.includes('nonexistent-module')) + await assert.rejects( + loader.loadModules(['fatal-module']), + (err) => { + assert.equal(err.isFatal, true) + return true + } + ) }) - it('should include module name in DependencyError message', async () => { + it('should throw when error.cause is fatal', async () => { const mockApp = { rootDir: '/test' } const loader = new DependencyLoader(mockApp) - loader.configs = { 'nonexistent-module': { module: true, name: 'nonexistent-module' } } + loader.configs = { 'fatal-module': { module: true, name: 'fatal-module' } } + + loader.loadModule = async () => { + const cause = new Error('Root cause') + cause.isFatal = true + const error = new Error('Wrapper') + error.cause = cause + throw error + } await assert.rejects( - loader.loadModules(['nonexistent-module']), + loader.loadModules(['fatal-module']), (err) => { - assert.ok(err.message.includes('nonexistent-module')) + assert.equal(err.cause.isFatal, true) return true } ) }) - it('should set cause on DependencyError', async () => { + it('should log peer dependency warnings on non-fatal failure', async () => { const mockApp = { rootDir: '/test' } const loader = new DependencyLoader(mockApp) loader.configs = { 'nonexistent-module': { module: true, name: 'nonexistent-module' } } + loader.peerDependencies = { 'nonexistent-module': ['dependent-mod'] } - try { - await loader.loadModules(['nonexistent-module']) - assert.fail('should have thrown') - } catch (err) { - assert.ok(err.cause) - } + await loader.loadModules(['nonexistent-module']) + + assert.ok(loader.failedModules.includes('nonexistent-module')) }) it('should handle empty module list', async () => { @@ -410,13 +312,18 @@ describe('DependencyLoader', () => { describe('#loadModule()', () => { it('should throw when module already exists', async () => { - const mockApp = { rootDir: '/test' } + const mockApp = { + rootDir: '/test', + errors: { + DEP_ALREADY_LOADED: new AdaptError('DEP_ALREADY_LOADED') + } + } const loader = new DependencyLoader(mockApp) loader.instances = { 'existing-module': {} } await assert.rejects( loader.loadModule('existing-module'), - { message: 'Module already exists' } + { code: 'DEP_ALREADY_LOADED' } ) }) @@ -443,19 +350,29 @@ describe('DependencyLoader', () => { describe('#waitForModule()', () => { it('should throw for missing module', async () => { - const mockApp = { rootDir: '/test' } + const mockApp = { + rootDir: '/test', + errors: { + DEP_MISSING: new AdaptError('DEP_MISSING') + } + } const loader = new DependencyLoader(mockApp) loader._configsLoaded = true loader.configs = {} await assert.rejects( loader.waitForModule('adapt-authoring-missing'), - { message: "Missing required module 'adapt-authoring-missing'" } + { code: 'DEP_MISSING' } ) }) it('should throw for failed module', async () => { - const mockApp = { rootDir: '/test' } + const mockApp = { + rootDir: '/test', + errors: { + DEP_FAILED: new AdaptError('DEP_FAILED') + } + } const loader = new DependencyLoader(mockApp) loader._configsLoaded = true loader.configs = { 'adapt-authoring-failed': { name: 'adapt-authoring-failed' } } @@ -463,7 +380,7 @@ describe('DependencyLoader', () => { await assert.rejects( loader.waitForModule('adapt-authoring-failed'), - { message: "Dependency 'adapt-authoring-failed' failed to load" } + { code: 'DEP_FAILED' } ) }) @@ -601,7 +518,6 @@ describe('DependencyLoader', () => { const mockApp = { rootDir: '/test', logger: { - _isReady: true, log: (level, name, ...args) => { if (typeof args[0] === 'object' && !Array.isArray(args[0]) && typeof args[0] !== 'string') { loggedTimes = args[0] @@ -628,7 +544,6 @@ describe('DependencyLoader', () => { const mockApp = { rootDir: '/test', logger: { - _isReady: true, log: (level, name, ...args) => { if (typeof args[0] === 'string' && args[0] === 'LOAD') { loggedMessage = args[1] diff --git a/tests/Errors.spec.js b/tests/Errors.spec.js new file mode 100644 index 00000000..1e6dae32 --- /dev/null +++ b/tests/Errors.spec.js @@ -0,0 +1,91 @@ +import { describe, it, before, after } from 'node:test' +import assert from 'node:assert/strict' +import Errors from '../lib/Errors.js' +import AdaptError from '../lib/AdaptError.js' +import fs from 'fs-extra' +import path from 'path' +import { fileURLToPath } from 'url' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) + +describe('Errors', () => { + let testDir + + before(async () => { + testDir = path.join(__dirname, 'data', 'errors-test') + const errorsDir = path.join(testDir, 'errors') + await fs.ensureDir(errorsDir) + await fs.writeJson(path.join(errorsDir, 'test.json'), { + TEST_ERROR: { + description: 'A test error', + statusCode: 400, + data: { id: 'The item ID' } + }, + FATAL_ERROR: { + description: 'A fatal error', + statusCode: 500, + isFatal: true + } + }) + }) + + after(async () => { + await fs.remove(testDir) + }) + + describe('constructor', () => { + it('should load error definitions from dependencies', () => { + const deps = { test: { name: 'test', rootDir: testDir } } + const errors = new Errors({ dependencies: deps }) + assert.ok(errors.TEST_ERROR) + assert.ok(errors.FATAL_ERROR) + }) + + it('should return AdaptError instances', () => { + const deps = { test: { name: 'test', rootDir: testDir } } + const errors = new Errors({ dependencies: deps }) + assert.ok(errors.TEST_ERROR instanceof AdaptError) + }) + + it('should return fresh instances on each access', () => { + const deps = { test: { name: 'test', rootDir: testDir } } + const errors = new Errors({ dependencies: deps }) + assert.notEqual(errors.TEST_ERROR, errors.TEST_ERROR) + }) + + it('should set statusCode from definition', () => { + const deps = { test: { name: 'test', rootDir: testDir } } + const errors = new Errors({ dependencies: deps }) + assert.equal(errors.TEST_ERROR.statusCode, 400) + }) + + it('should set isFatal from definition', () => { + const deps = { test: { name: 'test', rootDir: testDir } } + const errors = new Errors({ dependencies: deps }) + assert.equal(errors.FATAL_ERROR.isFatal, true) + assert.equal(errors.TEST_ERROR.isFatal, false) + }) + + it('should warn on duplicate error codes', () => { + const dupDir = path.join(__dirname, 'data', 'errors-dup') + const errorsDir = path.join(dupDir, 'errors') + fs.ensureDirSync(errorsDir) + fs.writeJsonSync(path.join(errorsDir, 'dup.json'), { + TEST_ERROR: { description: 'duplicate', statusCode: 500 } + }) + const deps = { + test: { name: 'test', rootDir: testDir }, + dup: { name: 'dup', rootDir: dupDir } + } + let warned = false + new Errors({ dependencies: deps, log: () => { warned = true } }) // eslint-disable-line no-new + assert.ok(warned) + fs.removeSync(dupDir) + }) + + it('should handle empty dependencies', () => { + const errors = new Errors({ dependencies: {} }) + assert.deepEqual(Object.keys(errors), []) + }) + }) +}) diff --git a/tests/Lang.spec.js b/tests/Lang.spec.js new file mode 100644 index 00000000..e057f7e7 --- /dev/null +++ b/tests/Lang.spec.js @@ -0,0 +1,100 @@ +import { describe, it, before, after } from 'node:test' +import assert from 'node:assert/strict' +import Lang from '../lib/Lang.js' +import fs from 'fs-extra' +import path from 'path' +import { fileURLToPath } from 'url' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) + +function createLang (phrases, defaultLang = 'en') { + const lang = new Lang({ dependencies: {}, defaultLang, rootDir: __dirname }) + lang.phrases = phrases + return lang +} + +describe('Lang', () => { + let testDir + + before(async () => { + testDir = path.join(__dirname, 'data', 'lang-test') + const langDir = path.join(testDir, 'lang') + await fs.ensureDir(langDir) + await fs.writeJson(path.join(langDir, 'en.json'), { + 'app.name': 'Test App', + 'app.greeting': 'Hello ${name}', // eslint-disable-line no-template-curly-in-string + 'error.TEST_ERROR': 'A test error occurred' + }) + await fs.writeJson(path.join(langDir, 'fr.json'), { + 'app.name': 'Application Test' + }) + }) + + after(async () => { + await fs.remove(testDir) + }) + + describe('#loadPhrases()', () => { + it('should load phrases from dependencies', async () => { + const lang = new Lang() + await lang.loadPhrases({ test: { rootDir: testDir } }, testDir) + assert.ok(lang.phrases.en) + assert.equal(lang.phrases.en['app.name'], 'Test App') + }) + + it('should load multiple languages', async () => { + const lang = new Lang() + await lang.loadPhrases({ test: { rootDir: testDir } }, testDir) + assert.ok(lang.phrases.fr) + assert.equal(lang.phrases.fr['app.name'], 'Application Test') + }) + }) + + describe('#supportedLanguages', () => { + it('should return loaded language keys', async () => { + const lang = new Lang() + await lang.loadPhrases({ test: { rootDir: testDir } }, testDir) + const languages = lang.supportedLanguages + assert.ok(languages.includes('en')) + assert.ok(languages.includes('fr')) + }) + }) + + describe('#translate()', () => { + it('should return translated string', () => { + const lang = createLang({ en: { hello: 'Hello' } }) + assert.equal(lang.translate('en', 'hello'), 'Hello') + }) + + it('should substitute data placeholders', () => { + // eslint-disable-next-line no-template-curly-in-string + const lang = createLang({ en: { greeting: 'Hello ${name}' } }) + assert.equal(lang.translate('en', 'greeting', { name: 'World' }), 'Hello World') + }) + + it('should fall back to default lang when lang is not a string', () => { + const lang = createLang({ en: { hello: 'Hello' } }) + assert.equal(lang.translate(undefined, 'hello'), 'Hello') + }) + + it('should return key and warn when key is missing', () => { + let warned = false + const lang = createLang({ en: {} }) + lang.log = () => { warned = true } + assert.equal(lang.translate('en', 'missing.key'), 'missing.key') + assert.ok(warned) + }) + + it('should return non-error, non-string values unchanged', () => { + const lang = createLang({}) + assert.equal(lang.translate('en', 42), 42) + }) + + it('should translate an error using its code', () => { + const lang = createLang({ en: { 'error.TEST': 'Translated error' } }) + const error = new Error('TEST') + error.code = 'TEST' + assert.equal(lang.translate('en', error), 'Translated error') + }) + }) +}) diff --git a/tests/Logger.spec.js b/tests/Logger.spec.js new file mode 100644 index 00000000..02017e75 --- /dev/null +++ b/tests/Logger.spec.js @@ -0,0 +1,84 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import Logger from '../lib/Logger.js' + +describe('Logger', () => { + describe('constructor', () => { + it('should create with defaults', () => { + const logger = new Logger() + assert.ok(logger.config) + assert.ok(logger.logHook) + assert.equal(logger.config.mute, false) + }) + + it('should mute when levels is empty', () => { + const logger = new Logger({ levels: [] }) + assert.equal(logger.config.mute, true) + }) + + it('should not mute when levels are provided', () => { + const logger = new Logger({ levels: ['error'] }) + assert.equal(logger.config.mute, false) + }) + + it('should configure levels from options', () => { + const logger = new Logger({ levels: ['error', 'warn'] }) + assert.equal(logger.config.levels.error.enable, true) + assert.equal(logger.config.levels.debug.enable, false) + }) + }) + + describe('.isLevelEnabled()', () => { + it('should return true when level is in config', () => { + assert.equal(Logger.isLevelEnabled(['error', 'warn'], 'error'), true) + }) + + it('should return false when level is not in config', () => { + assert.equal(Logger.isLevelEnabled(['error'], 'debug'), false) + }) + + it('should return false when level is negated', () => { + assert.equal(Logger.isLevelEnabled(['error', '!error'], 'error'), false) + }) + }) + + describe('.getModuleOverrides()', () => { + it('should return matching overrides', () => { + const config = ['error', 'debug.mymod', '!debug.other'] + assert.deepEqual(Logger.getModuleOverrides(config, 'debug'), ['debug.mymod', '!debug.other']) + }) + + it('should return empty array when no overrides', () => { + assert.deepEqual(Logger.getModuleOverrides(['error'], 'debug'), []) + }) + }) + + describe('.isLoggingEnabled()', () => { + it('should return true when level is enabled', () => { + const levels = { error: { enable: true, moduleOverrides: [] } } + assert.equal(Logger.isLoggingEnabled(levels, 'error', 'test'), true) + }) + + it('should return false when level is disabled', () => { + const levels = { error: { enable: false, moduleOverrides: [] } } + assert.equal(Logger.isLoggingEnabled(levels, 'error', 'test'), false) + }) + + it('should allow module-specific override', () => { + const levels = { debug: { enable: false, moduleOverrides: ['debug.mymod'] } } + assert.equal(Logger.isLoggingEnabled(levels, 'debug', 'mymod'), true) + }) + + it('should allow module-specific disable override', () => { + const levels = { debug: { enable: true, moduleOverrides: ['!debug.mymod'] } } + assert.equal(Logger.isLoggingEnabled(levels, 'debug', 'mymod'), false) + }) + }) + + describe('#log()', () => { + it('should not throw when muted via empty levels', () => { + const logger = new Logger({ levels: [] }) + assert.doesNotThrow(() => logger.log('error', 'test', 'message')) + }) + }) +}) diff --git a/tests/utils-getArgs.spec.js b/tests/utils-getArgs.spec.js index b72d6fc3..6a3ad997 100644 --- a/tests/utils-getArgs.spec.js +++ b/tests/utils-getArgs.spec.js @@ -7,16 +7,10 @@ describe('getArgs()', () => { it('should return an object with parsed arguments', () => { const args = getArgs() assert.equal(typeof args, 'object') - assert.ok(Array.isArray(args.params)) }) - it('should include the underscore array from minimist', () => { + it('should include params as an array', () => { const args = getArgs() - assert.ok(Array.isArray(args._)) - }) - - it('should derive params by slicing first two entries from _', () => { - const args = getArgs() - assert.deepEqual(args.params, args._.slice(2)) + assert.ok(Array.isArray(args.params)) }) })