const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const vm = require('node:vm'); const packageVersion = require('../package.json').version; const renderingShared = require('../src/player/rendering-shared'); const { renderPlayerPage, renderPlayerOnboardingFormPage } = require('../src/player/render'); require('../src/common'); const timetableRegionSource = fs.readFileSync(require.resolve('../src/player/regions/timetable.js'), 'utf8'); const { mediaKind, normalizeSlide, renderEditorJsContent, renderHtmlRegionContent, sanitizeRichText, getAnnouncementIconsDataScript, getPlayerRuntimeScripts } = require('../src/player/render-helpers'); test('mediaKind classifies player media by extension', () => { assert.equal(mediaKind('poster.PNG'), 'image'); assert.equal(mediaKind('intro.mp4'), 'video'); assert.equal(mediaKind('manual.pdf'), 'pdf'); assert.equal(mediaKind('notes.txt'), 'file'); }); test('sanitizeRichText strips unsafe content but preserves allowed markup', () => { const html = '
LinkText
NameValue
AlphaBeta
Photo
'; assert.equal( sanitizeRichText(html), '
LinkText
NameValue
AlphaBeta
Photo
' ); }); test('normalizeSlide normalizes nested content without mutating the source', () => { const slide = { id: 12, content: { hero: { value: '{"headline":"Hello"}', font_family: ' Open Sans! ', font_size: '42', font_color: 'not-a-color', type: 'text' }, footer: 'plain text' } }; const normalized = normalizeSlide(slide); assert.notEqual(normalized.content, slide.content); assert.deepEqual(normalized.content.hero, { value: { headline: 'Hello' }, font_family: 'Open Sans', font_size: 42, font_color: '#000000', type: 'text' }); assert.deepEqual(normalized.content.footer, { type: 'text', value: 'plain text' }); assert.deepEqual(slide.content.hero, { value: '{"headline":"Hello"}', font_family: ' Open Sans! ', font_size: '42', font_color: 'not-a-color', type: 'text' }); }); test('server and browser slide normalization stay in sync', () => { const browserContext = { window: {} }; const sharedRenderingSource = fs.readFileSync(require.resolve('../src/player/rendering-shared.js'), 'utf8'); const browserRenderingSource = fs.readFileSync(require.resolve('../src/player/public/js/player-page-rendering.js'), 'utf8'); const slide = { content: { hero: { value: '{"headline":"Hello"}', font_family: ' Open Sans! ', font_size: '42', font_color: 'not-a-color', type: 'text' } } }; vm.runInNewContext(sharedRenderingSource, browserContext); vm.runInNewContext(browserRenderingSource, browserContext); assert.equal(JSON.stringify(browserContext.normalizeSlide(slide)), JSON.stringify(normalizeSlide(slide))); }); test('renderEditorJsContent sanitizes editor blocks and wraps legacy text', () => { const editorJson = { blocks: [ { type: 'header', data: { level: 2, text: 'Title' } }, { type: 'paragraph', data: { text: 'badok' } }, { type: 'list', data: { style: 'ordered', items: ['One', { text: 'Two' }] } } ] }; assert.equal( renderEditorJsContent(editorJson), '

Title

badok

  1. One
  2. Two
' ); assert.equal(renderEditorJsContent('plain text'), '

plain text

'); }); test('region animation decoration preserves plain markup and annotates animated roots', () => { const plainMarkup = '
Text
'; const animationConfig = { intro: { preset: 'fadeIn', duration_ms: 700 } }; assert.equal(renderingShared.decorateRegionMarkup(plainMarkup, { animationConfig: {} }), plainMarkup); assert.equal( renderingShared.decorateRegionMarkup(plainMarkup, { animationConfig: animationConfig }), '
Text
' ); const browserContext = { window: {} }; vm.runInNewContext(fs.readFileSync(require.resolve('../src/player/rendering-shared.js'), 'utf8'), browserContext); vm.runInNewContext(fs.readFileSync(require.resolve('../src/player/public/js/player-page-rendering.js'), 'utf8'), browserContext); browserContext.getTemplateRenderPlan = function () { return { layout: { regions: [{ regionKey: 'hero', regionType: 'text', animationJson: JSON.stringify(animationConfig) }], canvasWidth: 1920, canvasHeight: 1080, backgroundColor: '#000000', backgroundGradient: '', background: '' }, renderRegion: function () { return plainMarkup; } }; }; browserContext.normalizePlayerAnimationConfig = function (value) { return JSON.parse(value); }; browserContext.selectPlayerMediaSource = function () { return ''; }; browserContext.setPlayerCanvasDimensions = function () {}; browserContext.buildBackdropStyle = function () { return ''; }; browserContext.renderSlideShell = function (_slide, _canvasClass, _width, _height, content) { return content; }; const renderedSlide = browserContext.renderTemplateSlideMarkup({ template: {}, content: {} }); assert.match(renderedSlide, /data-animation-json=/); }); test('browser rendering helpers select media and expose playlist revision and canvas helpers', () => { const styles = {}; const browserContext = { window: {}, document: { documentElement: { style: { setProperty: function (key, value) { styles[key] = value; } } } }, currentPlaylistEtag: '"cached-revision"' }; vm.runInNewContext(fs.readFileSync(require.resolve('../src/player/rendering-shared.js'), 'utf8'), browserContext); vm.runInNewContext(fs.readFileSync(require.resolve('../src/player/public/js/player-page-rendering.js'), 'utf8'), browserContext); assert.equal(browserContext.selectPlayerMediaSource('/media/original.jpg', [ { url: '/media/small.jpg', width: 400 }, { url: '/media/large.jpg', width: 1200 } ], 800), '/media/large.jpg'); assert.equal(browserContext.getPlaylistRevision({ revision: 'new-revision' }), 'new-revision'); assert.equal(browserContext.getPlaylistRevision({}), 'cached-revision'); browserContext.setPlayerCanvasDimensions(1920, 1080); assert.deepEqual(styles, { '--player-canvas-width': '1920px', '--player-canvas-height': '1080px' }); }); test('server and browser rich-text rendering stay in sync', () => { const browserContext = { window: {} }; const sharedRenderingSource = fs.readFileSync(require.resolve('../src/player/rendering-shared.js'), 'utf8'); const browserRenderingSource = fs.readFileSync(require.resolve('../src/player/public/js/player-page-rendering.js'), 'utf8'); vm.runInNewContext(sharedRenderingSource, browserContext); vm.runInNewContext(browserRenderingSource, browserContext); const value = { blocks: [{ type: 'paragraph', data: { text: '
Cell
' } }] }; assert.equal(browserContext.renderEditorJsContent(value), renderEditorJsContent(value)); }); test('shared API item selection treats object paths as one item and preserves array/root behavior', () => { const helpers = require('../src/web/public/js/shared/placeholder-utils'); const campaign = { name: 'Campaign', amount_raised: { value: '200' }, goal: { value: '1000' } }; const response = { data: campaign }; assert.deepEqual(helpers.resolveApiItems(response, 'data'), [campaign]); assert.deepEqual(helpers.resolveApiItems(response, ''), [response]); assert.deepEqual(helpers.resolveApiItems(response, 'missing'), []); assert.deepEqual(helpers.resolveApiItems(response, 'data.name'), []); assert.deepEqual(helpers.resolveApiItems(response, 'data.absent.nested'), []); assert.deepEqual(helpers.resolveApiItems({ data: null }, 'data'), []); assert.deepEqual(helpers.resolveApiItems({ data: {} }, 'data'), [{}]); const rows = [campaign]; for (const key of ['items', 'results', 'data']) { assert.equal(helpers.resolveApiItems({ [key]: rows }, ''), rows); assert.equal(helpers.resolveApiItems({ [key]: rows }, key), rows); } assert.equal(helpers.resolveApiItems(rows, ''), rows); assert.deepEqual(helpers.resolveApiItems({ data: rows }, 'data[0]'), [campaign]); const sandbox = { window: {} }; vm.runInNewContext(fs.readFileSync(require.resolve('../src/web/public/js/shared/placeholder-utils.js'), 'utf8'), sandbox); assert.equal(JSON.stringify(sandbox.window.placeholderUtils.resolveApiItems(response, 'data')), JSON.stringify([campaign])); }); test('API editor and player resolve object paths, root overrides, and numeric placeholders consistently', () => { let editor; const campaign = { name: 'Campaign', amount_raised: { value: '200' }, goal: { value: '1000' } }; const response = { data: campaign }; const sources = [{ id: 4, items_path: 'data', responseJson: response }]; const context = { window: { pulseRegionTypes: { register(_type, definition) { editor = definition; } }, pulsePlayerRegionTypes: { register() {} } }, initialData: { apiSources: sources }, escapeHtml: String }; for (const file of ['../src/web/public/js/shared/placeholder-utils.js', '../src/web/public/js/regions/type/api.js', '../src/player/regions/api.js']) { vm.runInNewContext(fs.readFileSync(require.resolve(file), 'utf8'), context); } assert.equal(editor.getItems(4, sources)[0], campaign); assert.equal(editor.getItemCount(4, sources), 1); assert.equal(context.getApiItem(4, 1), campaign); assert.equal(context.getApiItem(4, 2), null); assert.equal(editor.getItems(4, sources, '')[0], response); assert.equal(context.getApiItem(4, 1, ''), response); assert.equal(editor.getItemCount(4, sources, 'missing'), 0); assert.equal(context.getApiItem(4, 1, 'missing'), null); assert.equal(context.substituteApiVariables('{{amount_raised.value}}', campaign, 4), '200'); assert.match(context.substituteApiVariables('{{progress(amount_raised.value,goal.value)}}', campaign, 4), /width:20%/); }); test('shared API progress placeholder renders a clamped bar from numeric fields', () => { const sandbox = { window: {} }; vm.runInNewContext(fs.readFileSync(require.resolve('../src/web/public/js/shared/placeholder-utils.js'), 'utf8'), sandbox); const placeholderUtils = sandbox.window.placeholderUtils; const markup = placeholderUtils.renderProgressPlaceholder( { fundraising: { current: '$1,250.00', total: '$1,000.00' } }, 'progress(fundraising.current,fundraising.total,success,light,striped,animated)' ); assert.match(markup, / { const sandbox = { window: {} }; vm.runInNewContext(fs.readFileSync(require.resolve('../src/web/public/js/shared/placeholder-utils.js'), 'utf8'), sandbox); const placeholderUtils = sandbox.window.placeholderUtils; const literal = 'progress(200,1000,#F47B20,#2D2D2D,pill)'; assert.equal(placeholderUtils.isProgressPlaceholderExpression(literal), true); const markup = placeholderUtils.renderProgressPlaceholder({}, literal); assert.match(markup, /aria-valuenow="20"/); assert.match(markup, /width:20%/); assert.match(markup, /background-color:#f47b20/); assert.match(markup, /background-color:#2d2d2d/); assert.doesNotMatch(markup, /Ubuntu|font-size:96px/); const fields = placeholderUtils.renderProgressPlaceholder( { raised: { value: 200 }, target: { amount: 1000 } }, 'progress(raised.value,target.amount,pill)' ); assert.match(fields, /aria-valuenow="20"/); }); test('shared placeholder transforms support arithmetic', () => { const sandbox = { window: {} }; vm.runInNewContext(fs.readFileSync(require.resolve('../src/web/public/js/shared/placeholder-utils.js'), 'utf8'), sandbox); const placeholderUtils = sandbox.window.placeholderUtils; assert.equal(placeholderUtils.formatPlaceholderValue(placeholderUtils.resolvePlaceholderExpression({ amount: 12 }, 'amount.multiply(10)')), '120'); assert.equal(placeholderUtils.formatPlaceholderValue(placeholderUtils.resolvePlaceholderExpression({ amount: 12 }, 'amount.add(3)')), '15'); assert.equal(placeholderUtils.formatPlaceholderValue(placeholderUtils.resolvePlaceholderExpression({ amount: 12 }, 'amount.subtract(3)')), '9'); assert.equal(placeholderUtils.formatPlaceholderValue(placeholderUtils.resolvePlaceholderExpression({ amount: 12 }, 'amount.divide(3)')), '4'); assert.equal(placeholderUtils.formatPlaceholderValue(placeholderUtils.resolvePlaceholderExpression({ amount: 12 }, 'amount.add(3).multiply(10)')), '150'); const textlessMarkup = placeholderUtils.renderProgressPlaceholder( { current: 30, total: 100 }, 'progress(current,total,textless)' ); assert.match(textlessMarkup, /aria-label="30%"/); assert.match(textlessMarkup, /aria-valuenow="30"/); assert.match(textlessMarkup, /style="width:30%;color:inherit;"><\/span>/); const radiusMarkup = placeholderUtils.renderProgressPlaceholder( { current: 30, total: 100 }, 'progress(current,total,radius(12px))' ); assert.match(radiusMarkup, /border-radius:12px;/); const now = Date.now(); const timedMarkup = placeholderUtils.renderProgressPlaceholder( { start: new Date(now - 30 * 60 * 1000).toISOString(), end: new Date(now + 30 * 60 * 1000).toISOString() }, 'progress(start,end)' ); assert.match(timedMarkup, /aria-valuenow="50"/); }); test('timetable region registers the timetable type', () => { assert.ok(timetableRegionSource.includes("registry.register('timetable'")); assert.ok(timetableRegionSource.includes("sanitizeRichText(substituteTimetableVariables(value")); }); test('player announcement bootstrap includes catalog-only icons', () => { assert.match(getAnnouncementIconsDataScript(), /bank/); }); test('onboarding form page injects its configured device id', () => { const page = renderPlayerOnboardingFormPage('device-123'); assert.match(page, /var deviceId = "device-123";/); }); test('player runtime script URLs include content fingerprints', () => { const page = renderPlayerPage('test', { slides: [] }); assert.match(page, new RegExp('player-script/playback\\.js\\?v=' + packageVersion.replace(/\./g, '\\.') + '-[a-f0-9]{12}')); }); test('player runtime script assembly includes shared and region scripts once', () => { const runtimeScripts = getPlayerRuntimeScripts(); const scriptNames = runtimeScripts.map(function (entry) { return entry[0]; }); const sharedRenderingIndex = scriptNames.indexOf('rendering-shared'); const pageRenderingIndex = scriptNames.indexOf('rendering'); const htmlRegionScript = runtimeScripts.find(function (entry) { return entry[0].startsWith('region-') && entry[1].includes("registry.register('html'"); }); assert.ok(sharedRenderingIndex >= 0 && sharedRenderingIndex < pageRenderingIndex); assert.ok(scriptNames.includes('placeholder-utils')); assert.ok(scriptNames.includes('qr-code-svg')); assert.ok(htmlRegionScript); assert.equal(new Set(scriptNames).size, scriptNames.length); }); test('shared iframe renderers size preview content explicitly', () => { const playerHtmlRegionSource = fs.readFileSync(require.resolve('../src/player/regions/html.js'), 'utf8'); const playerWebpageRegionSource = fs.readFileSync(require.resolve('../src/player/regions/webpage.js'), 'utf8'); const playerRenderHelpersSource = fs.readFileSync(require.resolve('../src/player/render-helpers.js'), 'utf8'); const slideThumbnailPreviewSource = fs.readFileSync(require.resolve('../src/web/lib/media/slide-thumbnail-preview.js'), 'utf8'); const slideThumbnailsSource = fs.readFileSync(require.resolve('../src/web/lib/media/slide-thumbnails.js'), 'utf8'); assert.ok(playerHtmlRegionSource.includes('style="width:100%;height:100%;border:0;display:block;background:transparent;overflow:hidden;"')); assert.ok(playerWebpageRegionSource.includes('style="width:100%;height:100%;border:0;display:block;background:#fff;overflow:hidden;"')); assert.ok(playerRenderHelpersSource.includes('style="width:100%;height:100%;border:0;display:block;background:transparent;overflow:hidden;"')); assert.ok(slideThumbnailPreviewSource.includes('slide-preview-webpage-region')); assert.ok(slideThumbnailPreviewSource.includes('style="width:100%;height:100%;border:0;display:block;background:transparent;overflow:hidden;"')); assert.ok(slideThumbnailsSource.includes('slide-preview-webpage-placeholder')); assert.ok(slideThumbnailsSource.includes('style="width:100%;height:100%;border:0;display:block;background:transparent;overflow:hidden;"')); }); test('html region helpers unwrap object-shaped values before rendering', () => { const rendered = renderHtmlRegionContent({ html: '
Hi
' }); assert.match(rendered, /srcdoc="<!doctype html><html><head><style>html,body\{margin:0;width:100%;height:100%;overflow:hidden;background:transparent;\}<\/style><\/head><body><div>Hi<\/div><\/body><\/html>"/); assert.doesNotMatch(rendered, /\[object Object\]/); }); test('shared render value normalization follows caller key order recursively', () => { assert.equal( renderingShared.normalizeRenderableString({ value: { content: 'Hi' } }, ['value', 'text', 'html', 'content']), 'Hi' ); assert.equal( renderingShared.normalizeRenderableString({ value: { url: 'https://example.test' } }, ['value', 'text', 'url', 'href', 'src', 'content']), 'https://example.test' ); assert.equal(renderingShared.normalizeRenderableString(null, ['value', 'text']), ''); }); test('shared region value normalization preserves selected values and stringifies other objects', () => { const nestedValue = { amount: 12 }; assert.equal(renderingShared.normalizeRegionValue({ value: nestedValue }, ['value', 'text', 'html']), nestedValue); assert.equal(renderingShared.normalizeRegionValue({ text: 'hello' }, ['value', 'text', 'html']), 'hello'); assert.equal(renderingShared.normalizeRegionValue({ amount: 12 }, ['value', 'text', 'html']), '{"amount":12}'); assert.equal(renderingShared.normalizeRegionValue(12, ['value', 'text', 'html']), 12); const circularValue = {}; circularValue.self = circularValue; assert.equal(renderingShared.normalizeRegionValue(circularValue, ['value', 'text', 'html']), ''); }); test('shared HTML document builder wraps fragments and preserves complete documents', () => { assert.equal( renderingShared.buildHtmlDocument('
Hi
'), '
Hi
' ); assert.equal(renderingShared.buildHtmlDocument('Hi'), 'Hi'); assert.equal(renderingShared.buildHtmlDocument(' '), ''); }); test('browser HTML and webpage regions preserve their distinct output modes', () => { const modules = {}; const browserContext = { window: { pulsePlayerRegionTypes: { register: function (type, regionModule) { modules[type] = regionModule; } } } }; [ '../src/player/rendering-shared.js', '../src/player/public/js/player-page-rendering.js', '../src/player/regions/html.js', '../src/player/regions/webpage.js' ].forEach(function (scriptPath) { vm.runInNewContext(fs.readFileSync(require.resolve(scriptPath), 'utf8'), browserContext); }); const htmlFragment = modules.html.renderRegion({ baseStyle: '' }, { value: { content: 'Hi' } }); const htmlDocument = modules.html.renderRegion({ baseStyle: '' }, { value: 'Hi' }); const webpage = modules.webpage.renderRegion({ baseStyle: '', label: 'Weather' }, { value: { url: 'https://example.test' } }); assert.match(htmlFragment, /template-region-html-content/); assert.match(htmlFragment, /Hi<\/strong>/); assert.match(htmlDocument, /sandbox=""/); assert.match(webpage, /src="https:\/\/example\.test"/); });