Publish Docker Image / build-and-push (./build/Dockerfile, git.lzstealth.com/lzstealth/pulse-signage-web, web) (push) Successful in 1m12s
Publish Docker Image / build-and-push (./build/Dockerfile.player, git.lzstealth.com/lzstealth/pulse-signage-player, player) (push) Successful in 30s
534 lines
16 KiB
JavaScript
534 lines
16 KiB
JavaScript
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const vm = require('node:vm');
|
|
const test = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
|
|
test('playlist refresh queues updates until the next slide transition', async () => {
|
|
const calls = {
|
|
showCurrent: 0,
|
|
logDebug: [],
|
|
scheduleSlideAdvance: []
|
|
};
|
|
|
|
const sandbox = {
|
|
window: null,
|
|
location: { origin: 'http://localhost', href: 'http://localhost/screen/test2' },
|
|
Date,
|
|
JSON,
|
|
Math,
|
|
Number,
|
|
String,
|
|
Boolean,
|
|
Array,
|
|
Object,
|
|
Promise,
|
|
setTimeout,
|
|
clearTimeout,
|
|
console,
|
|
currentPlaylistSignature: 'old-signature',
|
|
currentPlaylistFadeBetweenSlides: false,
|
|
currentPlaylistSkipUnavailableRtmp: false,
|
|
slug: 'test2',
|
|
pendingPlaylistUpdate: null,
|
|
slides: [{ id: 1, duration_seconds: 12 }],
|
|
lastRenderedSlide: { id: 1, duration_seconds: 12 },
|
|
index: 0,
|
|
timer: null,
|
|
slideExpiresAt: null,
|
|
pausedRemainingMs: null,
|
|
app: null,
|
|
currentPlaylistEtag: '',
|
|
initialData: {
|
|
rssFeeds: [{ id: 1 }],
|
|
apiSources: [{ id: 2 }],
|
|
timetableGroups: [{ id: 3 }]
|
|
},
|
|
activeSlidesCacheKey: '',
|
|
activeSlidesCacheValue: [],
|
|
renderCacheViewportKey: '',
|
|
slideMarkupCache: Object.create(null),
|
|
templateLayoutCache: Object.create(null),
|
|
templateRenderPlanCache: Object.create(null),
|
|
getCurrentActiveSlides() {
|
|
return sandbox.slides;
|
|
},
|
|
getPlaylistRevision(data) {
|
|
return data.signature;
|
|
},
|
|
normalizeSlide(slide) {
|
|
return slide;
|
|
},
|
|
getActiveSlidesFrom(slideList) {
|
|
return slideList;
|
|
},
|
|
savePlaylistSnapshot() {},
|
|
markRefreshHealthy() {},
|
|
setOfflineBannerVisible() {},
|
|
scheduleRefreshRetry() {},
|
|
syncWebpagePreloads() {},
|
|
syncRtmpWarmups() {},
|
|
showCurrent() {
|
|
calls.showCurrent += 1;
|
|
},
|
|
sendCommandState() {},
|
|
scheduleSlideAdvance(delayMs) {
|
|
calls.scheduleSlideAdvance.push(delayMs);
|
|
},
|
|
logDebug(...args) {
|
|
calls.logDebug.push(args.join(' '));
|
|
}
|
|
};
|
|
sandbox.window = sandbox;
|
|
|
|
class XhrStub {
|
|
open(method, url) {
|
|
this.method = method;
|
|
this.url = url;
|
|
}
|
|
|
|
setRequestHeader() {}
|
|
|
|
getResponseHeader(name) {
|
|
return name === 'ETag' ? '"next-etag"' : '';
|
|
}
|
|
|
|
send() {
|
|
this.readyState = 4;
|
|
this.status = 200;
|
|
this.responseText = JSON.stringify({
|
|
signature: 'next-signature',
|
|
slides: [{ id: 1, duration_seconds: 12, disable_audio: false }],
|
|
rssFeeds: [{ id: 10 }],
|
|
apiSources: [{ id: 20 }],
|
|
timetableGroups: [{ id: 30 }],
|
|
playlist: { fade_between_slides: false, skip_unavailable_rtmp: false }
|
|
});
|
|
if (typeof this.onreadystatechange === 'function') {
|
|
this.onreadystatechange();
|
|
}
|
|
}
|
|
}
|
|
|
|
sandbox.XMLHttpRequest = XhrStub;
|
|
|
|
const scriptPath = path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-playback.js');
|
|
const script = fs.readFileSync(scriptPath, 'utf8');
|
|
const prelude = `
|
|
var pendingPlaylistUpdate = null;
|
|
var slides = [{ id: 1, duration_seconds: 12 }];
|
|
var currentPlaylistSignature = 'old-signature';
|
|
var currentPlaylistFadeBetweenSlides = false;
|
|
var currentPlaylistSkipUnavailableRtmp = false;
|
|
var currentPlaylistEtag = '';
|
|
var lastRenderedSlide = { id: 1, duration_seconds: 12 };
|
|
var index = 0;
|
|
var timer = null;
|
|
var slideExpiresAt = null;
|
|
var pausedRemainingMs = null;
|
|
var app = null;
|
|
var activeSlidesCacheKey = '';
|
|
var activeSlidesCacheValue = [];
|
|
var renderCacheViewportKey = '';
|
|
var slideMarkupCache = Object.create(null);
|
|
var templateLayoutCache = Object.create(null);
|
|
var templateRenderPlanCache = Object.create(null);
|
|
`;
|
|
vm.runInNewContext(prelude + '\n' + script, sandbox, { filename: scriptPath });
|
|
|
|
await sandbox.refresh();
|
|
|
|
assert.equal(calls.showCurrent, 0);
|
|
assert.equal(sandbox.currentPlaylistSignature, 'old-signature');
|
|
assert.equal(sandbox.slides[0].disable_audio, undefined);
|
|
assert.deepEqual(sandbox.initialData.rssFeeds, [{ id: 10 }]);
|
|
assert.deepEqual(sandbox.initialData.apiSources, [{ id: 20 }]);
|
|
assert.deepEqual(sandbox.initialData.timetableGroups, [{ id: 30 }]);
|
|
assert.equal(calls.logDebug.some((entry) => entry.includes('Unable to load screen playlist.')), false);
|
|
assert.equal(calls.logDebug.some((entry) => entry.includes('applying on next slide transition')), true);
|
|
});
|
|
|
|
test('single-slide playlists re-render the active slide instead of refreshing after a queued update', async () => {
|
|
const calls = {
|
|
showCurrent: 0,
|
|
refresh: 0
|
|
};
|
|
const timers = [];
|
|
|
|
const sandbox = {
|
|
window: null,
|
|
location: { origin: 'http://localhost', href: 'http://localhost/screen/test2' },
|
|
Date,
|
|
JSON,
|
|
Math,
|
|
Number,
|
|
String,
|
|
Boolean,
|
|
Array,
|
|
Object,
|
|
Promise,
|
|
setTimeout(fn) {
|
|
timers.push(fn);
|
|
return timers.length;
|
|
},
|
|
clearTimeout() {},
|
|
console,
|
|
currentPlaylistSignature: 'old-signature',
|
|
currentPlaylistFadeBetweenSlides: false,
|
|
currentPlaylistSkipUnavailableRtmp: false,
|
|
slug: 'test2',
|
|
pendingPlaylistUpdate: null,
|
|
slides: [{ id: 1, duration_seconds: 12 }],
|
|
lastRenderedSlide: { id: 1, duration_seconds: 12 },
|
|
index: 0,
|
|
timer: null,
|
|
slideExpiresAt: null,
|
|
pausedRemainingMs: null,
|
|
currentPlaylistEtag: '',
|
|
activeSlidesCacheKey: '',
|
|
activeSlidesCacheValue: [],
|
|
renderCacheViewportKey: '',
|
|
slideMarkupCache: Object.create(null),
|
|
templateLayoutCache: Object.create(null),
|
|
templateRenderPlanCache: Object.create(null),
|
|
getCurrentActiveSlides() {
|
|
return sandbox.slides;
|
|
},
|
|
getPlaylistRevision(data) {
|
|
return data.signature;
|
|
},
|
|
normalizeSlide(slide) {
|
|
return slide;
|
|
},
|
|
getActiveSlidesFrom(slideList) {
|
|
return slideList;
|
|
},
|
|
savePlaylistSnapshot() {},
|
|
markRefreshHealthy() {},
|
|
setOfflineBannerVisible() {},
|
|
scheduleRefreshRetry() {},
|
|
syncWebpagePreloads() {},
|
|
syncRtmpWarmups() {},
|
|
clearActiveSlidesCache() {},
|
|
showCurrent() {},
|
|
sendCommandState() {},
|
|
refresh() {
|
|
calls.refresh += 1;
|
|
},
|
|
logDebug() {}
|
|
};
|
|
sandbox.window = sandbox;
|
|
|
|
class XhrStub {
|
|
open() {}
|
|
|
|
setRequestHeader() {}
|
|
|
|
getResponseHeader(name) {
|
|
return name === 'ETag' ? '"next-etag"' : '';
|
|
}
|
|
|
|
send() {
|
|
this.readyState = 4;
|
|
this.status = 200;
|
|
this.responseText = JSON.stringify({
|
|
signature: 'next-signature',
|
|
slides: [{ id: 2, duration_seconds: 12, disable_audio: false }],
|
|
playlist: { fade_between_slides: false, skip_unavailable_rtmp: false }
|
|
});
|
|
if (typeof this.onreadystatechange === 'function') {
|
|
this.onreadystatechange();
|
|
}
|
|
}
|
|
}
|
|
|
|
sandbox.XMLHttpRequest = XhrStub;
|
|
|
|
const scriptPath = path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-playback.js');
|
|
const commandsPath = path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-commands.js');
|
|
const playbackScript = fs.readFileSync(scriptPath, 'utf8');
|
|
const commandsScript = fs.readFileSync(commandsPath, 'utf8');
|
|
const prelude = `
|
|
var pendingPlaylistUpdate = null;
|
|
var slides = [{ id: 1, duration_seconds: 12 }];
|
|
var currentPlaylistSignature = 'old-signature';
|
|
var currentPlaylistFadeBetweenSlides = false;
|
|
var currentPlaylistSkipUnavailableRtmp = false;
|
|
var currentPlaylistEtag = '';
|
|
var lastRenderedSlide = { id: 1, duration_seconds: 12 };
|
|
var index = 0;
|
|
var timer = null;
|
|
var slideExpiresAt = null;
|
|
var pausedRemainingMs = null;
|
|
var app = null;
|
|
var activeSlidesCacheKey = '';
|
|
var activeSlidesCacheValue = [];
|
|
var renderCacheViewportKey = '';
|
|
var slideMarkupCache = Object.create(null);
|
|
var templateLayoutCache = Object.create(null);
|
|
var templateRenderPlanCache = Object.create(null);
|
|
`;
|
|
vm.runInNewContext(prelude + '\n' + playbackScript + '\n' + commandsScript, sandbox, { filename: scriptPath });
|
|
sandbox.showCurrent = function () {
|
|
calls.showCurrent += 1;
|
|
};
|
|
|
|
sandbox.pendingPlaylistUpdate = {
|
|
slides: [{ id: 2, duration_seconds: 12 }],
|
|
signature: 'next-signature',
|
|
fadeBetweenSlides: false,
|
|
skipUnavailableRtmp: false
|
|
};
|
|
|
|
sandbox.scheduleSlideAdvance(100);
|
|
assert.equal(timers.length, 1);
|
|
timers.shift()();
|
|
|
|
assert.equal(calls.refresh, 0);
|
|
assert.equal(calls.showCurrent, 1);
|
|
assert.equal(sandbox.currentPlaylistSignature, 'next-signature');
|
|
assert.deepEqual(sandbox.slides, [{ id: 2, duration_seconds: 12 }]);
|
|
});
|
|
|
|
test('deferred playlist updates preserve the current slide index', async () => {
|
|
const sandbox = {
|
|
window: null,
|
|
location: { origin: 'http://localhost', href: 'http://localhost/screen/test2' },
|
|
Date,
|
|
JSON,
|
|
Math,
|
|
Number,
|
|
String,
|
|
Boolean,
|
|
Array,
|
|
Object,
|
|
Promise,
|
|
setTimeout,
|
|
clearTimeout,
|
|
console,
|
|
currentPlaylistSignature: 'old-signature',
|
|
currentPlaylistFadeBetweenSlides: false,
|
|
currentPlaylistSkipUnavailableRtmp: false,
|
|
slug: 'test2',
|
|
pendingPlaylistUpdate: null,
|
|
slides: [
|
|
{ id: 1, duration_seconds: 12 },
|
|
{ id: 2, duration_seconds: 12 },
|
|
{ id: 3, duration_seconds: 12 }
|
|
],
|
|
lastRenderedSlide: { id: 2, duration_seconds: 12 },
|
|
index: 1,
|
|
timer: null,
|
|
slideExpiresAt: null,
|
|
pausedRemainingMs: null,
|
|
app: null,
|
|
currentPlaylistEtag: '',
|
|
activeSlidesCacheKey: '',
|
|
activeSlidesCacheValue: [],
|
|
renderCacheViewportKey: '',
|
|
slideMarkupCache: Object.create(null),
|
|
templateLayoutCache: Object.create(null),
|
|
templateRenderPlanCache: Object.create(null),
|
|
getCurrentActiveSlides() {
|
|
return sandbox.slides;
|
|
},
|
|
getPlaylistRevision(data) {
|
|
return data.signature;
|
|
},
|
|
normalizeSlide(slide) {
|
|
return slide;
|
|
},
|
|
getActiveSlidesFrom(slideList) {
|
|
return slideList;
|
|
},
|
|
savePlaylistSnapshot() {},
|
|
markRefreshHealthy() {},
|
|
setOfflineBannerVisible() {},
|
|
scheduleRefreshRetry() {},
|
|
syncWebpagePreloads() {},
|
|
syncRtmpWarmups() {},
|
|
clearActiveSlidesCache() {},
|
|
showCurrent() {},
|
|
sendCommandState() {},
|
|
logDebug() {}
|
|
};
|
|
sandbox.window = sandbox;
|
|
|
|
const scriptPath = path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-playback.js');
|
|
const script = fs.readFileSync(scriptPath, 'utf8');
|
|
const prelude = `
|
|
var pendingPlaylistUpdate = null;
|
|
var slides = [
|
|
{ id: 1, duration_seconds: 12 },
|
|
{ id: 2, duration_seconds: 12 },
|
|
{ id: 3, duration_seconds: 12 }
|
|
];
|
|
var currentPlaylistSignature = 'old-signature';
|
|
var currentPlaylistFadeBetweenSlides = false;
|
|
var currentPlaylistSkipUnavailableRtmp = false;
|
|
var currentPlaylistEtag = '';
|
|
var lastRenderedSlide = { id: 2, duration_seconds: 12 };
|
|
var index = 1;
|
|
var timer = null;
|
|
var slideExpiresAt = null;
|
|
var pausedRemainingMs = null;
|
|
var app = null;
|
|
var activeSlidesCacheKey = '';
|
|
var activeSlidesCacheValue = [];
|
|
var renderCacheViewportKey = '';
|
|
var slideMarkupCache = Object.create(null);
|
|
var templateLayoutCache = Object.create(null);
|
|
var templateRenderPlanCache = Object.create(null);
|
|
`;
|
|
vm.runInNewContext(prelude + '\n' + script, sandbox, { filename: scriptPath });
|
|
|
|
sandbox.pendingPlaylistUpdate = {
|
|
slides: [
|
|
{ id: 10, duration_seconds: 12 },
|
|
{ id: 11, duration_seconds: 12 },
|
|
{ id: 12, duration_seconds: 12 }
|
|
],
|
|
signature: 'next-signature',
|
|
fadeBetweenSlides: false,
|
|
skipUnavailableRtmp: false
|
|
};
|
|
|
|
const applied = sandbox.applyPendingPlaylistUpdate();
|
|
|
|
assert.equal(applied, true);
|
|
assert.equal(sandbox.currentPlaylistSignature, 'next-signature');
|
|
assert.equal(sandbox.pendingPlaylistUpdate, null);
|
|
assert.deepEqual(sandbox.slides, [
|
|
{ id: 10, duration_seconds: 12 },
|
|
{ id: 11, duration_seconds: 12 },
|
|
{ id: 12, duration_seconds: 12 }
|
|
]);
|
|
assert.equal(sandbox.index, 1);
|
|
});
|
|
|
|
test('removing the currently visible slide from a two-slide playlist applies the one-slide update immediately', async () => {
|
|
const calls = {
|
|
showCurrent: 0,
|
|
logDebug: []
|
|
};
|
|
|
|
const sandbox = {
|
|
window: null,
|
|
location: { origin: 'http://localhost', href: 'http://localhost/screen/test2' },
|
|
Date,
|
|
JSON,
|
|
Math,
|
|
Number,
|
|
String,
|
|
Boolean,
|
|
Array,
|
|
Object,
|
|
Promise,
|
|
setTimeout,
|
|
clearTimeout,
|
|
console,
|
|
currentPlaylistSignature: 'old-signature',
|
|
currentPlaylistFadeBetweenSlides: false,
|
|
currentPlaylistSkipUnavailableRtmp: false,
|
|
slug: 'test2',
|
|
pendingPlaylistUpdate: null,
|
|
slides: [{ id: 1, duration_seconds: 12 }, { id: 2, duration_seconds: 12 }],
|
|
lastRenderedSlide: { id: 2, duration_seconds: 12 },
|
|
index: 1,
|
|
timer: null,
|
|
slideExpiresAt: null,
|
|
pausedRemainingMs: null,
|
|
app: null,
|
|
currentPlaylistEtag: '',
|
|
activeSlidesCacheKey: '',
|
|
activeSlidesCacheValue: [],
|
|
renderCacheViewportKey: '',
|
|
slideMarkupCache: Object.create(null),
|
|
templateLayoutCache: Object.create(null),
|
|
templateRenderPlanCache: Object.create(null),
|
|
getCurrentActiveSlides() {
|
|
return sandbox.slides;
|
|
},
|
|
getPlaylistRevision(data) {
|
|
return data.signature;
|
|
},
|
|
normalizeSlide(slide) {
|
|
return slide;
|
|
},
|
|
getActiveSlidesFrom(slideList) {
|
|
return slideList;
|
|
},
|
|
savePlaylistSnapshot() {},
|
|
markRefreshHealthy() {},
|
|
setOfflineBannerVisible() {},
|
|
scheduleRefreshRetry() {},
|
|
syncWebpagePreloads() {},
|
|
syncRtmpWarmups() {},
|
|
clearActiveSlidesCache() {},
|
|
showCurrent() {
|
|
calls.showCurrent += 1;
|
|
},
|
|
sendCommandState() {},
|
|
refresh() {},
|
|
logDebug(...args) {
|
|
calls.logDebug.push(args.join(' '));
|
|
}
|
|
};
|
|
sandbox.window = sandbox;
|
|
|
|
class XhrStub {
|
|
open() {}
|
|
|
|
setRequestHeader() {}
|
|
|
|
getResponseHeader(name) {
|
|
return name === 'ETag' ? '"next-etag"' : '';
|
|
}
|
|
|
|
send() {
|
|
this.readyState = 4;
|
|
this.status = 200;
|
|
this.responseText = JSON.stringify({
|
|
signature: 'next-signature',
|
|
slides: [{ id: 1, duration_seconds: 12, disable_audio: false }],
|
|
playlist: { fade_between_slides: false, skip_unavailable_rtmp: false }
|
|
});
|
|
if (typeof this.onreadystatechange === 'function') {
|
|
this.onreadystatechange();
|
|
}
|
|
}
|
|
}
|
|
|
|
sandbox.XMLHttpRequest = XhrStub;
|
|
|
|
const scriptPath = path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-playback.js');
|
|
const playbackScript = fs.readFileSync(scriptPath, 'utf8');
|
|
const prelude = `
|
|
var pendingPlaylistUpdate = null;
|
|
var slides = [{ id: 1, duration_seconds: 12 }, { id: 2, duration_seconds: 12 }];
|
|
var currentPlaylistSignature = 'old-signature';
|
|
var currentPlaylistFadeBetweenSlides = false;
|
|
var currentPlaylistSkipUnavailableRtmp = false;
|
|
var currentPlaylistEtag = '';
|
|
var lastRenderedSlide = { id: 2, duration_seconds: 12 };
|
|
var index = 1;
|
|
var timer = null;
|
|
var slideExpiresAt = null;
|
|
var pausedRemainingMs = null;
|
|
var app = null;
|
|
var activeSlidesCacheKey = '';
|
|
var activeSlidesCacheValue = [];
|
|
var renderCacheViewportKey = '';
|
|
var slideMarkupCache = Object.create(null);
|
|
var templateLayoutCache = Object.create(null);
|
|
var templateRenderPlanCache = Object.create(null);
|
|
`;
|
|
vm.runInNewContext(prelude + '\n' + playbackScript, sandbox, { filename: scriptPath });
|
|
|
|
await sandbox.refresh();
|
|
|
|
assert.equal(sandbox.currentPlaylistSignature, 'next-signature');
|
|
assert.equal(sandbox.pendingPlaylistUpdate, null);
|
|
assert.deepEqual(sandbox.slides, [{ id: 1, duration_seconds: 12, disable_audio: false }]);
|
|
assert.equal(calls.logDebug.some((entry) => entry.includes('applying on next slide transition')), false);
|
|
}); |