From 14100e4b793045c184c488ab9874beecfd51b06c Mon Sep 17 00:00:00 2001 From: Kaveri <134367736+kaveritekawade@users.noreply.github.com> Date: Mon, 16 Dec 2024 15:55:44 +0530 Subject: [PATCH] New: Added support for the cmi5 specification (Fixes #124) (#127) * Added support for the cmi5 specification * help text updated, removed the (coming soon) * README update * Updated the code as per the code improvement suggestions * Removed logs and updated endpoint URL to add trailing slash * Update scripts/post-build.js Co-authored-by: Cahir O'Doherty <41006337+cahirodoherty-learningpool@users.noreply.github.com> --------- Co-authored-by: Cahir O'Doherty <41006337+cahirodoherty-learningpool@users.noreply.github.com> --- README.md | 2 +- bower.json | 3 +- js/CMI5.js | 514 ++++++++++++++++++++++++++++++++++++++++++ js/XAPI.js | 83 +++++-- package.json | 1 + properties.schema | 16 +- required/cmi5.xml | 24 ++ scripts/post-build.js | 26 +-- 8 files changed, 630 insertions(+), 39 deletions(-) create mode 100644 js/CMI5.js create mode 100644 required/cmi5.xml diff --git a/README.md b/README.md index 9cdbea7..b4d20ac 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Some setup is required in order to configure the xAPI extension. If using a sta |Setting|Default|Help| |--|--|--| |Is Enabled| `false` | Set to `true` to enable the extension -|Specification | `xApi` | This must be set +| Specification | xAPI or cmi5 | This must be set. Specify xAPI or cmi5 for course compliance. | |Endpoint| | URL to the LRS endpoint |User (or Key)| | This can be configured in your LRS, or omit if using ADL Launch mechanism |Password (or Secret)| | (as above) diff --git a/bower.json b/bower.json index cadfa5b..6aa658d 100644 --- a/bower.json +++ b/bower.json @@ -14,7 +14,8 @@ "description": "An extension to track learner activity via xAPI", "main": "/js/XAPIIndex.js", "scripts": { - "postversion": "cp package.json bower.json" + "postversion": "cp package.json bower.json", + "adaptpostbuild": "/scripts/post-build.js" }, "keywords": [ "adapt-plugin", diff --git a/js/CMI5.js b/js/CMI5.js new file mode 100644 index 0000000..f81925e --- /dev/null +++ b/js/CMI5.js @@ -0,0 +1,514 @@ +import Adapt from 'core/js/adapt'; +import logging from 'core/js/logging'; + +class CMI5 extends Backbone.Controller { + initialize(xapi) { + this.xapi = xapi; + } + + /** + * Configures the CMI5 launch by setting the launch parameters in the model, + * Setting the authentication token, actor, registration and activityId, fetching the CMI5 launch data. + * Fetches the agent profile data as per the cmi5 requirement. + * @returns {Promise} A promise that resolves when the configuration is complete. + */ + async configureCmi5Launch() { + // Set the launch parameters in the model + this.getLaunchParameters(); + await this.setAuthToken(); + + // check to see if configuration has been passed in URL + this.xapi.xapiWrapper = window.xapiWrapper || window.ADL.XAPIWrapper; + this.xapi.xapiWrapper.changeConfig({ + auth: `Basic ${this.xapi.get('_auth')}`, + activity_id: this.xapi.get('_activityId'), + endpoint: this.xapi.get('_endpoint'), + strictCallbacks: true, + }); + this.xapi.set({ + actor: this.xapi.get('_actor'), + registration: this.xapi.get('_registration'), + activityId: this.xapi.get('_activityId'), + }); + const cmi5LaunchData = await this.getCmi5LaunchData(); + if (this.isMasteryScoreSet(cmi5LaunchData)) { + this.respectLMSMasteryScore(cmi5LaunchData); + } + if (cmi5LaunchData?.returnURL) { + this.listenTo(Adapt, 'adapt:userExit', () => this.exitCourse(cmi5LaunchData.returnURL)); + } + + // https://github.com/AICC/CMI-5_Spec_Current/blob/quartz/cmi5_spec.md#110-xapi-agent-profile-data-model + const agentProfile = await this.getAgentProfile(); + // Not currently using the agent profile data in the course + // but it's a cmi5 requirement to fetch it before sending statements + logging.info('Agent Profile: ', agentProfile); + } + + /** + * Retrieves launch parameters from the URL and sets them as properties of the XAPI object. + */ + getLaunchParameters() { + const params = new URLSearchParams(new URL(window.location).search); + if (params.size === 0) return; + this.xapi.set({ + _endpoint: params.get('endpoint') + '/', + _fetch: params.get('fetch'), + _activityId: params.get('activityId'), + _actor: JSON.parse(decodeURIComponent(params.get('actor'))), + _registration: params.get('registration'), + }); + } + + /** Retrieves an authentication token and sets it in the model */ + async setAuthToken() { + const authToken = await this.getAuthToken(); + if (!authToken) return; + // Set the auth token in the model + this.xapi.set({ + _auth: authToken, + }); + } + + /** + * Retrieves the authentication token from the server. + * Note that it is recommended to use POST request to get the token + * See: https://github.com/AICC/CMI-5_Spec_Current/blob/quartz/cmi5_spec.md#822-definition-auth-token + * + * @returns {Promise} The authentication token. + * @throws {Error} If there is an error fetching the authentication token. + */ + async getAuthToken() { + try { + const fetchURL = this.xapi.get('_fetch'); + const requestOptions = { + method: 'POST', + }; + const response = await fetch(fetchURL, requestOptions); + if (!response.ok) { + throw new Error(response.error); + } + const authToken = await response.json(); + return authToken['auth-token']; + } catch (error) { + console.error('Error:', error); + logging.error( + 'adapt-contrib-xapi: Failed to fetch authentication token', + error + ); + this.xapi.showError(); + } + } + + /** + * Retrieves the launch data from the LRS, if it is a CMI5 launch + */ + async getCmi5LaunchData() { + const activityId = this.xapi.get('activityId'); + const actor = this.xapi.get('actor'); + const registration = this.xapi.get('registration'); + let launchData = {}; + + try { + await new Promise((resolve, reject) => { + this.xapi.xapiWrapper.getState( + activityId, + actor, + 'LMS.LaunchData', + registration, + null, + (error, xhr) => { + if (error) { + logging.warn( + `adapt-contrib-xapi: getState() failed for ${activityId} (LMS.LaunchData)` + ); + return reject(new Error(error)); + } + + if (!xhr) { + logging.warn( + `adapt-contrib-xapi: getState() failed for ${activityId} (LMS.LaunchData)` + ); + return reject( + new Error("'xhr' parameter is missing from callback") + ); + } + + if (xhr.status === 404) { + return resolve(); + } + + if (xhr.status !== 200) { + logging.warn( + `adapt-contrib-xapi: getState() failed for ${activityId} (LMS.LaunchData)` + ); + return reject( + new Error( + `Invalid status code ${xhr.status} returned from getState() call` + ) + ); + } + + // Check for empty response, otherwise the subsequent JSON.parse() will fail. + if (xhr.response === '') { + return resolve(); + } + + try { + const response = JSON.parse(xhr.response); + + if (!_.isEmpty(response)) { + launchData = response; + } + } catch (parseError) { + return reject(parseError); + } + + return resolve(); + } + ); + }); + } catch (error) { + logging.error('adapt-contrib-xapi:', error); + throw error; + } + + if (!_.isEmpty(launchData)) { + this.xapi.set({ launchData }); + } + + return launchData; + } + + /** + * Checks if the mastery score is set in the cmi5 launch data. + * @param {Object} cmi5LaunchData - The cmi5 launch data object. + * @returns {boolean} - Returns true if the mastery score is set, otherwise false. + */ + isMasteryScoreSet(cmi5LaunchData) { + return ( + cmi5LaunchData && + cmi5LaunchData.masteryScore !== undefined && + cmi5LaunchData.masteryScore !== null && + cmi5LaunchData.masteryScore !== '' + ); + } + + /** + * Updates the assessment configuration to respect the LMS mastery score. + * If there is only one assessment in the course, it also updates the assessment + * configuration for that assessment. + * @param {Object} cmi5LaunchData - The cmi5 launch data containing the mastery score. + */ + respectLMSMasteryScore(cmi5LaunchData) { + const assessmentConfig = Adapt.course.get('_assessment'); + if (!assessmentConfig) return; + let assessmentCount = 0; + let assessmentToModify = null; + + if (!assessmentConfig._isPercentageBased) return; + const masterScorePercentage = cmi5LaunchData.masteryScore * 100; + this.updateAssessmentConfig(assessmentConfig, masterScorePercentage); + Adapt.course.set('_assessment', assessmentConfig); + + Adapt.articles?.models?.forEach((article) => { + if (article.get('_assessment')?._isEnabled) { + assessmentCount++; + assessmentToModify = article; + } + }); + + // If there is only one assessment in the course, update the assessment config + if (assessmentCount === 1 && assessmentToModify) { + const assessment = assessmentToModify.get('_assessment'); + if (assessment) { + this.updateAssessmentConfig(assessment, masterScorePercentage); + } + } + logging.debug('New assessment config: ', Adapt.course.get('_assessment')); + } + + /** + * Updates the assessment configuration with the given master score percentage. + * @param {Object} assessment - The assessment object to update. + * @param {number} masterScorePercentage - The master score percentage to set for the assessment. + */ + updateAssessmentConfig(assessment, masterScorePercentage) { + assessment._scoreToPass = masterScorePercentage; + assessment._correctToPass = masterScorePercentage; + assessment._passingScore = masterScorePercentage; + } + + /** + * Retrieves the agent profile from the LRS + */ + async getAgentProfile() { + const actor = this.xapi.get('actor'); + let agentProfile = {}; + + try { + await new Promise((resolve, reject) => { + this.xapi.xapiWrapper.getAgentProfile( + actor, + 'cmi5LearnerPreferences', + null, + (error, xhr) => { + if (error) { + logging.warn( + `adapt-contrib-xapi: getAgentProfile() failed for cmi5LearnerPreferences` + ); + return reject(new Error(error)); + } + + if (!xhr) { + logging.warn( + `adapt-contrib-xapi: getAgentProfile() failed for cmi5LearnerPreferences` + ); + return reject( + new Error("'xhr' parameter is missing from callback") + ); + } + + if (xhr.status === 404) { + return resolve(); + } + + if (xhr.status !== 200) { + logging.warn( + `adapt-contrib-xapi: getAgentProfile() failed for cmi5LearnerPreferences` + ); + return reject( + new Error( + `Invalid status code ${xhr.status} returned from getAgentProfile() call` + ) + ); + } + + // Check for empty response, otherwise the subsequent JSON.parse() will fail. + if (xhr.response === '') { + return resolve(); + } + + try { + const response = JSON.parse(xhr.response); + agentProfile = response; + } catch (parseError) { + return reject(parseError); + } + + return resolve(); + } + ); + }); + } catch (error) { + logging.error('adapt-contrib-xapi:', error); + throw error; + } + + if (!_.isEmpty(agentProfile)) { + this.xapi.set({ agentProfile }); + } + + return agentProfile; + } + + /** + * Retrieves a defined xAPI statement based on the provided verb and result. + * + * @param {string} verb - The xAPI verb. + * @param {object} [result] - The xAPI result object. + * @returns {object} - The xAPI statement. + */ + getCmi5DefinedStatement(verb, result) { + if (typeof result === 'undefined') { + result = {}; + } + + const object = this.xapi.getCourseActivity(); + const context = this.getCmi5Context(verb); + + // Append the duration. + switch (verb) { + case window.ADL.verbs.initialized: + // Nothing extra needed + break; + case window.ADL.verbs.failed: + case window.ADL.verbs.passed: { + if (result.completion) { + delete result.completion; + } + result.duration = this.xapi.convertMillisecondsToISO8601Duration( + this.xapi.getAttemptDuration() + ); + break; + } + case window.ADL.verbs.completed: { + result.duration = this.xapi.convertMillisecondsToISO8601Duration( + this.xapi.getAttemptDuration() + ); + break; + } + case window.ADL.verbs.terminated: { + result.duration = this.xapi.convertMillisecondsToISO8601Duration( + this.xapi.getSessionDuration() + ); + break; + } + default: { + logging.warn(`Verb ${verb} not a valid cmi5 defined verb`); + return; + } + } + + return this.xapi.getStatement( + this.xapi.getVerb(verb), + object, + result, + context + ); + } + + /** + * Retrieves the cmi5 context based on the provided verb. + * @param {string} verb - The verb used to determine the cmi5 context. + * @returns {Object} - The cmi5 context object. + */ + getCmi5Context(verb) { + const context = { + contextActivities: { + category: [ + { + id: 'https://w3id.org/xapi/cmi5/context/categories/cmi5', + objectType: 'Activity', + }, + ], + }, + }; + + // Append the category and masteryScore. + switch (verb) { + case window.ADL.verbs.failed: + case window.ADL.verbs.passed: + this.addMoveOnCategory(context); + this.addMasteryScoreExtension(context); + break; + + case window.ADL.verbs.completed: + this.addMoveOnCategory(context); + break; + } + return context; + } + + /** + * Adds a moveon category to the context activities. + * The new category has an id of 'https://w3id.org/xapi/cmi5/context/categories/moveon' and an objectType of 'Activity'. + * @param {Object} context - The context object. + */ + addMoveOnCategory(context) { + const { moveOn } = this.xapi.get('launchData'); + if (!moveOn) return; + + context.contextActivities.category.push({ + id: 'https://w3id.org/xapi/cmi5/context/categories/moveon', + objectType: 'Activity', + }); + } + + /** + * Adds the mastery score extension to the context object. + * If the mastery score is set in the launch data, it will be added to the context.extensions property. + * @param {object} context - The context object to which the mastery score extension will be added. + */ + addMasteryScoreExtension(context) { + if (!this.isMasteryScoreSet(this.xapi.get('launchData'))) return; + const { masteryScore } = this.xapi.get('launchData'); + context.extensions + ? context.extensions.push({ + 'https://w3id.org/xapi/cmi5/context/extensions/masteryscore': + masteryScore, + }) + : (context.extensions = { + 'https://w3id.org/xapi/cmi5/context/extensions/masteryscore': + masteryScore, + }); + } + + /** + * Merges the default contextActivities and extensions with the existing statement.context. + * + * @param {object} statement - The statement object to merge the default context with. + */ + mergeDefaultContext(statement) { + // Merge the default contextActivities and extensions with the existing statement.context + const defaultContextTemplate = this.xapi.get('launchData')?.contextTemplate; + if (!defaultContextTemplate) return; + + const { contextActivities, extensions } = defaultContextTemplate; + const { context } = statement; + + statement.context = { + ...(context || {}), + contextActivities: { + ...(context?.contextActivities || {}), + parent: [ + ...(context?.contextActivities?.parent || []), + ...(contextActivities?.parent || []).filter(Boolean), + ], + grouping: [ + ...(context?.contextActivities?.grouping || []), + ...(contextActivities?.grouping || []).filter(Boolean), + ], + }, + extensions: { + ...(context?.extensions || {}), + ...(extensions || {}), + }, + }; + + statement.timestamp = new Date().toISOString(); + } + + /** + * Handles the tracking completion of CMI5. + * + * @param {string} completionVerb - The completion verb. + * @param {any} result - The result of the completion. + * @returns {Promise} - A promise that resolves when the tracking completion is handled. + */ + async handleCmi5TrackingCompletion(completionVerb, result) { + // Handle the cmi5 defined statements for passed/failed/completed. + await this.xapi.sendStatement( + this.getCmi5DefinedStatement(completionVerb, result) + ); + + // Handle the cmi5 defined statements with the moveOn property. + const { completed, failed, passed } = window.ADL.verbs; + const launchDataMoveOn = this.xapi.get('launchData')?.moveOn; + const completion = true; + const conditions = { + CompletedOrPassed: () => completionVerb === failed, + Completed: () => completionVerb !== completed, + CompletedAndPassed: () => completionVerb === passed, + }; + + if (conditions[launchDataMoveOn]?.()) { + await this.xapi.sendStatement( + this.getCmi5DefinedStatement(completed, { completion }) + ); + } + } + + /** + * Exits the course and redirects to the specified URL. + * @param {string} returnURL - The URL to redirect to after exiting the course. + */ + exitCourse(returnURL) { + if (!returnURL) { + return; + } + window.location.href = returnURL; + } +} + +export default CMI5; diff --git a/js/XAPI.js b/js/XAPI.js index 1dd4bfa..7d37d54 100644 --- a/js/XAPI.js +++ b/js/XAPI.js @@ -6,6 +6,7 @@ import notify from 'core/js/notify'; import offlineStorage from 'core/js/offlineStorage'; import wait from 'core/js/wait'; import XAPIWrapper from 'libraries/xapiwrapper.min'; +import CMI5 from './CMI5'; class XAPI extends Backbone.Model { @@ -21,7 +22,8 @@ class XAPI extends Backbone.Model { shouldUseRegistration: false, componentBlacklist: 'blank,graphic', isInitialised: false, - state: {} + state: {}, + shouldListenToTracking: true, }; this.xapiWrapper = XAPIWrapper; @@ -68,6 +70,9 @@ class XAPI extends Backbone.Model { /** Implementation starts here */ async initialize() { if (!this.getConfig('_isEnabled')) return this; + if (this.getConfig('_specification') === 'cmi5') { + this.cmi5 = new CMI5(this); + } wait.begin(); @@ -111,11 +116,18 @@ class XAPI extends Backbone.Model { this.courseName = Adapt.course.get('displayTitle') || Adapt.course.get('title'); this.courseDescription = Adapt.course.get('description') || ''; - // Send the 'launched' and 'initialized' statements. - const statements = [ - this.getCourseStatement(window.ADL.verbs.launched), - this.getCourseStatement(window.ADL.verbs.initialized) - ]; + const statements = []; + + if (this.cmi5) { + // Only send 'initialized' statement for cmi5, as the LMS will send 'launched' + statements.push( + this.cmi5.getCmi5DefinedStatement(window.ADL.verbs.initialized) + ); + } else { + // Send the 'launched' and 'initialized' statements. + statements.push(this.getCourseStatement(window.ADL.verbs.launched)); + statements.push(this.getCourseStatement(window.ADL.verbs.initialized)); + } try { await this.sendStatements(statements); @@ -180,12 +192,18 @@ class XAPI extends Backbone.Model { * Intializes the ADL xapiWrapper code. */ async initializeWrapper() { - // If no endpoint has been configured, assume the ADL Launch method. + // If the specification is cmi5, use the cmi5 launch method. + if (this.cmi5) { + await this.cmi5.configureCmi5Launch(); + return; + } + + // If no endpoint has been configured, assume the ADL/URL Launch method. if (!this.getConfig('_endpoint')) { // check to see if configuration has been passed in URL this.xapiWrapper = window.xapiWrapper || window.ADL.XAPIWrapper; if (this.checkWrapperConfig()) { - // URL had all necessary configuration so we continue using it. + // URL had all necessary configuration so we can assume the URL launch method. // Set the LRS specific properties. this.set({ registration: this.getLRSAttribute('registration'), @@ -218,7 +236,7 @@ class XAPI extends Backbone.Model { }, true, true); }); } - // The endpoint has been defined in the config, so use the static values. + // The endpoint has been defined in the config, so assume this is using the wrapper launch method. // Initialise the xAPI wrapper. this.xapiWrapper = window.xapiWrapper || window.ADL.XAPIWrapper; @@ -298,7 +316,13 @@ class XAPI extends Backbone.Model { } // Always send the 'terminated' verb. - statements.push(this.getCourseStatement(window.ADL.verbs.terminated)); + if (this.cmi5) { + statements.push( + this.cmi5.getCmi5DefinedStatement(window.ADL.verbs.terminated) + ); + } else { + statements.push(this.getCourseStatement(window.ADL.verbs.terminated)); + } // Note: it is not possible to intercept these synchronous statements. await this.sendStatementsSync(statements); @@ -412,6 +436,13 @@ class XAPI extends Backbone.Model { return; } + // If cmi5 and + // the launch mode is not normal (but either Review or Browse) + // THEN do not listen to cmi5 defined statements + if (this.cmi5 && this.get('launchData')?.launchMode !== 'Normal') { + return; + } + // Allow surfacing the learner's info in _globals. this.getLearnerInfo(); @@ -424,8 +455,10 @@ class XAPI extends Backbone.Model { // Use the config to specify the core events. this.coreEvents = Object.assign(this.coreEvents, this.getConfig('_coreEvents')); - // Always listen out for course completion. - this.listenTo(Adapt, 'tracking:complete', this.onTrackingComplete); + // Listen out for course completion. + if (this.get('shouldListenToTracking')) { + this.listenTo(Adapt, 'tracking:complete', this.onTrackingComplete); + } // Conditionally listen to the events. // Visits to the menu. @@ -546,6 +579,10 @@ class XAPI extends Backbone.Model { getActivityType(model) { let type = ''; + if (!model || typeof model.get !== 'function') { + throw new Error('Invalid model object'); + } + switch (model.get('_type')) { case 'component': { type = model.get('_isQuestionType') ? window.ADL.activityTypes.interaction : window.ADL.activityTypes.media; @@ -568,6 +605,9 @@ class XAPI extends Backbone.Model { type = window.ADL.activityTypes.lesson; break; } + default: { + console.warn('Unexpected model type: ', model.get('_type')); + } } return type; @@ -967,7 +1007,13 @@ class XAPI extends Backbone.Model { _.defer(async () => { // Send the completion status. - await this.sendStatement(this.getCourseStatement(completionVerb, result)); + if (this.cmi5) { + await this.cmi5.handleCmi5TrackingCompletion(completionVerb, result); + } else { + await this.sendStatement( + this.getCourseStatement(completionVerb, result) + ); + } }); } @@ -981,8 +1027,8 @@ class XAPI extends Backbone.Model { const Adapt = require('core/js/adapt'); - if (state.components) { - state.components.forEach(stateObject => { + if (state.components && state.components.length > 0) { + state.components.forEach((stateObject) => { const restoreModel = Adapt.findById(stateObject._id); if (restoreModel) { @@ -993,8 +1039,8 @@ class XAPI extends Backbone.Model { }); } - if (state.blocks) { - state.blocks.forEach(stateObject => { + if (state.blocks && state.blocks.length > 0) { + state.blocks.forEach((stateObject) => { const restoreModel = Adapt.findById(stateObject._id); if (restoreModel) { @@ -1407,6 +1453,9 @@ class XAPI extends Backbone.Model { } Adapt.trigger('xapi:lrs:sendStatement:success', body); + }; + if (this.cmi5) { + this.cmi5.mergeDefaultContext(statement); } this.xapiWrapper.sendStatement(statement, sendStatementCallback, attachments); diff --git a/package.json b/package.json index cadfa5b..b90988e 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "keywords": [ "adapt-plugin", "adapt-extension", + "cmi5", "tincan", "tin can", "xapi", diff --git a/properties.schema b/properties.schema index 1563d39..7817e24 100755 --- a/properties.schema +++ b/properties.schema @@ -62,19 +62,15 @@ }, "_specification": { "type": "string", - "enum": [ - "xAPI" - ], + "enum": ["xAPI", "cmi5"], "default": "xAPI", "title": "Specification", "inputType": { "type": "Select", - "options": [ - "xAPI" - ] + "options": ["xAPI", "cmi5"] }, "validators": [], - "help": "Indicates whether the plugin should use standard xAPI or cmi5 profile (coming soon)." + "help": "Indicates whether the plugin should use standard xAPI or cmi5 profile." }, "_activityID": { "type": "string", @@ -85,6 +81,12 @@ "validators": [], "help": "Unique identifier for this xAPI activity, i.e. usually the URL to the course." }, + "_auID": { + "type": "string", + "title": "assignable unit (AU) ID", + "default": "1", + "help": "Unique identifier for this assignable unit." + }, "_endpoint": { "type": "string", "required": true, diff --git a/required/cmi5.xml b/required/cmi5.xml new file mode 100644 index 0000000..53178ff --- /dev/null +++ b/required/cmi5.xml @@ -0,0 +1,24 @@ + + + + + <langstring lang="en-US"><![CDATA[@@course.title]]></langstring> + + + + + + + + + + <langstring lang="en-US"><![CDATA[@@course.title]]></langstring> + + + + + + + index.html + + \ No newline at end of file diff --git a/scripts/post-build.js b/scripts/post-build.js index 682938d..2f2f43f 100644 --- a/scripts/post-build.js +++ b/scripts/post-build.js @@ -32,20 +32,20 @@ module.exports = function(fs, path, log, options, done) { // cmi5 is a profile of the xAPI specification and some systems, e.g. SCORM Cloud // do not work well with both manifest types, so remove the one which is not being used. - if (!configJson._xapi.hasOwnProperty('_specification') || configJson._xapi._specification === 'xAPI') { - // TODO - This will change once cmi5 is supported. - // Remove the cmi5.xml file (default behaviour). - // fs.unlink(path.join(options.outputdir, 'cmi5.xml'), callback); - callback(); - } else if (configJson._xapi._specification === 'cmi5') { - // Remove the tincan.xml file. - fs.unlink(path.join(options.outputdir, 'tincan.xml'), callback); + + if (configJson?._xapi?._specification !== 'cmi5') { + // Remove the cmi5.xml file (default behaviour). + fs.unlink(path.join(options.outputdir, 'cmi5.xml'), callback); + } else { + // Remove the tincan.xml file. + fs.unlink(path.join(options.outputdir, 'tincan.xml'), callback); + } + }, + ], + function (err, info) { + if (err) { + return done(err); } - } - ], function(err, info) { - if (err) { - return done(err); - } done(); });