diff --git a/README.md b/README.md index 8e3eb01b..900a473a 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ This extension provides course tracking functionality (hence the name [spoor](ht **Spoor** makes use of the excellent [pipwerks SCORM API Wrapper](https://github.com/pipwerks/scorm-api-wrapper/). [Visit the **Spoor** wiki](https://github.com/adaptlearning/adapt-contrib-spoor/wiki) for more information about its functionality and for explanations of key properties. - + ## Installation As one of Adapt's *[core extensions](https://github.com/adaptlearning/adapt_framework/wiki/Core-Plug-ins-in-the-Adapt-Learning-Framework#extensions),* **Spoor** is included with the [installation of the Adapt framework](https://github.com/adaptlearning/adapt_framework/wiki/Manual-installation-of-the-Adapt-framework#installation) and the [installation of the Adapt authoring tool](https://github.com/adaptlearning/adapt_authoring/wiki/Installing-Adapt-Origin). @@ -131,6 +131,17 @@ Determines the 'exit state' (`cmi.core.exit` in SCORM 1.2, `cmi.exit` in SCORM 2 ##### \_setCompletedWhenFailed (boolean): Determines whether the `cmi.completion_status` is set to "completed" if the assessment is "failed". Only valid for SCORM 2004, where the logic for completion and success is separate. The default is `true`. +##### \_connectionTest (object): +The settings used to configure the connection test when committing data to the LMS. The LMS API usually returns true for each data transmission regardless of the ability to persist the data. Contains the following attributes: + + * **\_isEnabled** (boolean): Determines whether the connection should be tested. The default is `true`. + + * **\_testOnSetValue** (boolean): Determines whether the connection should be tested for each call to set data on the LMS. The default is `true`. + + * **_silentRetryLimit** (number): The limit for silent retry attempts to establish a connection before raising an error. The default is `2`. + + * **_silentRetryDelay** (number): The interval in milliseconds between silent connection retries. The default is `1000`. + #### \_showCookieLmsResetButton (boolean): Determines whether a reset button will be available to relaunch the course and optionally clear tracking data (scorm_test_harness.html only). The default is `false`. diff --git a/example.json b/example.json index cf0331ee..3d177cbc 100644 --- a/example.json +++ b/example.json @@ -27,7 +27,13 @@ "_commitOnVisibilityChangeHidden": true, "_exitStateIfIncomplete": "auto", "_exitStateIfComplete": "auto", - "_setCompletedWhenFailed": true + "_setCompletedWhenFailed": true, + "_connectionTest": { + "_isEnabled": true, + "_testOnSetValue": true, + "_silentRetryLimit": 0, + "_silentRetryDelay": 2000 + } }, "_showCookieLmsResetButton": false, "_shouldPersistCookieLMSData": true diff --git a/js/adapt-stateful-session.js b/js/adapt-stateful-session.js index 500fdf09..9853799b 100644 --- a/js/adapt-stateful-session.js +++ b/js/adapt-stateful-session.js @@ -51,38 +51,7 @@ export default class StatefulSession extends Backbone.Controller { this.scorm.initialize(); return; } - if (settings._showDebugWindow) { - this.scorm.showDebugWindow(); - } - this.scorm.setVersion(settings._scormVersion || '1.2'); - if (_.isBoolean(settings._suppressErrors)) { - this.scorm.suppressErrors = settings._suppressErrors; - } - if (_.isBoolean(settings._commitOnStatusChange)) { - this.scorm.commitOnStatusChange = settings._commitOnStatusChange; - } - if (_.isBoolean(settings._commitOnAnyChange)) { - this.scorm.commitOnAnyChange = settings._commitOnAnyChange; - } - if (_.isFinite(settings._timedCommitFrequency)) { - this.scorm.timedCommitFrequency = settings._timedCommitFrequency; - } - if (_.isFinite(settings._maxCommitRetries)) { - this.scorm.maxCommitRetries = settings._maxCommitRetries; - } - if (_.isFinite(settings._commitRetryDelay)) { - this.scorm.commitRetryDelay = settings._commitRetryDelay; - } - if ('_exitStateIfIncomplete' in settings) { - this.scorm.exitStateIfIncomplete = settings._exitStateIfIncomplete; - } - if ('_exitStateIfComplete' in settings) { - this.scorm.exitStateIfComplete = settings._exitStateIfComplete; - } - if (_.isBoolean(settings._setCompletedWhenFailed)) { - this.scorm.setCompletedWhenFailed = settings._setCompletedWhenFailed; - } - this.scorm.initialize(); + this.scorm.initialize(settings); } restoreSession() { diff --git a/js/scorm/Connection.js b/js/scorm/Connection.js new file mode 100644 index 00000000..0b984654 --- /dev/null +++ b/js/scorm/Connection.js @@ -0,0 +1,79 @@ +import Adapt from 'core/js/adapt'; + +export default class Connection { + + constructor({ + _isEnabled = true, + _silentRetryLimit = 2, + _silentRetryDelay = 1000, + _testOnSetValue = true + } = {}, ScormWrapper) { + this.test = this.test.bind(this); + this._isEnabled = _isEnabled; + this._isInProgress = false; + this._isSilentDisconnection = false; + this._isDisconnected = false; + this._silentRetryLimit = _silentRetryLimit; + this._silentRetryDelay = _silentRetryDelay; + this._silentRetryTimeout = null; + this._silentRetryCount = 0; + this._testOnSetValue = _testOnSetValue; + this._scorm = ScormWrapper; + } + + async test() { + if (!this._isEnabled || this._isInProgress) return; + this._isInProgress = true; + try { + const response = await fetch(`connection.txt?nocache=${Date.now()}`); + if (response?.ok) return this.onConnectionSuccess(); + } catch (err) {} + this.onConnectionError(); + } + + async testOnSetValue() { + if (!this._isEnabled || !this._testOnSetValue) return; + return this.test(); + } + + reset() { + this._silentRetryCount = 0; + this._isSilentDisconnection = false; + if (this._silentRetryTimeout === null) return; + window.clearTimeout(this._silentRetryTimeout); + this._silentRetryTimeout = null; + } + + stop() { + this.reset(); + this._isEnabled = false; + } + + /** + * @todo Remove need for commit? + */ + onConnectionSuccess() { + if (this._isDisconnected) { + this._scorm.commit(); + if (!this._isSilentDisconnection) Adapt.trigger('tracking:connectionSuccess'); + } + this._isInProgress = false; + this._isDisconnected = false; + this.reset(); + } + + onConnectionError() { + if (!this._isEnabled) return; + this._isInProgress = false; + this._isDisconnected = true; + if (this._silentRetryCount < this._silentRetryLimit) { + this._isSilentDisconnection = true; + this._silentRetryCount++; + this._silentRetryTimeout = window.setTimeout(this.test, this._silentRetryDelay); + return; + } + this.reset(); + this._scorm.handleConnectionError(this.test); + } + +} diff --git a/js/scorm/wrapper.js b/js/scorm/wrapper.js index d948cb3e..3a64c9a5 100644 --- a/js/scorm/wrapper.js +++ b/js/scorm/wrapper.js @@ -1,8 +1,11 @@ import Adapt from 'core/js/adapt'; -import notify from 'core/js/notify'; +import Data from 'core/js/data'; +import Wait from 'core/js/wait'; +import Notify from 'core/js/notify'; import pipwerks from 'libraries/SCORM_API_wrapper'; import Logger from './logger'; import ScormError from './error'; +import Connection from './Connection'; const { CLIENT_COULD_NOT_CONNECT, @@ -71,10 +74,8 @@ class ScormWrapper { this.logOutputWin = null; this.startTime = null; this.endTime = null; - this.lmsConnected = false; this.finishCalled = false; - this.logger = Logger.getInstance(); this.scorm = pipwerks.SCORM; /** @@ -83,10 +84,11 @@ class ScormWrapper { this.scorm.handleExitMode = false; this.suppressErrors = false; - this.debouncedCommit = _.debounce(this.commit.bind(this), 100); - if (window.__debug) { - this.showDebugWindow(); - } + this.commit = this.commit.bind(this); + this.doRetryCommit = this.doRetryCommit.bind(this); + this.debouncedCommit = _.debounce(this.commit, 100); + if (window.__debug) this.showDebugWindow(); + this._connection = null; if (!(window.API?.__offlineAPIWrapper && window?.API_1484_11?.__offlineAPIWrapper)) return; this.logger.error('Offline SCORM API is being used. No data will be reported to the LMS!'); @@ -111,18 +113,55 @@ class ScormWrapper { this.scorm.version = value; } - initialize() { + initialize(settings) { + if (settings) { + if (settings._showDebugWindow) { + this.showDebugWindow(); + } + this.setVersion(settings._scormVersion || '1.2'); + if (_.isBoolean(settings._suppressErrors)) { + this.suppressErrors = settings._suppressErrors; + } + if (_.isBoolean(settings._commitOnStatusChange)) { + this.commitOnStatusChange = settings._commitOnStatusChange; + } + if (_.isBoolean(settings._commitOnAnyChange)) { + this.commitOnAnyChange = settings._commitOnAnyChange; + } + if (_.isFinite(settings._timedCommitFrequency)) { + this.timedCommitFrequency = settings._timedCommitFrequency; + } + if (_.isFinite(settings._maxCommitRetries)) { + this.maxCommitRetries = settings._maxCommitRetries; + } + if (_.isFinite(settings._commitRetryDelay)) { + this.commitRetryDelay = settings._commitRetryDelay; + } + if ('_exitStateIfIncomplete' in settings) { + this.exitStateIfIncomplete = settings._exitStateIfIncomplete; + } + if ('_exitStateIfComplete' in settings) { + this.exitStateIfComplete = settings._exitStateIfComplete; + } + if (_.isBoolean(settings._setCompletedWhenFailed)) { + this.setCompletedWhenFailed = settings._setCompletedWhenFailed; + } + } + this.logger.debug('ScormWrapper::initialize'); this.lmsConnected = this.scorm.init(); if (!this.lmsConnected) { - this.handleError(new ScormError(CLIENT_COULD_NOT_CONNECT)); + this.handleInitializeError(); return this.lmsConnected; } + if (settings?._connectionTest?._isEnabled !== false) { + this._connection = new Connection(settings._connectionTest, this); + } + this.startTime = new Date(); this.initTimedCommit(); - return this.lmsConnected; } @@ -184,7 +223,7 @@ class ScormWrapper { case 'unknown': // the SCORM 2004 version of not attempted return status; default: - this.handleError(new ScormError(SERVER_STATUS_UNSUPPORTED, { status })); + this.handleDataError(new ScormError(SERVER_STATUS_UNSUPPORTED, { status })); return null; } } @@ -204,7 +243,7 @@ class ScormWrapper { this.setFailed(); break; default: - this.handleError(new ScormError(CLIENT_STATUS_UNSUPPORTED, { status })); + this.handleDataError(new ScormError(CLIENT_STATUS_UNSUPPORTED, { status })); } } @@ -266,7 +305,7 @@ class ScormWrapper { this.logger.debug('ScormWrapper::commit'); if (!this.lmsConnected) { - this.handleError(new ScormError(ScormError.CLIENT_NOT_CONNECTED)); + this.handleConnectionError(); return; } @@ -278,6 +317,8 @@ class ScormWrapper { if (this.scorm.save()) { this.commitRetries = 0; this.lastCommitSuccessTime = new Date(); + // if success, test the connection as the API usually returns true regardless of the ability to persist the data + if (this._connection) this._connection.test(); Adapt.trigger('spoor:commit', this); return; } @@ -289,7 +330,7 @@ class ScormWrapper { } const errorCode = this.scorm.debug.getCode(); - this.handleError(new ScormError(CLIENT_COULD_NOT_COMMIT, { + this.handleDataError(new ScormError(CLIENT_COULD_NOT_COMMIT, { errorCode, errorInfo: this.scorm.debug.getInfo(errorCode), diagnosticInfo: this.scorm.debug.getDiagnosticInfo(errorCode) @@ -300,7 +341,7 @@ class ScormWrapper { this.logger.debug('ScormWrapper::finish'); if (!this.lmsConnected || this.finishCalled) { - this.handleError(new ScormError(CLIENT_NOT_CONNECTED)); + this.handleConnectionError(); return; } @@ -329,12 +370,17 @@ class ScormWrapper { this.scorm.set('cmi.core.exit', this.getExitState()); } + if (this._connection) { + this._connection.stop(); + this._connection = null; + } + // api no longer available from this point this.lmsConnected = false; - if (this.scorm.quit()) return; const errorCode = this.scorm.debug.getCode(); - this.handleError(new ScormError(CLIENT_COULD_NOT_FINISH, { + + this.handleFinishError(new ScormError(CLIENT_COULD_NOT_FINISH, { errorCode, errorInfo: this.scorm.debug.getInfo(errorCode), diagnosticInfo: this.scorm.debug.getDiagnosticInfo(errorCode) @@ -380,7 +426,7 @@ class ScormWrapper { } if (!this.lmsConnected) { - this.handleError(new ScormError(CLIENT_NOT_CONNECTED)); + this.handleConnectionError(); return; } @@ -396,7 +442,7 @@ class ScormWrapper { this.logger.warn('ScormWrapper::getValue: data model element not initialized'); break; default: - this.handleError(new ScormError(CLIENT_COULD_NOT_GET_PROPERTY, { + this.handleDataError(new ScormError(CLIENT_COULD_NOT_GET_PROPERTY, { property, errorCode, errorInfo: this.scorm.debug.getInfo(errorCode), @@ -416,17 +462,20 @@ class ScormWrapper { } if (!this.lmsConnected) { - this.handleError(new ScormError(CLIENT_NOT_CONNECTED)); + this.handleConnectionError(); return; } const success = this.scorm.set(property, value); - if (!success) { - // Some LMSes have an annoying tendency to return false from a set call even when it actually worked fine. + if (success) { + // if success, test the connection as the API usually returns true regardless of the ability to persist the data + this._connection?.testOnSetValue(); + } else { + // Some LMSs have an annoying tendency to return false from a set call even when it actually worked fine. // So we should only throw an error if there was a valid error code... const errorCode = this.scorm.debug.getCode(); if (errorCode !== 0) { - this.handleError(new ScormError(CLIENT_COULD_NOT_SET_PROPERTY, { + this.handleDataError(new ScormError(CLIENT_COULD_NOT_SET_PROPERTY, { property, value, errorCode, @@ -439,7 +488,6 @@ class ScormWrapper { } if (this.commitOnAnyChange) this.debouncedCommit(); - return success; } @@ -457,12 +505,11 @@ class ScormWrapper { } if (!this.lmsConnected) { - this.handleError(new ScormError(CLIENT_NOT_CONNECTED)); + this.handleConnectionError(); return false; } this.scorm.get(property); - return (this.scorm.debug.getCode() !== 401); // 401 is the 'not implemented' error code } @@ -471,7 +518,7 @@ class ScormWrapper { if (!this.commitOnAnyChange && this.timedCommitFrequency > 0) { const delay = this.timedCommitFrequency * (60 * 1000); - this.timedCommitIntervalID = window.setInterval(this.commit.bind(this), delay); + this.timedCommitIntervalID = window.setInterval(this.commit, delay); } } @@ -480,7 +527,7 @@ class ScormWrapper { this.commitRetryPending = true;// stop anything else from calling commit until this is done - this.retryCommitTimeoutID = window.setTimeout(this.doRetryCommit.bind(this), this.commitRetryDelay); + this.retryCommitTimeoutID = window.setTimeout(this.doRetryCommit, this.commitRetryDelay); } doRetryCommit() { @@ -491,13 +538,29 @@ class ScormWrapper { this.commit(); } - handleError(error) { + async handleInitializeError() { + if (!Data.isReady) await Data.whenReady(); + Adapt.trigger('tracking:initializeError'); + // defer error to allow other plugins which may be handling errors to execute + _.defer(() => this.handleError(new ScormError(CLIENT_COULD_NOT_CONNECT))); + } - if (!Adapt.get('_isStarted')) { - Adapt.once('contentObjectView:ready', this.handleError.bind(this, error)); - return; - } + handleConnectionError(callback = null) { + Adapt.trigger('tracking:connectionError', callback); + this.handleError(new ScormError(CLIENT_NOT_CONNECTED)); + } + handleDataError(error) { + Adapt.trigger('tracking:dataError'); + this.handleError(error); + } + + handleFinishError(error) { + Adapt.trigger('tracking:terminationError'); + this.handleError(error); + } + + handleError(error) { if ('value' in error.data) { // because some browsers (e.g. Firefox) don't like displaying very long strings in the window.confirm dialog if (error.data.value.length && error.data.value.length > 80) error.data.value = error.data.value.slice(0, 80) + '...'; @@ -511,12 +574,18 @@ class ScormWrapper { switch (error.name) { case CLIENT_COULD_NOT_CONNECT: - notify.popup({ - _isCancellable: false, - title: messages.title, - body: message - }); - return; + // don't show if error notification already handled by other plugins + if (!Notify.isOpen) { + // prevent course load execution + Wait.begin(); + $('.js-loading').hide(); + + Notify.popup({ + _isCancellable: false, + title: messages.title, + body: message + }); + } } this.logger.error(message); diff --git a/properties.schema b/properties.schema index 42e7e380..53f321fa 100644 --- a/properties.schema +++ b/properties.schema @@ -258,6 +258,47 @@ "inputType": "Checkbox", "validators": [], "help": "If enabled, `cmi.completion_status` will be set to \"completed\" if the assessment is \"failed\". Only valid for SCORM 2004, where the logic for completion and success is separate." + }, + "_connectionTest": { + "type": "object", + "required": true, + "title": "Connection Test", + "properties": { + "_isEnabled": { + "type": "boolean", + "default": true, + "title": "Is Enabled", + "inputType": "Checkbox", + "validators": [], + "help": "Determines whether the connection should be tested." + }, + "_testOnSetValue": { + "type": "boolean", + "default": true, + "title": "Test on set value", + "inputType": "Checkbox", + "validators": [], + "help": "Determines whether the connection should be tested for each call to set data on the LMS." + }, + "_silentRetryLimit": { + "type": "number", + "required": false, + "default": "2", + "title": "Silent Retry Limit", + "inputType": "Number", + "validators": ["number"], + "help": "The limit for silent retry attempts to establish a connection before raising an error." + }, + "_silentRetryDelay": { + "type": "number", + "required": false, + "default": "1000", + "title": "Silent Retry Delay", + "inputType": "Number", + "validators": ["number"], + "help": "The interval in milliseconds between silent connection retries." + } + } } } }, diff --git a/required/connection.txt b/required/connection.txt new file mode 100644 index 00000000..e69de29b diff --git a/schema/config.schema.json b/schema/config.schema.json index a9f2f3cc..33567d3c 100644 --- a/schema/config.schema.json +++ b/schema/config.schema.json @@ -184,6 +184,37 @@ "title": "Completed when failed", "description": "If enabled, `cmi.completion_status` will be set to \"completed\" if the assessment is \"failed\". Only valid for SCORM 2004, where the logic for completion and success is separate.", "default": true + }, + "_connectionTest": { + "type": "object", + "title": "Connection Test", + "default": {}, + "properties": { + "_isEnabled": { + "type": "boolean", + "title": "Enable connection test", + "description": "Determines whether the connection should be tested.", + "default": true + }, + "_testOnSetValue": { + "type": "boolean", + "title": "Test on set value", + "description": "Determines whether the connection should be tested for each call to set data on the LMS.", + "default": true + }, + "_silentRetryLimit": { + "type": "number", + "title": "Silent Retry Limit", + "description": "The limit for silent retry attempts to establish a connection before raising an error.", + "default": 2 + }, + "_silentRetryDelay": { + "type": "number", + "title": "Silent Retry Delay", + "description": "The interval in milliseconds between silent connection retries.", + "default": 1000 + } + } } } },