'use strict'; // Tizen was the one player that cached nothing but the playlist: a panel came back from a reboot // knowing exactly what to show and fetched every frame of it from a server that was not there. // tizen/js/media-cache.js fixes that, and none of it can be exercised on hardware without a TV — so // the decisions are all in injected-backend form and driven here against a fake one. // // The backend (resolve wgt-private, append to a stream, turn a file into a URI) is the only part // that needs a device, and it is the part with no logic in it. const { test } = require('node:test'); const assert = require('node:assert/strict'); const path = require('node:path'); const MediaCache = require(path.join(__dirname, '..', '..', 'tizen', 'js', 'media-cache.js')); const CHUNK = MediaCache.CHUNK_BYTES; /** * A fake TV. `failEvery` drops every Nth request — a marginal link, which is the condition the * whole resumable design exists for. */ function fakeBackend(asset, opts = {}) { const b = { asset, // { bytes: number[], etag, total } files: new Map(), // name -> number[] index: {}, requests: 0, failEvery: opts.failEvery || 0, rangeSupport: opts.rangeSupport !== false, available: () => true, loadIndex: () => b.index, saveIndex: (i) => { b.index = JSON.parse(JSON.stringify(i)); }, httpRange(url, start, end, validator) { b.requests++; if (b.failEvery && b.requests % b.failEvery === 0) throw new Error('link dropped'); const body = b.asset.bytes; if (!b.rangeSupport) { return { status: 200, start: 0, total: body.length, validator: b.asset.etag, body: body.slice() }; } // If-Range with a stale validator: the server sends the WHOLE asset, which is the signal to // start over rather than append a tail from a different file. if (validator && validator !== b.asset.etag) { return { status: 200, start: 0, total: body.length, validator: b.asset.etag, body: body.slice() }; } if (start >= body.length) return { status: 416, start, total: body.length, validator: b.asset.etag, body: null }; const stop = Math.min(end, body.length - 1); return { status: 206, start, total: body.length, validator: b.asset.etag, body: body.slice(start, stop + 1) }; }, appendPart(contentId, body, offset) { const name = contentId + '.part'; const cur = b.files.get(name) || []; if (offset === 0) b.files.set(name, body.slice()); else { if (cur.length !== offset) return 0; // a real append cannot write into a hole b.files.set(name, cur.concat(body)); } return body.length; }, promotePart(contentId) { const part = b.files.get(contentId + '.part'); if (!part) return null; b.files.set(contentId, part); b.files.delete(contentId + '.part'); return { path: '/wgt-private/' + contentId, uri: 'file:///wgt-private/' + contentId }; }, remove(contentId) { b.files.delete(contentId); b.files.delete(contentId + '.part'); } }; return b; } const asset = (n, fill, etag = '"v1"') => ({ bytes: new Array(n).fill(fill), etag }); const urlFor = (it) => 'http://s/api/content/' + it.content_id + '/file?rev=' + it.content_rev; test('THE GAP: media is cached at all, and resolves to a local file the player can open', async () => { const b = fakeBackend(asset(CHUNK, 7)); const mc = new MediaCache(b); assert.equal(mc.localUrl('c1', 5), null, 'nothing cached yet'); await mc.sync([{ content_id: 'c1', content_rev: 5 }], urlFor); assert.equal(mc.localUrl('c1', 5), 'file:///wgt-private/c1'); assert.deepEqual(b.files.get('c1'), new Array(CHUNK).fill(7)); }); test('a link that drops every other request still completes the asset', async () => { // Without accumulation this never finishes: each attempt restarts from zero, so an asset larger // than one uninterrupted transfer is never cached and the panel has nothing to fall back on. const b = fakeBackend(asset(CHUNK * 4, 3), { failEvery: 2 }); const mc = new MediaCache(b); const items = [{ content_id: 'c1', content_rev: 5 }]; for (let pass = 0; pass < 30 && !mc.localUrl('c1', 5); pass++) await mc.sync(items, urlFor); assert.ok(mc.localUrl('c1', 5), 'the asset must eventually be cached'); assert.equal(b.files.get('c1').length, CHUNK * 4); }); test('a partial is never promoted — an incomplete file is not offered to the player', async () => { const b = fakeBackend(asset(CHUNK * 3, 9), { failEvery: 1 }); // every request fails const mc = new MediaCache(b); await mc.sync([{ content_id: 'c1', content_rev: 5 }], urlFor); assert.equal(mc.localUrl('c1', 5), null); assert.equal(b.files.get('c1'), undefined, 'no whole file exists'); }); test('progress accumulates across passes rather than restarting', async () => { const b = fakeBackend(asset(CHUNK * 3, 4)); const mc = new MediaCache(b); // One step at a time, so the resume offset is observable. assert.equal(await mc.fetchStep('c1', 5, 'http://s/x'), 'progress'); assert.equal(mc.index.c1.bytes, CHUNK); assert.equal(await mc.fetchStep('c1', 5, 'http://s/x'), 'progress'); assert.equal(mc.index.c1.bytes, CHUNK * 2, 'the second attempt appended, it did not restart'); assert.equal(await mc.fetchStep('c1', 5, 'http://s/x'), 'done'); }); test('THE UPDATE HALF: a replaced asset is a miss, and the old bytes are deleted', async () => { // The trap that offline caching creates. Same content id, same URL path, different bytes — a // cache that cannot tell would keep playing last month's video forever. const b = fakeBackend(asset(CHUNK, 1)); const mc = new MediaCache(b); await mc.sync([{ content_id: 'c1', content_rev: 5 }], urlFor); assert.ok(mc.localUrl('c1', 5)); b.asset = asset(CHUNK, 2, '"v2"'); assert.equal(mc.localUrl('c1', 6), null, 'a new revision must not match the cached copy'); await mc.sync([{ content_id: 'c1', content_rev: 6 }], urlFor); assert.ok(mc.localUrl('c1', 6), 'the new revision is cached'); assert.deepEqual(b.files.get('c1'), new Array(CHUNK).fill(2), 'and it is the NEW bytes'); }); test('an asset replaced MID-transfer is discarded, not spliced', async () => { // Appending the tail of the new asset to the head of the old one produces a file of exactly the // right length that is wrong throughout — it would pass every completeness check there is. const b = fakeBackend(asset(CHUNK * 4, 0x61)); const mc = new MediaCache(b); assert.equal(await mc.fetchStep('c1', 5, 'http://s/x'), 'progress'); assert.equal(mc.index.c1.bytes, CHUNK); b.asset = asset(CHUNK * 4, 0x62, '"v2"'); // replaced underneath us, same revision claim assert.equal(await mc.fetchStep('c1', 5, 'http://s/x'), 'done', 'a 200 carries the whole new asset'); const cached = b.files.get('c1'); assert.equal(cached.length, CHUNK * 4); assert.ok(cached.every((v) => v === 0x62), 'not one byte of the superseded asset may survive'); }); test('items dropped from the playlist have their bytes deleted', async () => { // Otherwise the cache only grows, and the failure eventually lands as a write error on whatever // happens to be downloading at the time. const b = fakeBackend(asset(CHUNK, 1)); const mc = new MediaCache(b); await mc.sync([{ content_id: 'c1', content_rev: 5 }], urlFor); assert.ok(b.files.get('c1')); await mc.sync([{ content_id: 'c2', content_rev: 1 }], urlFor); assert.equal(b.files.get('c1'), undefined, 'an unreferenced asset must not linger'); assert.equal(mc.index.c1, undefined); }); test('a cached asset costs no requests on later sweeps', async () => { const b = fakeBackend(asset(CHUNK, 1)); const mc = new MediaCache(b); const items = [{ content_id: 'c1', content_rev: 5 }]; await mc.sync(items, urlFor); const after = b.requests; await mc.sync(items, urlFor); await mc.sync(items, urlFor); assert.equal(b.requests, after, 'a cached asset must not be re-fetched every 60s'); }); test('remote-url items are never downloaded', async () => { const b = fakeBackend(asset(CHUNK, 1)); const mc = new MediaCache(b); await mc.sync([{ content_id: 'c1', content_rev: 5, remote_url: 'https://example.com/live' }], urlFor); assert.equal(b.requests, 0); }); test('a server with no range support still caches the asset whole', async () => { const b = fakeBackend(asset(CHUNK * 2, 6), { rangeSupport: false }); const mc = new MediaCache(b); await mc.sync([{ content_id: 'c1', content_rev: 5 }], urlFor); assert.ok(mc.localUrl('c1', 5)); assert.equal(b.files.get('c1').length, CHUNK * 2); }); test('a 416 discards a partial that is longer than the asset', async () => { const b = fakeBackend(asset(CHUNK, 1)); const mc = new MediaCache(b); // Pretend a previous life left a longer partial behind. mc.index.c1 = { rev: 5, bytes: CHUNK * 9, total: CHUNK * 9, validator: '"v1"', complete: false }; b.files.set('c1.part', new Array(CHUNK * 9).fill(0)); assert.equal(await mc.fetchStep('c1', 5, 'http://s/x'), 'restart'); assert.equal(b.files.get('c1.part'), undefined, 'the stale partial must be gone'); }); test('the index survives a restart — progress is not lost with the process', async () => { // A signage panel reboots. If the index lived only in memory, every reboot during a slow // download would throw the transfer away, which on a bad link means it never finishes. const b = fakeBackend(asset(CHUNK * 3, 8)); const first = new MediaCache(b); assert.equal(await first.fetchStep('c1', 5, 'http://s/x'), 'progress'); const reborn = new MediaCache(b); // same backend = same persisted index + files assert.equal(reborn.index.c1.bytes, CHUNK, 'the resume offset survived'); assert.equal(await reborn.fetchStep('c1', 5, 'http://s/x'), 'progress'); assert.equal(reborn.index.c1.bytes, CHUNK * 2); }); test('an item with no revision still caches, and matches a copy stored without one', async () => { // Older servers do not send content_rev. Treating absent-vs-absent as a mismatch would re-download // the entire playlist on every sweep. const b = fakeBackend(asset(CHUNK, 1)); const mc = new MediaCache(b); await mc.sync([{ content_id: 'c1' }], () => 'http://s/x'); assert.ok(mc.localUrl('c1', undefined)); const after = b.requests; await mc.sync([{ content_id: 'c1' }], () => 'http://s/x'); assert.equal(b.requests, after); }); /* ================================================================================================ * THE ADAPTER ITSELF. * * Everything above drives the DECISION layer against a fake backend, which is the right way to test * decisions — and it is also how the real adapter shipped unable to write a single byte without one * assertion noticing. The fake was correct; the platform calls underneath it were not: * * - `var dir = tizen.filesystem.resolve(...)` — resolve() is declared `void`. dir was undefined, * available() answered false, and MediaCache.create() returned null on every panel in the * fleet. The offline cache did not exist. It failed CLOSED, which is the only reason this never * showed up as corruption: the capability was correctly withheld and the feature was absent. * - `f.openStream('a', cb)` — asynchronous. `written` was read on the next line, before any * callback could have run, so appendPart returned 0 forever. * - `part.moveTo(destPath, name, ...)` — asynchronous, belongs on the parent DIRECTORY, and takes * (originFullPath, destinationFullPath). Called on a file handle with the arguments transposed: * three documented errors in one call, each of which alone raises IOError. * * So the adapter is exercised here too, against a fake `tizen.filesystem` written from Samsung's * published IDL rather than from our code — including the deprecated calls, modelled with their * documented (useless-to-us) semantics, so reaching for them again fails here instead of in a shop. * ============================================================================================== */ /** A fake Samsung TV, per developer.samsung.com/smarttv Filesystem API. */ function fakeTizen({ version = 5.0 } = {}) { const files = new Map(); // full virtual path -> number[] const calls = []; function makeFile(p) { return { fullPath: p, isDirectory: false, get fileSize() { return (files.get(p) || []).length; }, toURI: () => 'file:///opt/usr/apps/priv/' + p, // "This operation is performed asynchronously." openStream(mode, onsuccess) { calls.push('openStream'); const stream = { writeBytes(bytes) { const cur = files.get(p) || []; files.set(p, mode === 'a' ? cur.concat(Array.from(bytes)) : Array.from(bytes)); }, close() {}, }; if (onsuccess) setTimeout(() => onsuccess(stream), 0); }, // "IOError - If the File in which the moveTo() method is invoked is a file (not a directory)" moveTo() { const e = new Error('IOError'); e.name = 'IOError'; throw e; }, }; } // --- deprecated 1.0 surface, with its REAL semantics --------------------------------------- const deprecated = { // "void resolve(...)" — the File arrives only via the callback; the return value is undefined. resolve(location, onsuccess) { calls.push('resolve'); const dir = { isDirectory: true, fullPath: location, resolve(name) { const p = location + '/' + name; if (!files.has(p)) { const e = new Error('NotFoundError'); e.name = 'NotFoundError'; throw e; } return makeFile(p); }, createFile(name) { const p = location + '/' + name; files.set(p, []); return makeFile(p); }, deleteFile(p, ok) { files.delete(p); if (ok) setTimeout(ok, 0); }, moveTo(origin, dest, overwrite, ok) { if (!files.has(origin)) { const e = new Error('NotFoundError'); e.name = 'NotFoundError'; throw e; } files.set(dest, files.get(origin)); files.delete(origin); if (ok) setTimeout(ok, 0); }, }; if (onsuccess) setTimeout(() => onsuccess(dir), 0); return undefined; }, }; if (version < 5) return { filesystem: deprecated, __files: files, __calls: calls }; // --- 5.0 synchronous FileSystemManager ----------------------------------------------------- const modern = Object.assign({}, deprecated, { pathExists(p) { return files.has(p); }, toURI(p) { return 'file:///opt/usr/apps/priv/' + p; }, deleteFile(p, ok) { files.delete(p); if (ok) setTimeout(ok, 0); }, // "FileHandle openFile(Path path, FileMode openMode, optional boolean makeParents)" — RETURNS. openFile(p, mode, makeParents) { calls.push('openFile:' + mode); if (!files.has(p)) { if (mode === 'r') { const e = new Error('NotFoundError'); e.name = 'NotFoundError'; throw e; } files.set(p, []); } if (mode === 'w') files.set(p, []); // 'w' truncates let pos = 0; return { path: p, seek(offset) { pos = offset; return this; }, writeData(u8) { const cur = files.get(p).slice(); // A positioned write: pad any gap, then overwrite in place — what seek+write really does. while (cur.length < pos) cur.push(0); for (let i = 0; i < u8.length; i++) cur[pos + i] = u8[i]; pos += u8.length; files.set(p, cur); }, flush() {}, close() {}, }; }, }); return { filesystem: modern, __files: files, __calls: calls }; } function withTizen(fake, fn) { const hadT = 'tizen' in global; const oldT = global.tizen; const hadL = 'localStorage' in global; const oldL = global.localStorage; const store = new Map(); global.tizen = fake; global.localStorage = { getItem: (k) => (store.has(k) ? store.get(k) : null), setItem: (k, v) => store.set(k, v) }; try { return fn(); } finally { if (hadT) global.tizen = oldT; else delete global.tizen; if (hadL) global.localStorage = oldL; else delete global.localStorage; } } const MEDIA_DIR = 'wgt-private/st-media/'; test('ADAPTER: a Tizen 5.0 panel really can write, resume and hand back a URI', () => { const fake = fakeTizen({ version: 5.0 }); withTizen(fake, () => { const b = MediaCache.tizenBackend(); assert.equal(b.available(), true, 'the 5.0 synchronous filesystem must be usable'); // The contract the decision layer depends on: appendPart returns the number of bytes WRITTEN, // synchronously. Returning 0 here is what made every download stall forever. assert.equal(b.appendPart('c1', [1, 2, 3], 0), 3, 'a first write must report what it wrote'); assert.equal(b.appendPart('c1', [4, 5], 3), 2, 'and so must a resumed one'); assert.deepEqual(fake.__files.get(MEDIA_DIR + 'c1'), [1, 2, 3, 4, 5]); const p = b.promotePart('c1'); assert.ok(p && p.uri.startsWith('file://'), 'a finished asset must resolve to a playable URI'); b.remove('c1'); assert.equal(fake.__files.has(MEDIA_DIR + 'c1'), false); assert.equal(b.promotePart('c1'), null, 'and must not claim a file that is gone'); }); }); test('ADAPTER: writes are POSITIONED, so replaying a chunk cannot corrupt the file', () => { // The crash window is real and is exactly the event this feature exists for: power is cut between // the write and the index save, so the next boot replays the last chunk. An append lands it a // second time and the panel promotes a silently corrupt video that plays as garbage. A positioned // write overwrites the same bytes with the same bytes, and the window stops mattering. const fake = fakeTizen({ version: 5.0 }); withTizen(fake, () => { const b = MediaCache.tizenBackend(); b.appendPart('c1', [1, 2, 3, 4], 0); b.appendPart('c1', [5, 6], 4); b.appendPart('c1', [5, 6], 4); // the replay assert.deepEqual(fake.__files.get(MEDIA_DIR + 'c1'), [1, 2, 3, 4, 5, 6], 'a replayed chunk must overwrite, not append'); }); }); test('ADAPTER: offset 0 truncates, so a lost index restarts cleanly instead of prepending', () => { const fake = fakeTizen({ version: 5.0 }); withTizen(fake, () => { const b = MediaCache.tizenBackend(); b.appendPart('c1', [9, 9, 9, 9, 9, 9], 0); b.appendPart('c1', [1, 2], 0); assert.deepEqual(fake.__files.get(MEDIA_DIR + 'c1'), [1, 2]); }); }); test('ADAPTER: a panel without the synchronous filesystem says so instead of writing nothing', () => { // Tizen 4.0 (2018 models). The deprecated resolve()/openStream() pair cannot serve a synchronous // backend at all, and a cache that reports itself available and then silently writes zero bytes is // worse than no cache — capabilities.js declares offline.cache on the strength of create(). for (const fake of [fakeTizen({ version: 4.0 }), { filesystem: null }, {}]) { withTizen(fake, () => { const b = MediaCache.tizenBackend(); assert.equal(b.available(), false); assert.equal(b.appendPart('c1', [1, 2, 3], 0), 0, 'and must not pretend to have written'); assert.equal(b.promotePart('c1'), null); assert.equal(MediaCache.create(), null, 'so no cache is created, and no capability claimed'); }); } }); test('ADAPTER: no deprecated asynchronous call is on the write path', () => { // Belt and braces against the exact regression: resolve(), openStream() and moveTo() all hand // their result to a callback, so any backend built on them returns before it has done anything. const fake = fakeTizen({ version: 5.0 }); withTizen(fake, () => { const b = MediaCache.tizenBackend(); b.appendPart('c1', [1], 0); b.promotePart('c1'); b.remove('c1'); assert.deepEqual(fake.__calls.filter((c) => c === 'resolve' || c === 'openStream'), [], 'resolve() returns void and openStream() is async — neither can serve a synchronous backend'); }); }); test('a 206 with no readable Content-Range is a stall, never a completed asset', async () => { // Content-Length on a 206 is the length of the CHUNK. Trusting it reports the first 1MB of a 50MB // video as a 1MB asset — complete, promoted, and truncated on screen. A proxy that strips // Content-Range, or a CORS context where the header simply is not readable, produces exactly it. const b = fakeBackend(asset(CHUNK * 4, 2)); const mc = new MediaCache(b); // The entry fetchStep would have created before the first request. mc.index.c1 = { rev: 5, bytes: 0, total: 0, validator: null, complete: false, path: null, uri: null }; const verdict = await mc.applyChunk('c1', 5, { status: 206, start: 0, total: 0, validator: '"v1"', body: new Array(CHUNK).fill(2), }); assert.equal(verdict, 'stalled'); assert.equal(mc.localUrl('c1', 5), null, 'a truncated file must never be handed to the player'); }); test('a 200 whose body is short of its own length is progress, not done', async () => { const b = fakeBackend(asset(CHUNK, 1)); const mc = new MediaCache(b); mc.index.c1 = { rev: 5, bytes: 0, total: 0, validator: null, complete: false, path: null, uri: null }; const verdict = await mc.applyChunk('c1', 5, { status: 200, start: 0, total: CHUNK * 3, validator: '"v1"', body: new Array(CHUNK).fill(1), }); assert.equal(verdict, 'progress', "'done' would stop the sweep on an asset with more to fetch"); assert.equal(mc.localUrl('c1', 5), null); }); test('a server with no validator is given up on, not re-fetched forever', async () => { // A big asset from a server that sends neither ETag nor Last-Modified can never be resumed, so the // partial is correctly discarded. Dropping alone meant the next sweep started from zero, pulled // the same megabyte and discarded it again — every sweep, forever, on precisely the marginal link // this whole feature exists to be gentle on. const b = fakeBackend(asset(CHUNK * 4, 3)); b.asset.etag = null; const mc = new MediaCache(b); await mc.sync([{ content_id: 'c1', content_rev: 5 }], urlFor); const afterFirst = b.requests; assert.ok(afterFirst > 0, 'it must at least try once'); await mc.sync([{ content_id: 'c1', content_rev: 5 }], urlFor); await mc.sync([{ content_id: 'c1', content_rev: 5 }], urlFor); assert.equal(b.requests, afterFirst, 'and then stop asking'); assert.equal(mc.localUrl('c1', 5), null, 'while never claiming to hold it'); }); test('...and a new revision clears that verdict rather than blacklisting the asset forever', async () => { const b = fakeBackend(asset(CHUNK * 4, 3)); b.asset.etag = null; const mc = new MediaCache(b); await mc.sync([{ content_id: 'c1', content_rev: 5 }], urlFor); const afterFirst = b.requests; b.asset.etag = '"v2"'; // the server grew validators await mc.sync([{ content_id: 'c1', content_rev: 6 }], urlFor); assert.ok(b.requests > afterFirst, 'a fresh revision must be tried again'); assert.equal(mc.localUrl('c1', 6), 'file:///wgt-private/c1'); }); test('a small asset from a validator-less server still caches — it never needs a resume', async () => { const b = fakeBackend(asset(Math.floor(CHUNK / 2), 4)); b.asset.etag = null; const mc = new MediaCache(b); await mc.sync([{ content_id: 'c1', content_rev: 5 }], urlFor); assert.equal(mc.localUrl('c1', 5), 'file:///wgt-private/c1'); });