Release v2.10.6
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile, web, pulse-signage-web) (push) Successful in 1m15s
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile.player, player, pulse-signage-player) (push) Successful in 34s

This commit is contained in:
2026-08-29 14:59:06 +01:00
parent ca9f38f3b9
commit 8c0c22156e
30 changed files with 922 additions and 531 deletions
+34
View File
@@ -33,3 +33,37 @@ test('playlist notifications use the player URL reported by the screen connectio
command: 'refresh'
}]);
});
test('playlist notifications use the bridge device route for remote player connections', async () => {
const calls = [];
const notify = createNotifyPlayerScreens(
async function () {
calls.push({ type: 'fallback' });
return { ok: true };
},
async function () {
return {
connections: [{ playerDeviceId: 'remote-player-1', playerPublicBaseUrl: 'https://remote.example' }]
};
},
async function () {
calls.push({ type: 'base-url' });
return { ok: true };
},
async function () {
return 'http://player:8081';
},
async function (deviceId, payload) {
calls.push({ deviceId, payload });
return { ok: true };
}
);
const count = await notify(['remote-screen'], 'refresh');
assert.equal(count, 1);
assert.deepEqual(calls, [{
deviceId: 'remote-player-1',
payload: { command: 'refresh', screenSlug: 'remote-screen' }
}]);
});
+12 -2
View File
@@ -5,9 +5,15 @@ const test = require('node:test');
const assert = require('node:assert/strict');
function loadCommandsScript(sandbox) {
const animationPath = path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-animation.js');
const mediaPath = path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-media.js');
const transitionPath = path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-transition.js');
const commandsPath = path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-commands.js');
const animationScript = fs.readFileSync(animationPath, 'utf8');
const mediaScript = fs.readFileSync(mediaPath, 'utf8');
const transitionScript = fs.readFileSync(transitionPath, 'utf8');
const commandsScript = fs.readFileSync(commandsPath, 'utf8');
vm.runInNewContext(commandsScript, sandbox, { filename: commandsPath });
vm.runInNewContext(animationScript + '\n' + mediaScript + '\n' + transitionScript + '\n' + commandsScript, sandbox, { filename: commandsPath });
}
function createSandbox() {
@@ -77,7 +83,8 @@ function createSandbox() {
templateRenderPlanCache: Object.create(null),
renderCacheViewportKey: '',
slideTransitionTimer: null,
slideFadeDurationMs: 560,
slideFadeLengthMs: 560,
slideFadeOffsetMs: 280,
slideExpiresAt: null,
pausedRemainingMs: null,
timer: null,
@@ -119,6 +126,9 @@ function createSandbox() {
test('renderSlideMarkup runs post-render setup immediately', () => {
const { sandbox, calls, rafCallbacks, app } = createSandbox();
loadCommandsScript(sandbox);
sandbox.playRegionAnimations = function () {
calls.playRegionAnimations += 1;
};
const returned = sandbox.renderSlideMarkup('<div class="slide">visible</div>', false);
+122 -1
View File
@@ -149,6 +149,125 @@ test('playlist refresh queues updates until the next slide transition', async ()
assert.equal(calls.logDebug.some((entry) => entry.includes('applying on next slide transition')), true);
});
test('playlist refresh reloads source data after a cached snapshot receives 304', async () => {
const requests = [];
const sandbox = {
window: null,
location: { origin: 'http://localhost' },
Date,
JSON,
Math,
Number,
String,
Boolean,
Array,
Object,
Promise,
setTimeout,
clearTimeout,
console,
slug: 'test',
initialData: null,
currentPlaylistEtag: '"cached-etag"',
currentPlaylistSignature: 'cached-signature',
currentPlaylistFadeBetweenSlides: false,
currentPlaylistSkipUnavailableRtmp: false,
pendingPlaylistUpdate: null,
slides: [{ id: 1, duration_seconds: 10 }],
lastRenderedSlide: null,
activeSlidesCacheKey: '',
activeSlidesCacheValue: [],
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() {},
sendCommandState() {},
scheduleSlideAdvance() {},
logDebug() {}
};
sandbox.window = sandbox;
class XhrStub {
open(method, url) {
this.method = method;
this.url = url;
requests.push(this);
}
setRequestHeader(name, value) {
this.headers = this.headers || {};
this.headers[name] = value;
}
getResponseHeader() { return ''; }
send() {
this.readyState = 4;
if (requests.length === 1) {
this.status = 304;
this.responseText = '';
} else {
this.status = 200;
this.responseText = JSON.stringify({
signature: 'fresh-signature',
slides: [{ id: 1, duration_seconds: 10 }],
rssFeeds: [],
apiSources: [{ id: 1 }],
timetableGroups: [],
weatherLocations: [{ id: 1 }],
playlist: { fade_between_slides: false, skip_unavailable_rtmp: false }
});
}
this.onreadystatechange();
}
}
sandbox.XMLHttpRequest = XhrStub;
const scriptPath = path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-playback.js');
vm.runInNewContext(fs.readFileSync(scriptPath, 'utf8'), sandbox, { filename: scriptPath });
await sandbox.refresh();
assert.equal(requests.length, 2);
assert.equal(requests[1].headers && requests[1].headers['If-None-Match'], undefined);
assert.deepEqual(sandbox.initialData.apiSources, [{ id: 1 }]);
assert.deepEqual(sandbox.initialData.weatherLocations, [{ id: 1 }]);
});
test('centralized slide advance timing preserves the configured slide duration', () => {
const sandbox = {
window: null,
currentPlaylistFadeBetweenSlides: false,
slideFadeLengthMs: 560,
slideFadeOffsetMs: 280,
getSlideHoldDelay(value) {
return value;
}
};
sandbox.window = sandbox;
const scriptPath = path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-playback.js');
const script = fs.readFileSync(scriptPath, 'utf8');
vm.runInNewContext(script, sandbox, { filename: scriptPath });
assert.equal(sandbox.getSlideAdvanceDelay({ duration_seconds: 12 }), 12000);
sandbox.currentPlaylistFadeBetweenSlides = true;
assert.equal(sandbox.getSlideAdvanceDelay({ duration_seconds: 10 }), 9720);
assert.equal(sandbox.getSlideAdvanceDelay({ duration_seconds: 10, use_video_duration: true }), 9440);
assert.equal(sandbox.getSlideAdvanceDelay({ duration_seconds: 12 }), 11720);
assert.equal(sandbox.getSlideAdvanceDelay({ duration_seconds: 12, use_video_duration: true }), 11440);
});
test('single-slide playlists re-render the active slide instead of refreshing after a queued update', async () => {
const calls = {
showCurrent: 0,
@@ -247,8 +366,10 @@ test('single-slide playlists re-render the active slide instead of refreshing af
sandbox.XMLHttpRequest = XhrStub;
const scriptPath = path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-playback.js');
const transitionPath = path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-transition.js');
const commandsPath = path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-commands.js');
const playbackScript = fs.readFileSync(scriptPath, 'utf8');
const transitionScript = fs.readFileSync(transitionPath, 'utf8');
const commandsScript = fs.readFileSync(commandsPath, 'utf8');
const prelude = `
var pendingPlaylistUpdate = null;
@@ -270,7 +391,7 @@ test('single-slide playlists re-render the active slide instead of refreshing af
var templateLayoutCache = Object.create(null);
var templateRenderPlanCache = Object.create(null);
`;
vm.runInNewContext(prelude + '\n' + playbackScript + '\n' + commandsScript, sandbox, { filename: scriptPath });
vm.runInNewContext(prelude + '\n' + transitionScript + '\n' + playbackScript + '\n' + commandsScript, sandbox, { filename: scriptPath });
sandbox.showCurrent = function () {
calls.showCurrent += 1;
};
+41 -3
View File
@@ -99,7 +99,8 @@ test('fqdn player registration wins over a local configured player target for me
assert.equal(success, true);
assert.equal(fetchCalls.length, 1);
assert.equal(fetchCalls[0].url, 'https://pulse-dev-bridge.lzstealth.com/api/media/uploads%2Fsample.bin?deviceId=player-remote');
assert.equal(fetchCalls[0].url, 'https://pulse-dev-bridge.lzstealth.com/api/media/uploads%2Fsample.bin');
assert.equal(fetchCalls[0].init.headers['x-pulse-player-device-id'], 'player-remote');
} finally {
global.fetch = originalFetch;
fs.rmSync(uploadDir, { recursive: true, force: true });
@@ -453,9 +454,46 @@ test('slide update sync queues one media task per live player and targets each p
assert.deepEqual(fetchCalls.map(function (call) {
return call.url;
}).sort(), [
'http://player-one:8081/api/media/uploads%2Fsample.bin?deviceId=player-one',
'http://player-two:8081/api/media/uploads%2Fsample.bin?deviceId=player-two'
'http://player-one:8081/api/media/uploads%2Fsample.bin',
'http://player-two:8081/api/media/uploads%2Fsample.bin'
].sort());
assert.deepEqual(fetchCalls.map(function (call) {
return call.init.headers['x-pulse-player-device-id'];
}).sort(), ['player-one', 'player-two']);
} finally {
global.fetch = originalFetch;
fs.rmSync(uploadDir, { recursive: true, force: true });
}
});
test('remote media sync uses the bridge device route', async () => {
const uploadDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pulse-signage-upload-bridge-'));
fs.mkdirSync(path.join(uploadDir, 'uploads'), { recursive: true });
fs.writeFileSync(path.join(uploadDir, 'uploads', 'sample.bin'), Buffer.from('hello world'));
const activeLastSeenAt = new Date(Date.now() - 10_000).toISOString();
const fetchCalls = [];
const originalFetch = global.fetch;
global.fetch = async function (url, init) {
fetchCalls.push({ url, init });
return { ok: true, status: 200, statusText: 'OK', headers: { get() { return null; } }, async text() { return ''; } };
};
const uploadSyncService = createUploadSyncService({
common: {},
bridgeInternalBaseUrl: 'http://player-bridge:8090',
pool: {
async query() {
return [[{ identifier: 'player-remote', internal_base_url: 'https://remote-player.example', last_seen_at: activeLastSeenAt }]];
}
},
playerSnapshotCache: new Map(),
notifyPlayerScreens: async () => {}
});
try {
assert.equal(await uploadSyncService.pushUploadFileToPlayer('/media/uploads/sample.bin', uploadDir), true);
assert.equal(fetchCalls[0].url, 'http://player-bridge:8090/api/media/uploads%2Fsample.bin');
assert.equal(fetchCalls[0].init.headers['x-pulse-player-device-id'], 'player-remote');
} finally {
global.fetch = originalFetch;
fs.rmSync(uploadDir, { recursive: true, force: true });