Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

New: network connection test #270

Merged
merged 8 commits into from
Apr 24, 2023
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 `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`.

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);
danielghost marked this conversation as resolved.
Show resolved Hide resolved
this.scorm.initialize();
}

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

export default class Connection {

constructor({
_isEnabled = true,
_silentRetryLimit = 2,
_silentRetryDelay = 1000
} = {}, 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;

try {
const response = await fetch(`connection.json?nocache=${Date.now()}`);
if (response?.ok) return this.onConnectionSuccess();
} catch (err) {}
this.onConnectionError();
}

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.bind(this), this._silentRetryDelay);
return;
}
this.reset();
Adapt.trigger('tracking:connectionError', this.test.bind(this));
danielghost marked this conversation as resolved.
Show resolved Hide resolved
}

}
109 changes: 74 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: true,
_testOnSetValue: true
};

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,17 @@ 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) {
Adapt.on('tracking:connectionError', () => this.handleConnectionError(false));
danielghost marked this conversation as resolved.
Show resolved Hide resolved
this._connection = new Connection(this.connectionTest, ScormWrapper.getInstance());
}

this.startTime = new Date();
this.initTimedCommit();

return this.lmsConnected;
}

Expand Down Expand Up @@ -184,7 +193,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 +213,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 +275,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 +287,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 +300,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 +311,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 +340,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 +396,7 @@ class ScormWrapper {
}

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

Expand All @@ -396,7 +412,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 +432,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 +458,6 @@ class ScormWrapper {
}

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

return success;
}

Expand All @@ -457,12 +475,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 +508,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(triggerError = true) {
danielghost marked this conversation as resolved.
Show resolved Hide resolved
if (triggerError) 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 +544,18 @@ class ScormWrapper {

switch (error.name) {
case CLIENT_COULD_NOT_CONNECT:
notify.popup({
_isCancellable: false,
title: messages.title,
body: message
});
return;
oliverfoster marked this conversation as resolved.
Show resolved Hide resolved
// 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
Loading