@xiboplayer/cache 0.4.1 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xiboplayer/cache",
3
- "version": "0.4.1",
3
+ "version": "0.4.3",
4
4
  "description": "Offline caching and download management with parallel chunk downloads",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -12,7 +12,7 @@
12
12
  },
13
13
  "dependencies": {
14
14
  "spark-md5": "^3.0.2",
15
- "@xiboplayer/utils": "0.4.1"
15
+ "@xiboplayer/utils": "0.4.3"
16
16
  },
17
17
  "devDependencies": {
18
18
  "vitest": "^2.0.0",
@@ -39,14 +39,16 @@ export async function cacheWidgetHtml(layoutId, regionId, mediaId, html) {
39
39
  const baseTag = `<base href="${BASE}/cache/media/">`;
40
40
  let modifiedHtml = html;
41
41
 
42
- // Insert base tag after <head> opening tag
43
- if (html.includes('<head>')) {
44
- modifiedHtml = html.replace('<head>', '<head>' + baseTag);
45
- } else if (html.includes('<HEAD>')) {
46
- modifiedHtml = html.replace('<HEAD>', '<HEAD>' + baseTag);
47
- } else {
48
- // No head tag, prepend base tag
49
- modifiedHtml = baseTag + html;
42
+ // Insert base tag after <head> opening tag (skip if already present)
43
+ if (!html.includes('<base ')) {
44
+ if (html.includes('<head>')) {
45
+ modifiedHtml = html.replace('<head>', '<head>' + baseTag);
46
+ } else if (html.includes('<HEAD>')) {
47
+ modifiedHtml = html.replace('<HEAD>', '<HEAD>' + baseTag);
48
+ } else {
49
+ // No head tag, prepend base tag
50
+ modifiedHtml = baseTag + html;
51
+ }
50
52
  }
51
53
 
52
54
  // Rewrite absolute CMS signed URLs to local cache paths
@@ -65,10 +67,12 @@ export async function cacheWidgetHtml(layoutId, regionId, mediaId, html) {
65
67
  // CMS global-elements.xml uses {{alignId}} {{valignId}} which produces
66
68
  // invalid CSS (empty value) when alignment is not configured
67
69
  const cssFixTag = '<style>img,video{object-position:center center}</style>';
68
- if (modifiedHtml.includes('</head>')) {
69
- modifiedHtml = modifiedHtml.replace('</head>', cssFixTag + '</head>');
70
- } else if (modifiedHtml.includes('</HEAD>')) {
71
- modifiedHtml = modifiedHtml.replace('</HEAD>', cssFixTag + '</HEAD>');
70
+ if (!modifiedHtml.includes('object-position:center center')) {
71
+ if (modifiedHtml.includes('</head>')) {
72
+ modifiedHtml = modifiedHtml.replace('</head>', cssFixTag + '</head>');
73
+ } else if (modifiedHtml.includes('</HEAD>')) {
74
+ modifiedHtml = modifiedHtml.replace('</HEAD>', cssFixTag + '</HEAD>');
75
+ }
72
76
  }
73
77
 
74
78
  // Rewrite Interactive Control hostAddress to SW-interceptable path
@@ -0,0 +1,297 @@
1
+ /**
2
+ * Widget HTML caching tests
3
+ *
4
+ * Tests the URL rewriting and base tag injection that cacheWidgetHtml performs.
5
+ * These are critical for widget rendering — CMS provides HTML with absolute
6
+ * signed URLs that must be rewritten to local cache paths.
7
+ *
8
+ * Real-world scenario (RSS ticker in layout 472, region 223, widget 193):
9
+ * 1. CMS getResource returns HTML with signed URLs for bundle.min.js, fonts.css
10
+ * 2. SW download manager may cache this raw HTML before the main thread processes it
11
+ * 3. cacheWidgetHtml must rewrite CMS URLs → /cache/static/ and fetch the resources
12
+ * 4. Widget iframe loads, SW serves bundle.min.js and fonts.css from static cache
13
+ * 5. bundle.min.js runs getWidgetData → $.ajax("193.json") → SW serves from media cache
14
+ */
15
+
16
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
17
+ import { cacheWidgetHtml } from './widget-html.js';
18
+
19
+ // --- Realistic CMS HTML templates (based on actual CMS output) ---
20
+
21
+ const CMS_BASE = 'https://displays.superpantalles.com';
22
+ const SIGNED_PARAMS = 'displayId=152&type=P&itemId=1&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Date=20260222T000000Z&X-Amz-Expires=1771803983&X-Amz-SignedHeaders=host&X-Amz-Signature=abc123';
23
+
24
+ /**
25
+ * Simulates actual CMS RSS ticker widget HTML (layout 472, region 223, widget 193).
26
+ * The CMS generates this via getResource — it includes:
27
+ * - Signed URLs for bundle.min.js and fonts.css (must be rewritten)
28
+ * - Relative data URL "193.json" resolved via <base> tag
29
+ * - Interactive Control (xiboIC) with hostAddress pointing at CMS
30
+ */
31
+ function makeRssTickerHtml() {
32
+ return `<!DOCTYPE html>
33
+ <html>
34
+ <head>
35
+ <meta charset="utf-8">
36
+ <title>RSS Ticker</title>
37
+ <script src="${CMS_BASE}/pwa/file?file=bundle.min.js&fileType=bundle&${SIGNED_PARAMS}"></script>
38
+ <link rel="stylesheet" href="${CMS_BASE}/pwa/file?file=fonts.css&fileType=fontCss&${SIGNED_PARAMS}">
39
+ <style>.rss-item { padding: 10px; }</style>
40
+ </head>
41
+ <body>
42
+ <div id="content">
43
+ <script>
44
+ var currentWidget = { url: "193.json", duration: 5 };
45
+ var options = {hostAddress: "${CMS_BASE}"};
46
+ xiboIC.init(options);
47
+ function getWidgetData() {
48
+ $.ajax({ url: currentWidget.url, dataType: "json" });
49
+ }
50
+ </script>
51
+ </div>
52
+ </body>
53
+ </html>`;
54
+ }
55
+
56
+ /** PDF widget (layout 472, region 221, widget 190) — simpler, no data URL */
57
+ function makePdfWidgetHtml() {
58
+ return `<!DOCTYPE html>
59
+ <html>
60
+ <head>
61
+ <meta charset="utf-8">
62
+ <script src="${CMS_BASE}/pwa/file?file=bundle.min.js&fileType=bundle&${SIGNED_PARAMS}"></script>
63
+ <link rel="stylesheet" href="${CMS_BASE}/pwa/file?file=fonts.css&fileType=fontCss&${SIGNED_PARAMS}"></link>
64
+ </head>
65
+ <body>
66
+ <object data="11.pdf" type="application/pdf" width="100%" height="100%"></object>
67
+ </body>
68
+ </html>`;
69
+ }
70
+
71
+ /** Clock widget — uses xmds.php endpoint (older CMS versions) */
72
+ function makeClockWidgetHtml() {
73
+ return `<html>
74
+ <head>
75
+ <script src="${CMS_BASE}/xmds.php?file=bundle.min.js&${SIGNED_PARAMS}"></script>
76
+ <link rel="stylesheet" href="${CMS_BASE}/xmds.php?file=fonts.css&${SIGNED_PARAMS}">
77
+ </head>
78
+ <body><div class="clock"></div></body>
79
+ </html>`;
80
+ }
81
+
82
+ // --- Mock Cache API ---
83
+
84
+ const cacheStore = new Map();
85
+ const mockCache = {
86
+ put: vi.fn(async (url, response) => {
87
+ const key = typeof url === 'string' ? url : url.toString();
88
+ const text = await response.clone().text();
89
+ cacheStore.set(key, text);
90
+ }),
91
+ match: vi.fn(async (key) => {
92
+ const url = typeof key === 'string' ? key : (key.url || key.toString());
93
+ const text = cacheStore.get(url);
94
+ return text ? new Response(text) : undefined;
95
+ }),
96
+ };
97
+
98
+ const fetchedUrls = [];
99
+ global.fetch = vi.fn(async (url) => {
100
+ fetchedUrls.push(url);
101
+ if (url.includes('bundle.min.js')) {
102
+ return new Response('var xiboIC = { init: function(){} };', { status: 200 });
103
+ }
104
+ if (url.includes('fonts.css')) {
105
+ return new Response(`@font-face { font-family: "Poppins"; src: url("${CMS_BASE}/pwa/file?file=Poppins-Regular.ttf&${SIGNED_PARAMS}"); }`, { status: 200 });
106
+ }
107
+ if (url.includes('.ttf') || url.includes('.woff')) {
108
+ return new Response(new Blob([new Uint8Array(100)]), { status: 200 });
109
+ }
110
+ return new Response('', { status: 404 });
111
+ });
112
+
113
+ global.caches = {
114
+ open: vi.fn(async () => mockCache),
115
+ };
116
+
117
+ // --- Tests ---
118
+
119
+ describe('cacheWidgetHtml', () => {
120
+ beforeEach(() => {
121
+ cacheStore.clear();
122
+ vi.clearAllMocks();
123
+ fetchedUrls.length = 0;
124
+ });
125
+
126
+ // --- Base tag injection ---
127
+
128
+ describe('base tag injection', () => {
129
+ it('injects <base> tag after <head>', async () => {
130
+ const html = '<html><head><title>Widget</title></head><body>content</body></html>';
131
+ await cacheWidgetHtml('472', '223', '193', html);
132
+
133
+ const stored = cacheStore.values().next().value;
134
+ expect(stored).toContain('<base href=');
135
+ expect(stored).toContain('/cache/media/">');
136
+ });
137
+
138
+ it('injects <base> tag when no <head> tag exists', async () => {
139
+ const html = '<div>no head tag</div>';
140
+ await cacheWidgetHtml('472', '223', '193', html);
141
+
142
+ const stored = cacheStore.values().next().value;
143
+ expect(stored).toContain('<base href=');
144
+ });
145
+ });
146
+
147
+ // --- RSS ticker: layout 472, region 223, widget 193 ---
148
+
149
+ describe('RSS ticker (layout 472 / region 223 / widget 193)', () => {
150
+ it('rewrites bundle.min.js and fonts.css signed URLs', async () => {
151
+ await cacheWidgetHtml('472', '223', '193', makeRssTickerHtml());
152
+
153
+ const stored = cacheStore.values().next().value;
154
+ expect(stored).not.toContain(CMS_BASE);
155
+ expect(stored).toContain('/cache/static/bundle.min.js');
156
+ expect(stored).toContain('/cache/static/fonts.css');
157
+ });
158
+
159
+ it('preserves the data URL (193.json) for SW interception', async () => {
160
+ await cacheWidgetHtml('472', '223', '193', makeRssTickerHtml());
161
+
162
+ const stored = cacheStore.values().next().value;
163
+ // 193.json is relative — resolved by <base> tag, not rewritten
164
+ expect(stored).toContain('"193.json"');
165
+ });
166
+
167
+ it('rewrites xiboIC hostAddress from CMS to local path', async () => {
168
+ await cacheWidgetHtml('472', '223', '193', makeRssTickerHtml());
169
+
170
+ const stored = cacheStore.values().next().value;
171
+ expect(stored).not.toContain(`hostAddress: "${CMS_BASE}"`);
172
+ expect(stored).toContain('/ic"');
173
+ });
174
+
175
+ it('fetches bundle.min.js and fonts.css from CMS for local caching', async () => {
176
+ await cacheWidgetHtml('472', '223', '193', makeRssTickerHtml());
177
+
178
+ const bundleFetched = fetchedUrls.some(u => u.includes('bundle.min.js'));
179
+ const fontsFetched = fetchedUrls.some(u => u.includes('fonts.css'));
180
+ expect(bundleFetched).toBe(true);
181
+ expect(fontsFetched).toBe(true);
182
+ });
183
+
184
+ it('stores processed HTML at correct cache key', async () => {
185
+ await cacheWidgetHtml('472', '223', '193', makeRssTickerHtml());
186
+
187
+ const keys = [...cacheStore.keys()];
188
+ const widgetKey = keys.find(k => k.includes('/cache/widget/472/223/193'));
189
+ expect(widgetKey).toBeTruthy();
190
+ });
191
+
192
+ it('caches font files referenced in fonts.css', async () => {
193
+ await cacheWidgetHtml('472', '223', '193', makeRssTickerHtml());
194
+
195
+ // fonts.css contains url("...Poppins-Regular.ttf") which should be fetched
196
+ const fontFetched = fetchedUrls.some(u => u.includes('Poppins-Regular.ttf'));
197
+ expect(fontFetched).toBe(true);
198
+ });
199
+ });
200
+
201
+ // --- PDF widget: layout 472, region 221, widget 190 ---
202
+
203
+ describe('PDF widget (layout 472 / region 221 / widget 190)', () => {
204
+ it('rewrites CMS URLs and preserves relative PDF path', async () => {
205
+ await cacheWidgetHtml('472', '221', '190', makePdfWidgetHtml());
206
+
207
+ const stored = cacheStore.values().next().value;
208
+ expect(stored).not.toContain(CMS_BASE);
209
+ expect(stored).toContain('/cache/static/bundle.min.js');
210
+ // PDF data attribute "11.pdf" is relative — resolved by <base> tag
211
+ expect(stored).toContain('"11.pdf"');
212
+ });
213
+
214
+ it('stores at correct widget cache key with layout/region/media IDs', async () => {
215
+ await cacheWidgetHtml('472', '221', '190', makePdfWidgetHtml());
216
+
217
+ const keys = [...cacheStore.keys()];
218
+ expect(keys.some(k => k.includes('/cache/widget/472/221/190'))).toBe(true);
219
+ });
220
+ });
221
+
222
+ // --- Clock widget: xmds.php endpoint (legacy) ---
223
+
224
+ describe('clock widget with xmds.php URLs', () => {
225
+ it('rewrites xmds.php signed URLs to local cache paths', async () => {
226
+ await cacheWidgetHtml('1', '1', '1', makeClockWidgetHtml());
227
+
228
+ const stored = cacheStore.values().next().value;
229
+ expect(stored).not.toContain('xmds.php');
230
+ expect(stored).toContain('/cache/static/bundle.min.js');
231
+ expect(stored).toContain('/cache/static/fonts.css');
232
+ });
233
+ });
234
+
235
+ // --- Idempotency (regression: duplicate base/style tags) ---
236
+
237
+ describe('idempotency', () => {
238
+ it('does not add duplicate <base> tags on re-processing', async () => {
239
+ await cacheWidgetHtml('472', '223', '193', makeRssTickerHtml());
240
+ const firstPass = cacheStore.values().next().value;
241
+
242
+ await cacheWidgetHtml('472', '223', '193', firstPass);
243
+ const secondPass = cacheStore.values().next().value;
244
+
245
+ expect(secondPass).toBe(firstPass);
246
+ });
247
+
248
+ it('does not add duplicate CSS fix tags on re-processing', async () => {
249
+ const html = '<html><head></head><body></body></html>';
250
+ await cacheWidgetHtml('472', '223', '193', html);
251
+ const firstPass = cacheStore.values().next().value;
252
+
253
+ await cacheWidgetHtml('472', '223', '193', firstPass);
254
+ const secondPass = cacheStore.values().next().value;
255
+
256
+ const baseCount = (secondPass.match(/<base /g) || []).length;
257
+ const styleCount = (secondPass.match(/object-position:center center/g) || []).length;
258
+ expect(baseCount).toBe(1);
259
+ expect(styleCount).toBe(1);
260
+ });
261
+ });
262
+
263
+ // --- SW pre-cache scenario (the bug we fixed) ---
264
+
265
+ describe('SW pre-cached raw HTML (regression)', () => {
266
+ it('processes raw HTML that SW cached without rewriting', async () => {
267
+ // Simulate: SW downloads getResource HTML and caches it raw
268
+ const rawCmsHtml = makeRssTickerHtml();
269
+
270
+ // Main thread finds it in cache and re-processes
271
+ await cacheWidgetHtml('472', '223', '193', rawCmsHtml);
272
+
273
+ const stored = cacheStore.values().next().value;
274
+ // CMS URLs must be rewritten even though HTML came from cache
275
+ expect(stored).not.toContain(CMS_BASE);
276
+ expect(stored).toContain('/cache/static/bundle.min.js');
277
+ expect(stored).toContain('/cache/static/fonts.css');
278
+ expect(stored).toContain('<base href=');
279
+ });
280
+
281
+ it('handles different widgets in different regions of same layout', async () => {
282
+ // Layout 472 has region 221 (PDF) and region 223 (RSS ticker)
283
+ await cacheWidgetHtml('472', '221', '190', makePdfWidgetHtml());
284
+ await cacheWidgetHtml('472', '223', '193', makeRssTickerHtml());
285
+
286
+ const keys = [...cacheStore.keys()];
287
+ const pdfKey = keys.find(k => k.includes('/cache/widget/472/221/190'));
288
+ const rssKey = keys.find(k => k.includes('/cache/widget/472/223/193'));
289
+ expect(pdfKey).toBeTruthy();
290
+ expect(rssKey).toBeTruthy();
291
+
292
+ // Both should have CMS URLs rewritten
293
+ expect(cacheStore.get(pdfKey)).not.toContain(CMS_BASE);
294
+ expect(cacheStore.get(rssKey)).not.toContain(CMS_BASE);
295
+ });
296
+ });
297
+ });