Skip to content

Commit

Permalink
Update: added feature to test the network connection when transferrin…
Browse files Browse the repository at this point in the history
…g data to the LMS API (fixes #268).
  • Loading branch information
danielghost committed Mar 15, 2023
1 parent c1b09c1 commit a1386ce
Show file tree
Hide file tree
Showing 6 changed files with 172 additions and 37 deletions.
13 changes: 12 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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 `0`.

* **_silentRetryDelay** (number): The interval in milliseconds between silent connection retries. The default is `2000`.

#### \_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`.

Expand Down
8 changes: 7 additions & 1 deletion example.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions js/adapt-stateful-session.js
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@ export default class StatefulSession extends Backbone.Controller {
if (_.isBoolean(settings._setCompletedWhenFailed)) {
this.scorm.setCompletedWhenFailed = settings._setCompletedWhenFailed;
}
const connectionTest = settings._connectionTest;
if (connectionTest) Object.assign(this.scorm.connectionTest, connectionTest);
this.scorm.initialize();
}

Expand Down
78 changes: 78 additions & 0 deletions js/scorm/Connection.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import Adapt from 'core/js/adapt';

export default class Connection {

constructor({
_isEnabled = false,
_silentRetryLimit = 0,
_silentRetryDelay = 2000
} = {}, ScormWrapper) {
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._scorm = ScormWrapper;
}

async test() {
if (!this._isEnabled || this._isInProgress) return;
this._isInProgress = true;

fetch(`connection.json?nocache=${Date.now()}`)
.then(response => {
(response?.ok) ? this.onConnectionSuccess() : this.onConnectionError();
})
.catch(error => {
this.onConnectionError();
});
}

reset() {
this._silentRetryCount = 0;
this._isSilentDisconnection = false;

if (this._silentRetryTimeout !== null) {
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.bind(this), this._silentRetryDelay);
} else {
this.reset();
Adapt.trigger('tracking:connectionError', this.test.bind(this));
}
}

}
105 changes: 70 additions & 35 deletions js/scorm/wrapper.js
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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;
/**
Expand All @@ -84,9 +85,13 @@ class ScormWrapper {

this.suppressErrors = false;
this.debouncedCommit = _.debounce(this.commit.bind(this), 100);
if (window.__debug) {
this.showDebugWindow();
}
if (window.__debug) this.showDebugWindow();
this._connection = null;

this.connectionTest = {
_isEnabled: false,
_testOnSetValue: false
};

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!');
Expand Down Expand Up @@ -116,13 +121,13 @@ class ScormWrapper {
this.lmsConnected = this.scorm.init();

if (!this.lmsConnected) {
this.handleError(new ScormError(CLIENT_COULD_NOT_CONNECT));
this.handleInitializeError();
return this.lmsConnected;
}

if (this.connectionTest._isEnabled) this._connection = new Connection(this.connectionTest, ScormWrapper.getInstance());
this.startTime = new Date();
this.initTimedCommit();

return this.lmsConnected;
}

Expand Down Expand Up @@ -184,7 +189,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;
}
}
Expand All @@ -204,7 +209,7 @@ class ScormWrapper {
this.setFailed();
break;
default:
this.handleError(new ScormError(CLIENT_STATUS_UNSUPPORTED, { status }));
this.handleDataError(new ScormError(CLIENT_STATUS_UNSUPPORTED, { status }));
}
}

Expand Down Expand Up @@ -266,7 +271,7 @@ class ScormWrapper {
this.logger.debug('ScormWrapper::commit');

if (!this.lmsConnected) {
this.handleError(new ScormError(ScormError.CLIENT_NOT_CONNECTED));
this.handleConnectionError();
return;
}

Expand All @@ -278,6 +283,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;
}
Expand All @@ -289,7 +296,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)
Expand All @@ -300,7 +307,7 @@ class ScormWrapper {
this.logger.debug('ScormWrapper::finish');

if (!this.lmsConnected || this.finishCalled) {
this.handleError(new ScormError(CLIENT_NOT_CONNECTED));
this.handleConnectionError();
return;
}

Expand Down Expand Up @@ -329,12 +336,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)
Expand Down Expand Up @@ -380,7 +392,7 @@ class ScormWrapper {
}

if (!this.lmsConnected) {
this.handleError(new ScormError(CLIENT_NOT_CONNECTED));
this.handleConnectionError();
return;
}

Expand All @@ -396,7 +408,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),
Expand All @@ -416,17 +428,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
if (this._connection && this.connectionTest._testOnSetValue) this._connection.test();
} 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,
Expand All @@ -439,7 +454,6 @@ class ScormWrapper {
}

if (this.commitOnAnyChange) this.debouncedCommit();

return success;
}

Expand All @@ -457,12 +471,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
}

Expand Down Expand Up @@ -491,13 +504,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() {
Adapt.trigger('tracking:connectionError');
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) + '...';
Expand All @@ -511,12 +540,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);
Expand Down
3 changes: 3 additions & 0 deletions required/connection.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"success": true
}

0 comments on commit a1386ce

Please sign in to comment.