Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 113 additions & 0 deletions tests/test_video_save_order.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
/* Actual native plugin and clock formatter; bounded jQuery/DOM/HTTP protocols.
* VIDEO_SAVE_STATE_SOURCE selects byte-identical parent source for controls.
*/
const {test} = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const assets = path.join(__dirname, '../xblocks_contrib/video/assets/js/src');

function harness() {
const requests = [];
const events = {on() {return this;}, off() {return this;}};
const underscore = {
extend: (...args) => Object.assign(...args),
has: (obj, key) => Object.prototype.hasOwnProperty.call(obj, key),
isFinite: Number.isFinite,
bindAll(obj, ...names) {names.forEach(name => {obj[name] = obj[name].bind(obj);});},
once(fn) {let called = false; return function(...args) {
if (!called) {called = true; return fn.apply(this, args);}
};}
};
function $() {return events;}
$.Deferred = () => ({resolve() {return this;}, promise() {return this;}});
$.isPlainObject = obj => obj !== null && typeof obj === 'object';
$.ajax = options => {
const record = {...options, data: JSON.parse(JSON.stringify(options.data)), callbacks: []};
requests.push(record);
return {always(callback) {record.callbacks.push(callback);}};
};
const context = vm.createContext({$, _: underscore, window: {}});
const clock = fs.readFileSync(path.join(assets, 'utils/time.js'), 'utf8').replace(/^export /gm, '');
const plugin = fs.readFileSync(process.env.VIDEO_SAVE_STATE_SOURCE ||
path.join(assets, '09_save_state_plugin.js'), 'utf8')
.replace(/^import .*;\s*$/gm, '').replace(/^export default SaveStatePlugin;\s*$/gm, '');
vm.runInContext(clock + '\n' + plugin, context);
function create(url = '/owned/save_user_state') {
const stored = {};
const state = {config: {saveStateEnabled: true, saveStateUrl: url}, videoPlayer: {currentTime: 10},
storage: {setItem(key, value) {stored[key] = value;}}, el: events};
context.state = state;
vm.runInContext('SaveStatePlugin(state, {}, {})', context);
return {state, stored, plugin: state.videoSaveStatePlugin};
}
return {requests, create, settle(index, status = 'success') {
requests[index].callbacks.forEach(callback => callback({}, status));
}};
}

test('native position requests serialize older and newer state', () => {
const h = harness(), v = h.create();
v.plugin.saveState(true); v.state.videoPlayer.currentTime = 73; v.plugin.saveState(true);
if (process.env.VIDEO_SAVE_CAPTURE) {
fs.writeFileSync(process.env.VIDEO_SAVE_CAPTURE,
JSON.stringify(h.requests.map(r => r.data), null, 2) + '\n');
}
assert.equal(h.requests.length, 1);
assert.equal(h.requests[0].data.saved_video_position, '00:00:10');
assert.equal(v.stored.savedVideoPosition, 73);
h.settle(0);
assert.equal(h.requests.length, 2);
assert.equal(h.requests[1].data.saved_video_position, '00:01:13');
assert.equal(h.requests[1].async, true);
if (process.env.VIDEO_SAVE_CAPTURE) {
fs.writeFileSync(process.env.VIDEO_SAVE_CAPTURE,
JSON.stringify(h.requests.map(r => r.data), null, 2) + '\n');
}
});

test('a failed request settles before the next queued save starts', () => {
const h = harness(), v = h.create();
v.plugin.saveState(true, {speed: '1.5'});
v.plugin.saveState(true, {auto_advance: true});
assert.equal(h.requests.length, 1);
h.settle(0, 'error');
assert.equal(h.requests.length, 2);
assert.deepEqual(h.requests[1].data, {auto_advance: true});
});

test('unload does not dispatch a parallel save; destroyed-page delivery is unclaimed', () => {
const h = harness(), v = h.create();
v.plugin.saveState(true); v.state.videoPlayer.currentTime = 90; v.plugin.onUnload();
assert.equal(h.requests.length, 1);
// This callback requires a surviving context. Its absence leaves delivery unfulfilled.
h.settle(0);
assert.equal(h.requests.length, 2);
assert.equal(h.requests[1].async, false);
assert.equal(h.requests[1].data.saved_video_position, '00:01:30');
});

test('an idle synchronous save and disabled saves preserve native behavior', () => {
const h = harness(), v = h.create();
v.state.config.saveStateEnabled = false; v.plugin.saveState(true);
assert.equal(h.requests.length, 0);
v.state.config.saveStateEnabled = true; v.state.videoPlayer.currentTime = 3.1242;
v.plugin.saveState(false);
assert.equal(h.requests.length, 1);
assert.equal(h.requests[0].async, false);
assert.equal(h.requests[0].data.saved_video_position, '00:00:03');
});

test('queued data is a snapshot and instances have independent queues', () => {
const h = harness(), a = h.create('/owned/a'), b = h.create('/owned/b');
a.plugin.saveState(true);
const data = {speed: '1.5'}; a.plugin.saveState(true, data); data.speed = '8';
b.state.videoPlayer.currentTime = 42; b.plugin.saveState(true);
assert.equal(h.requests.length, 2);
assert.equal(h.requests[1].url, '/owned/b');
h.settle(0);
assert.equal(h.requests.length, 3);
assert.equal(h.requests[2].url, '/owned/a');
assert.deepEqual(h.requests[2].data, {speed: '1.5'});
});
32 changes: 25 additions & 7 deletions xblocks_contrib/video/assets/js/src/09_save_state_plugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ let SaveStatePlugin = function(state, i18n, options) {
this.options = _.extend({events: []}, options);
this.state.videoSaveStatePlugin = this;
this.i18n = i18n;
this.pendingSaves = [];
this.saving = false;
this.initialize();

return $.Deferred().resolve().promise();
Expand Down Expand Up @@ -117,14 +119,30 @@ SaveStatePlugin.prototype = {
data.saved_video_position = formatFull(data.saved_video_position);
}

$.ajax({
url: this.state.config.saveStateUrl,
type: 'POST',
async: !!async,
dataType: 'json',
data: data
});
// Keep snapshots in client request order instead of overlapping
// ordinary saves. Transport failure does not prove server commit
// order; this queue is not a durable-delivery protocol.
this.pendingSaves.push({async: !!async, data: _.extend({}, data)});
this.processSaveQueue();
}
},

processSaveQueue: function() {
if (this.saving || !this.pendingSaves.length) {
return;
}
let next = this.pendingSaves.shift();
this.saving = true;
$.ajax({
url: this.state.config.saveStateUrl,
type: 'POST',
async: next.async,
dataType: 'json',
data: next.data
}).always(() => {
this.saving = false;
this.processSaveQueue();
});
}
};

Expand Down
22 changes: 21 additions & 1 deletion xblocks_contrib/video/tests/js/video_save_state_plugin_spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,26 @@ import * as Time from '../../assets/js/src/utils/time.js';
});
});

it('sends the next saved position only after the previous request settles', function() {
var requests = [];
$.ajax.and.callFake(function(options) {
var deferred = $.Deferred();
requests.push({options: options, deferred: deferred});
return deferred.promise();
});
state.config.saveStateEnabled = true;
state.videoPlayer.currentTime = 10;
state.videoSaveStatePlugin.saveState(true);
state.videoPlayer.currentTime = 73;
state.videoSaveStatePlugin.saveState(true);
expect(requests.length).toBe(1);
expect(requests[0].options.data.saved_video_position).toBe('00:00:10');
requests[0].deferred.resolve({success: true});
expect(requests.length).toBe(2);
expect(requests[1].options.data.saved_video_position).toBe('00:01:13');
requests[1].deferred.resolve({success: true});
});

it('can save state on page unload', function() {
$.ajax.calls.reset();
state.videoSaveStatePlugin.onUnload();
Expand Down Expand Up @@ -275,4 +295,4 @@ import * as Time from '../../assets/js/src/utils/time.js';
expect($.fn.off).toHaveBeenCalledWith('unload', plugin.onUnload);
});
});
}).call(this);
}).call(this);
2 changes: 1 addition & 1 deletion xblocks_contrib/video/tests/spec_helpers/helper.js
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@
// Do nothing.
return {};
} else if (settings.url === '/save_user_state') {
return {success: true};
return $.Deferred().resolve({success: true}).promise();
} else if (settings.url.match(/.+video-transcript.+$/)) {
if (settings.url.match(/.+&video_id=notAIGenerated/)) {
return settings.success(null);
Expand Down