From cdf1113e65699da5deb9d4fdc1205107e64c145e Mon Sep 17 00:00:00 2001 From: Gurpreet <60426541+lifeofgurpreet@users.noreply.github.com> Date: Fri, 2 Oct 2026 18:48:46 +0200 Subject: [PATCH] Serialize ordinary Video state-save request lifetimes Queue per-instance payload snapshots while a jqXHR is active. Preserve native request and cache semantics; ambiguous transport failure, destroyed-context delivery and durable server ordering remain unqualified. Co-Authored-By: Claude Opus 5.5 --- tests/test_video_save_order.js | 113 ++++++++++++++++++ .../assets/js/src/09_save_state_plugin.js | 32 +++-- .../tests/js/video_save_state_plugin_spec.js | 22 +++- .../video/tests/spec_helpers/helper.js | 2 +- 4 files changed, 160 insertions(+), 9 deletions(-) create mode 100644 tests/test_video_save_order.js diff --git a/tests/test_video_save_order.js b/tests/test_video_save_order.js new file mode 100644 index 00000000..55fbf50b --- /dev/null +++ b/tests/test_video_save_order.js @@ -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'}); +}); diff --git a/xblocks_contrib/video/assets/js/src/09_save_state_plugin.js b/xblocks_contrib/video/assets/js/src/09_save_state_plugin.js index c5de6262..f1d2480c 100644 --- a/xblocks_contrib/video/assets/js/src/09_save_state_plugin.js +++ b/xblocks_contrib/video/assets/js/src/09_save_state_plugin.js @@ -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(); @@ -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(); + }); } }; diff --git a/xblocks_contrib/video/tests/js/video_save_state_plugin_spec.js b/xblocks_contrib/video/tests/js/video_save_state_plugin_spec.js index f1256ba7..467fe48b 100644 --- a/xblocks_contrib/video/tests/js/video_save_state_plugin_spec.js +++ b/xblocks_contrib/video/tests/js/video_save_state_plugin_spec.js @@ -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(); @@ -275,4 +295,4 @@ import * as Time from '../../assets/js/src/utils/time.js'; expect($.fn.off).toHaveBeenCalledWith('unload', plugin.onUnload); }); }); -}).call(this); \ No newline at end of file +}).call(this); diff --git a/xblocks_contrib/video/tests/spec_helpers/helper.js b/xblocks_contrib/video/tests/spec_helpers/helper.js index 389f9c9f..889b16a4 100644 --- a/xblocks_contrib/video/tests/spec_helpers/helper.js +++ b/xblocks_contrib/video/tests/spec_helpers/helper.js @@ -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);