Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59730837ad | ||
|
|
4c459e2745 | ||
|
|
b9dd4178c4 | ||
|
|
a6a5d71ef0 | ||
|
|
c55c3d2baf |
@@ -2,6 +2,33 @@
|
|||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
## 2.5.13 - 2026-08-05
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Slide and template save forms now allow larger multipart text fields, so rich region content no longer trips Multer's default field-value limit.
|
||||||
|
|
||||||
|
## 2.5.12 - 2026-08-05
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- The auth route test now loads the alias bootstrap before the login flow, so isolated test runs do not depend on prior module initialization.
|
||||||
|
- The slide editor now blocks pasted data images in TinyMCE so image content must come through the upload flow.
|
||||||
|
|
||||||
|
## 2.5.11 - 2026-08-05
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Single-slide playlists now re-render the active slide immediately when a refresh arrives, instead of waiting for a transition that never happens.
|
||||||
|
- Playlist create and update flows now redirect to the canonical `/playlists/:id/edit` path after saving.
|
||||||
|
- Slide preview popups now keep rich-text spacing and line height consistent with the editor preview.
|
||||||
|
|
||||||
|
## 2.5.10 - 2026-08-05
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Slide editor rich-text updates now keep the hidden field and preview state in sync after inline formatting actions.
|
||||||
|
|
||||||
## 2.5.9 - 2026-08-05
|
## 2.5.9 - 2026-08-05
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "pulse-signage",
|
"name": "pulse-signage",
|
||||||
"version": "2.5.9",
|
"version": "2.5.13",
|
||||||
"private": false,
|
"private": false,
|
||||||
"description": "Pulse Signage application with MySQL and media storage",
|
"description": "Pulse Signage application with MySQL and media storage",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
@@ -395,18 +395,6 @@ body.screen-blackout #app {
|
|||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.template-region.text > *,
|
|
||||||
.template-region.api > *,
|
|
||||||
.template-region.rss > * {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.template-region.text > * + *,
|
|
||||||
.template-region.api > * + *,
|
|
||||||
.template-region.rss > * + * {
|
|
||||||
margin-top: 0.5em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.template-region.text ul,
|
.template-region.text ul,
|
||||||
.template-region.text ol,
|
.template-region.text ol,
|
||||||
.template-region.api ul,
|
.template-region.api ul,
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ function scheduleSlideAdvance(delayMs) {
|
|||||||
applyPendingPlaylistUpdate();
|
applyPendingPlaylistUpdate();
|
||||||
const activeSlides = getCurrentActiveSlides();
|
const activeSlides = getCurrentActiveSlides();
|
||||||
if (activeSlides.length < 2) {
|
if (activeSlides.length < 2) {
|
||||||
refresh();
|
showCurrent();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (index >= activeSlides.length) {
|
if (index >= activeSlides.length) {
|
||||||
|
|||||||
@@ -99,6 +99,16 @@ function showCurrent() {
|
|||||||
lastRenderedViewKey = getCurrentRenderKey(activeSlides);
|
lastRenderedViewKey = getCurrentRenderKey(activeSlides);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isSlideInList(slide, slideList) {
|
||||||
|
if (!slide || !Array.isArray(slideList)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return slideList.some(function (item) {
|
||||||
|
return item && Number(item.id) === Number(slide.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Skip the current slide when an RTMP feed is unavailable and the playlist asks for it.
|
// Skip the current slide when an RTMP feed is unavailable and the playlist asks for it.
|
||||||
function handleRtmpPlaybackFailure(message, options) {
|
function handleRtmpPlaybackFailure(message, options) {
|
||||||
if (!currentPlaylistSkipUnavailableRtmp) {
|
if (!currentPlaylistSkipUnavailableRtmp) {
|
||||||
@@ -165,6 +175,7 @@ function refresh() {
|
|||||||
const data = JSON.parse(request.responseText || '{}');
|
const data = JSON.parse(request.responseText || '{}');
|
||||||
const nextSignature = getPlaylistRevision(data);
|
const nextSignature = getPlaylistRevision(data);
|
||||||
const nextSlides = Array.isArray(data.slides) ? data.slides.map(normalizeSlide) : [];
|
const nextSlides = Array.isArray(data.slides) ? data.slides.map(normalizeSlide) : [];
|
||||||
|
const nextActiveSlides = getActiveSlidesFrom(nextSlides);
|
||||||
const nextFadeBetweenSlides = Boolean(data && data.playlist && data.playlist.fade_between_slides);
|
const nextFadeBetweenSlides = Boolean(data && data.playlist && data.playlist.fade_between_slides);
|
||||||
const nextSkipUnavailableRtmp = Boolean(data && data.playlist && data.playlist.skip_unavailable_rtmp);
|
const nextSkipUnavailableRtmp = Boolean(data && data.playlist && data.playlist.skip_unavailable_rtmp);
|
||||||
savePlaylistSnapshot({
|
savePlaylistSnapshot({
|
||||||
@@ -196,17 +207,22 @@ function refresh() {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
syncWebpagePreloads(getActiveSlidesFrom(nextSlides), index);
|
syncWebpagePreloads(nextActiveSlides, index);
|
||||||
if (typeof syncRtmpWarmups === 'function') {
|
if (typeof syncRtmpWarmups === 'function') {
|
||||||
syncRtmpWarmups(getActiveSlidesFrom(nextSlides), index);
|
syncRtmpWarmups(nextActiveSlides, index);
|
||||||
}
|
}
|
||||||
if (currentActiveSlides.length < 2) {
|
if (nextActiveSlides.length < 2) {
|
||||||
pendingPlaylistUpdate = {
|
pendingPlaylistUpdate = {
|
||||||
slides: nextSlides,
|
slides: nextSlides,
|
||||||
signature: nextSignature,
|
signature: nextSignature,
|
||||||
fadeBetweenSlides: nextFadeBetweenSlides,
|
fadeBetweenSlides: nextFadeBetweenSlides,
|
||||||
skipUnavailableRtmp: nextSkipUnavailableRtmp
|
skipUnavailableRtmp: nextSkipUnavailableRtmp
|
||||||
};
|
};
|
||||||
|
if (!isSlideInList(lastRenderedSlide, nextSlides)) {
|
||||||
|
applyPendingPlaylistUpdate();
|
||||||
|
showCurrent();
|
||||||
|
return;
|
||||||
|
}
|
||||||
logDebug('Playlist update detected; applying on next slide transition.');
|
logDebug('Playlist update detected; applying on next slide transition.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ async function networkFirst(request, cacheName, cacheKeyRequest) {
|
|||||||
if (cached) {
|
if (cached) {
|
||||||
return cached;
|
return cached;
|
||||||
}
|
}
|
||||||
throw _error;
|
return new Response('', { status: 504, statusText: 'Offline' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ function createUploadSyncService(options) {
|
|||||||
const notifyPlayerScreens = options && options.notifyPlayerScreens;
|
const notifyPlayerScreens = options && options.notifyPlayerScreens;
|
||||||
const backgroundTaskQueue = options && options.backgroundTaskQueue;
|
const backgroundTaskQueue = options && options.backgroundTaskQueue;
|
||||||
const MAX_UPLOAD_BYTES = 1024 * 1024 * 1024;
|
const MAX_UPLOAD_BYTES = 1024 * 1024 * 1024;
|
||||||
|
const MAX_FIELD_BYTES = 10 * 1024 * 1024;
|
||||||
const pendingPlayerUploadSyncs = new Map();
|
const pendingPlayerUploadSyncs = new Map();
|
||||||
let pendingPlayerUploadSyncFlushTimer = null;
|
let pendingPlayerUploadSyncFlushTimer = null;
|
||||||
let pendingPlayerUploadSyncFlushInFlight = null;
|
let pendingPlayerUploadSyncFlushInFlight = null;
|
||||||
@@ -84,7 +85,8 @@ function createUploadSyncService(options) {
|
|||||||
return multer({
|
return multer({
|
||||||
storage: storage,
|
storage: storage,
|
||||||
limits: {
|
limits: {
|
||||||
fileSize: MAX_UPLOAD_BYTES
|
fileSize: MAX_UPLOAD_BYTES,
|
||||||
|
fieldSize: MAX_FIELD_BYTES
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1448,6 +1448,31 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.slide-preview-text-content {
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-preview-text-content > * {
|
||||||
|
margin: 1em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-preview-text-content > *:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-preview-text-content > *:last-child {
|
||||||
|
margin-bottom: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-preview-text-content ul,
|
||||||
|
.slide-preview-text-content ol {
|
||||||
|
padding-left: 1.2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-preview-text-content code {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
.slide-preview-image {
|
.slide-preview-image {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|||||||
@@ -38,7 +38,7 @@
|
|||||||
var fontSize = style && style.font_size ? 'font-size:' + Math.max(1, Math.round(Number(style.font_size))) + 'px;' : '';
|
var fontSize = style && style.font_size ? 'font-size:' + Math.max(1, Math.round(Number(style.font_size))) + 'px;' : '';
|
||||||
var width = Math.max(1, Math.round(Number(region && region.width ? region.width : 0) || 1));
|
var width = Math.max(1, Math.round(Number(region && region.width ? region.width : 0) || 1));
|
||||||
var height = Math.max(1, Math.round(Number(region && region.height ? region.height : 0) || 1));
|
var height = Math.max(1, Math.round(Number(region && region.height ? region.height : 0) || 1));
|
||||||
var wrapperStyle = 'width:' + width + 'px;height:' + height + 'px;overflow:hidden;' + fontFamily + fontSize + color;
|
var wrapperStyle = 'width:' + width + 'px;height:' + height + 'px;overflow:hidden;line-height:1.5;' + fontFamily + fontSize + color;
|
||||||
return '<div class="slide-preview-text-content" style="' + wrapperStyle + '">' + sanitizePreviewHtml(raw) + '</div>';
|
return '<div class="slide-preview-text-content" style="' + wrapperStyle + '">' + sanitizePreviewHtml(raw) + '</div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -94,6 +94,39 @@ export function createSlideFormEditorController(options) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function syncEditorState(regionId, editor, shouldLock, hydrated) {
|
||||||
|
if (editor && typeof editor.save === 'function') {
|
||||||
|
editor.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
var content = editor.getContent({ format: 'html' });
|
||||||
|
content = isEmptyRichTextValue(content) ? '' : content;
|
||||||
|
var hidden = getEditorHiddenInput(regionId);
|
||||||
|
var sourceElm = editor && editor.targetElm ? editor.targetElm : null;
|
||||||
|
|
||||||
|
if (hidden) {
|
||||||
|
hidden.value = content;
|
||||||
|
}
|
||||||
|
if (sourceElm) {
|
||||||
|
sourceElm.value = content;
|
||||||
|
}
|
||||||
|
if (typeof editor.nodeChanged === 'function') {
|
||||||
|
editor.nodeChanged();
|
||||||
|
}
|
||||||
|
if (typeof editor.setDirty === 'function') {
|
||||||
|
editor.setDirty(true);
|
||||||
|
}
|
||||||
|
if (shouldLock && templateSelectorLock && typeof templateSelectorLock.arm === 'function') {
|
||||||
|
templateSelectorLock.arm();
|
||||||
|
}
|
||||||
|
if (shouldLock && templateSelectorLock && typeof templateSelectorLock.markEdited === 'function') {
|
||||||
|
templateSelectorLock.markEdited();
|
||||||
|
}
|
||||||
|
if (hydrated) {
|
||||||
|
requestPreviewRender();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function getEditorRegionType(card) {
|
function getEditorRegionType(card) {
|
||||||
if (!card) {
|
if (!card) {
|
||||||
return '';
|
return '';
|
||||||
@@ -228,38 +261,6 @@ export function createSlideFormEditorController(options) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncEditorState(regionId, editor, shouldLock, hydrated) {
|
|
||||||
if (editor && typeof editor.save === 'function') {
|
|
||||||
editor.save();
|
|
||||||
}
|
|
||||||
var content = editor.getContent({ format: 'html' });
|
|
||||||
content = isEmptyRichTextValue(content) ? '' : content;
|
|
||||||
var hidden = getEditorHiddenInput(regionId);
|
|
||||||
var sourceElm = editor && editor.targetElm ? editor.targetElm : null;
|
|
||||||
|
|
||||||
if (hidden) {
|
|
||||||
hidden.value = content;
|
|
||||||
}
|
|
||||||
if (sourceElm) {
|
|
||||||
sourceElm.value = content;
|
|
||||||
}
|
|
||||||
if (typeof editor.nodeChanged === 'function') {
|
|
||||||
editor.nodeChanged();
|
|
||||||
}
|
|
||||||
if (typeof editor.setDirty === 'function') {
|
|
||||||
editor.setDirty(true);
|
|
||||||
}
|
|
||||||
if (shouldLock && templateSelectorLock && typeof templateSelectorLock.arm === 'function') {
|
|
||||||
templateSelectorLock.arm();
|
|
||||||
}
|
|
||||||
if (shouldLock && templateSelectorLock && typeof templateSelectorLock.markEdited === 'function') {
|
|
||||||
templateSelectorLock.markEdited();
|
|
||||||
}
|
|
||||||
if (hydrated) {
|
|
||||||
requestPreviewRender();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyInlineFormat(regionId, editor, formatName) {
|
function applyInlineFormat(regionId, editor, formatName) {
|
||||||
if (editor && editor.undoManager && typeof editor.undoManager.transact === 'function') {
|
if (editor && editor.undoManager && typeof editor.undoManager.transact === 'function') {
|
||||||
editor.undoManager.transact(function () {
|
editor.undoManager.transact(function () {
|
||||||
@@ -287,6 +288,7 @@ export function createSlideFormEditorController(options) {
|
|||||||
menubar: false,
|
menubar: false,
|
||||||
branding: false,
|
branding: false,
|
||||||
promotion: false,
|
promotion: false,
|
||||||
|
paste_data_images: false,
|
||||||
plugins: 'lists code advlist fullscreen table',
|
plugins: 'lists code advlist fullscreen table',
|
||||||
toolbar: 'undo redo | fontfamily fontsizeinput | forecolor backcolor bold italic underlineformats removeformat | align lineheight indent outdent bullist numlist table chip | fullscreen',
|
toolbar: 'undo redo | fontfamily fontsizeinput | forecolor backcolor bold italic underlineformats removeformat | align lineheight indent outdent bullist numlist table chip | fullscreen',
|
||||||
toolbar_mode: 'sliding',
|
toolbar_mode: 'sliding',
|
||||||
|
|||||||
@@ -295,7 +295,7 @@ module.exports = function registerPlaylistRoutes(app, deps) {
|
|||||||
};
|
};
|
||||||
await savePlaylistItems(connection, playlist, req.body, actorId, { updatePlaylist: false });
|
await savePlaylistItems(connection, playlist, req.body, actorId, { updatePlaylist: false });
|
||||||
await connection.commit();
|
await connection.commit();
|
||||||
redirectAfterSave(req, res, '/playlists?edit=' + result.insertId, {
|
redirectAfterSave(req, res, '/playlists/' + result.insertId + '/edit', {
|
||||||
closeUrl: '/playlists',
|
closeUrl: '/playlists',
|
||||||
newUrl: '/playlists/new',
|
newUrl: '/playlists/new',
|
||||||
message: 'Playlist created.'
|
message: 'Playlist created.'
|
||||||
@@ -352,7 +352,7 @@ module.exports = function registerPlaylistRoutes(app, deps) {
|
|||||||
await connection.commit();
|
await connection.commit();
|
||||||
await notifyPlayerScreens(saveResult.affectedScreens, 'refresh');
|
await notifyPlayerScreens(saveResult.affectedScreens, 'refresh');
|
||||||
await broadcastDashboardState();
|
await broadcastDashboardState();
|
||||||
redirectAfterSave(req, res, '/playlists?edit=' + playlist.id, {
|
redirectAfterSave(req, res, '/playlists/' + playlist.id + '/edit', {
|
||||||
closeUrl: '/playlists',
|
closeUrl: '/playlists',
|
||||||
newUrl: '/playlists/new',
|
newUrl: '/playlists/new',
|
||||||
message: 'Playlist updated.'
|
message: 'Playlist updated.'
|
||||||
|
|||||||
@@ -27,6 +27,31 @@
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.slide-preview-text-content {
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-preview-text-content > * {
|
||||||
|
margin: 1em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-preview-text-content > *:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-preview-text-content > *:last-child {
|
||||||
|
margin-bottom: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-preview-text-content ul,
|
||||||
|
.slide-preview-text-content ol {
|
||||||
|
padding-left: 1.2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-preview-text-content code {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<div class="slide-preview-popup-stage" id="popup-preview-stage">
|
<div class="slide-preview-popup-stage" id="popup-preview-stage">
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
const test = require('node:test');
|
const test = require('node:test');
|
||||||
const assert = require('node:assert/strict');
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
require('../src/common');
|
||||||
|
|
||||||
const registerAuthRoutes = require('../src/web/routes/auth');
|
const registerAuthRoutes = require('../src/web/routes/auth');
|
||||||
const renderLoginPage = require('../src/web/routes/auth/login');
|
const renderLoginPage = require('../src/web/routes/auth/login');
|
||||||
const { createSessionService, normalizeReturnToPath } = require('../src/web/lib/auth/session');
|
const { createSessionService, normalizeReturnToPath } = require('../src/web/lib/auth/session');
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ test('playlist refresh queues updates until the next slide transition', async ()
|
|||||||
timer: null,
|
timer: null,
|
||||||
slideExpiresAt: null,
|
slideExpiresAt: null,
|
||||||
pausedRemainingMs: null,
|
pausedRemainingMs: null,
|
||||||
|
app: null,
|
||||||
currentPlaylistEtag: '',
|
currentPlaylistEtag: '',
|
||||||
activeSlidesCacheKey: '',
|
activeSlidesCacheKey: '',
|
||||||
activeSlidesCacheValue: [],
|
activeSlidesCacheValue: [],
|
||||||
@@ -117,6 +118,7 @@ test('playlist refresh queues updates until the next slide transition', async ()
|
|||||||
var timer = null;
|
var timer = null;
|
||||||
var slideExpiresAt = null;
|
var slideExpiresAt = null;
|
||||||
var pausedRemainingMs = null;
|
var pausedRemainingMs = null;
|
||||||
|
var app = null;
|
||||||
var activeSlidesCacheKey = '';
|
var activeSlidesCacheKey = '';
|
||||||
var activeSlidesCacheValue = [];
|
var activeSlidesCacheValue = [];
|
||||||
var renderCacheViewportKey = '';
|
var renderCacheViewportKey = '';
|
||||||
@@ -133,4 +135,273 @@ test('playlist refresh queues updates until the next slide transition', async ()
|
|||||||
assert.equal(sandbox.slides[0].disable_audio, undefined);
|
assert.equal(sandbox.slides[0].disable_audio, undefined);
|
||||||
assert.equal(calls.logDebug.some((entry) => entry.includes('Unable to load screen playlist.')), false);
|
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);
|
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('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);
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const registerPlaylistRoutes = require('../src/web/routes/admin/playlists');
|
||||||
|
|
||||||
|
function createAppAndHandlers() {
|
||||||
|
const handlers = {};
|
||||||
|
const app = {
|
||||||
|
post(path, ...routeHandlers) {
|
||||||
|
handlers[path] = routeHandlers;
|
||||||
|
},
|
||||||
|
get() {}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { app, handlers };
|
||||||
|
}
|
||||||
|
|
||||||
|
function createResponse() {
|
||||||
|
return {
|
||||||
|
redirectedTo: '',
|
||||||
|
statusCode: 200,
|
||||||
|
redirect(url) {
|
||||||
|
this.redirectedTo = url;
|
||||||
|
},
|
||||||
|
status(code) {
|
||||||
|
this.statusCode = code;
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
send() {
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('playlist create and update redirect to the canonical edit path', async () => {
|
||||||
|
const calls = [];
|
||||||
|
const connection = {
|
||||||
|
async beginTransaction() {},
|
||||||
|
async commit() {},
|
||||||
|
async rollback() {},
|
||||||
|
release() {},
|
||||||
|
async query(sql) {
|
||||||
|
if (sql.startsWith('INSERT INTO c_playlists')) {
|
||||||
|
return [{ insertId: 4 }];
|
||||||
|
}
|
||||||
|
if (sql.startsWith('UPDATE c_playlists SET')) {
|
||||||
|
calls.push('update-playlist');
|
||||||
|
return [{ affectedRows: 1 }];
|
||||||
|
}
|
||||||
|
if (sql.startsWith('DELETE FROM c_playlist_slides')) {
|
||||||
|
calls.push('delete-slides');
|
||||||
|
return [{ affectedRows: 1 }];
|
||||||
|
}
|
||||||
|
return [[]];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const { app, handlers } = createAppAndHandlers();
|
||||||
|
registerPlaylistRoutes(app, {
|
||||||
|
pool: {
|
||||||
|
async getConnection() {
|
||||||
|
return connection;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
common: {
|
||||||
|
async fetchDuplicateName() { return null; },
|
||||||
|
async fetchCanvasSizeById() { return { id: 7 }; },
|
||||||
|
async fetchPlaylistById() { return { id: 4, canvas_id: 7 }; }
|
||||||
|
},
|
||||||
|
pages: {},
|
||||||
|
fetchOrderedPlaylistSlides: async () => [],
|
||||||
|
fetchScreensByPlaylistId: async () => [],
|
||||||
|
fetchPlaylistCanvasId: async () => null,
|
||||||
|
readArrayField: () => [],
|
||||||
|
getAuditUserId: () => 12,
|
||||||
|
redirectAfterSave(req, res, url) {
|
||||||
|
calls.push({ kind: 'redirectAfterSave', url, path: req.path });
|
||||||
|
res.redirect(url);
|
||||||
|
},
|
||||||
|
notifyPlayerScreens: async () => {},
|
||||||
|
broadcastDashboardState: async () => {},
|
||||||
|
getPlaylistDeleteBlockMessage: async () => '',
|
||||||
|
requirePermission() {
|
||||||
|
return function (_req, _res, next) {
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const createHandlers = handlers['/playlists'];
|
||||||
|
const updateHandlers = handlers['/playlists/:id'];
|
||||||
|
assert.equal(Array.isArray(createHandlers), true);
|
||||||
|
assert.equal(Array.isArray(updateHandlers), true);
|
||||||
|
|
||||||
|
const createRes = createResponse();
|
||||||
|
await createHandlers[1]({
|
||||||
|
path: '/playlists',
|
||||||
|
body: {
|
||||||
|
name: 'Lunch Loop',
|
||||||
|
canvas_size_id: '7',
|
||||||
|
slide_id: []
|
||||||
|
}
|
||||||
|
}, createRes, () => {});
|
||||||
|
|
||||||
|
const updateRes = createResponse();
|
||||||
|
await updateHandlers[1]({
|
||||||
|
path: '/playlists/4',
|
||||||
|
params: { id: '4' },
|
||||||
|
body: {
|
||||||
|
name: 'Lunch Loop',
|
||||||
|
slide_id: []
|
||||||
|
}
|
||||||
|
}, updateRes, () => {});
|
||||||
|
|
||||||
|
assert.equal(createRes.redirectedTo, '/playlists/4/edit');
|
||||||
|
assert.equal(updateRes.redirectedTo, '/playlists/4/edit');
|
||||||
|
assert.deepEqual(calls, [
|
||||||
|
'delete-slides',
|
||||||
|
{ kind: 'redirectAfterSave', url: '/playlists/4/edit', path: '/playlists' },
|
||||||
|
'update-playlist',
|
||||||
|
'delete-slides',
|
||||||
|
{ kind: 'redirectAfterSave', url: '/playlists/4/edit', path: '/playlists/4' }
|
||||||
|
]);
|
||||||
|
});
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
|
||||||
|
const slideFormEditorSource = fs.readFileSync(require.resolve('../src/web/public/js/slides/slide-form-editor.js'), 'utf8');
|
||||||
|
|
||||||
|
test('slide editor disables pasted data images in TinyMCE', () => {
|
||||||
|
assert.ok(slideFormEditorSource.includes('paste_data_images: false'));
|
||||||
|
});
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const os = require('node:os');
|
||||||
|
const path = require('node:path');
|
||||||
|
const express = require('express');
|
||||||
|
|
||||||
|
require('../src/common');
|
||||||
|
|
||||||
|
const { createUploadSyncService } = require('../src/web/lib/media');
|
||||||
|
|
||||||
|
test('multipart uploads accept large text fields used by slide and template forms', async () => {
|
||||||
|
const uploadDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pulse-signage-upload-'));
|
||||||
|
const uploadSyncService = createUploadSyncService({
|
||||||
|
common: {},
|
||||||
|
playerSnapshotCache: new Map(),
|
||||||
|
notifyPlayerScreens: async () => {}
|
||||||
|
});
|
||||||
|
const upload = uploadSyncService.createUploadMiddleware(uploadDir);
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
app.post('/upload', upload.any(), function (_req, res) {
|
||||||
|
res.sendStatus(204);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use(function (error, _req, res, _next) {
|
||||||
|
res.status(error.statusCode || 500).send(String(error && error.message ? error.message : error));
|
||||||
|
});
|
||||||
|
|
||||||
|
const server = app.listen(0);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const address = server.address();
|
||||||
|
const largeValue = 'x'.repeat(1_100_000);
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.set('region_text_6', largeValue);
|
||||||
|
|
||||||
|
const response = await fetch(`http://127.0.0.1:${address.port}/upload`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(response.status, 204);
|
||||||
|
} finally {
|
||||||
|
await new Promise((resolve) => server.close(resolve));
|
||||||
|
fs.rmSync(uploadDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user