mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 22:03:13 -06:00
Both players carried calls that compile, read correctly, and are documented to
do something else. Verified line by line against docs.brightsign.biz and
Samsung's Smart TV Filesystem reference; every fix below cites the doc that
proves it, and the linter has been extended so each one fails here next time.
TIZEN
The offline media cache could never have worked on a panel. Its adapter used
the deprecated Filesystem API in three ways the IDL rules out:
`tizen.filesystem.resolve()` is declared `void`, so `var dir = resolve(...)`
was always undefined and MediaCache.create() returned null on every panel in
the fleet; `openStream()` is asynchronous, so appendPart read `written` before
any callback could run and returned 0 forever; and `moveTo()` is asynchronous,
belongs on the parent directory, and takes (origin, destination) — it was
called on a file handle with the arguments transposed. Rewritten against the
5.0 synchronous FileSystemManager, which is genuinely synchronous and is what
the decision layer needs. A Tizen 4.0 panel now reports available() false
instead of being handed a cache that silently writes nothing.
Writes are now POSITIONED rather than appended at EOF. Power cut between a
write and the index save — the exact event this feature exists for — replayed
the last chunk, and an append landed it twice: a silently corrupt video that
promoted as complete. A positioned write makes the replay idempotent.
Three decision-layer bugs alongside it: a 206 with no readable Content-Range
fell back to Content-Length, which is the CHUNK length, so the first megabyte
of a 50MB video promoted as a complete 1MB asset; a 200 whose body was short of
its own Content-Length returned 'done'; and a server with no ETag or
Last-Modified was re-fetched from zero on every sweep, forever, on precisely
the marginal link this feature exists to be gentle on.
The volume slider was dead. The dashboard sends `{level: 0..1}`; this handler
read `value`/`volume` as a 0..100 percentage, so it matched nothing and logged
"no usable value in payload" on every slider move while the panel declared
audio.volume as working. Both halves had to move together — taking `level` as a
percentage turns 50% into 0.5%, which is inaudible and looks like a fix.
Verified by driving the real handler in headless Chrome, before and after.
BRIGHTSIGN
FindMemberFunction is documented as available only when
roDeviceInfo.HasFeature("FindMemberFunction") is true. It was called
unguarded from the capability probe and from host telemetry — both on the event
loop — so a player without the feature would have died within a minute of boot
and taken the display with it. The guard needed guarding.
The boot report never arrived. The host flushed its buffer straight after
Show(), before the page had been fetched, while the player correctly waits for
its socket before subscribing. Between two correct decisions every boot line
fell on the floor. The host now waits for the page's `probe`, and the bridge
buffers until a consumer registers.
offline.cache was claimed on `navigator.serviceWorker` being present. It is
present on a BrightSign widget and will not run a worker — our XT245 passes the
check and never fetches sw.js. Now requires a controller, matching the web
player. Removed from the brightsign baseline for the same reason.
display.resolution was claimed on @brightsign/videooutput, which has no
setMode at all; mode setting lives on @brightsign/videomodeconfiguration.
roStorageHotplug.GetStorages() answers "USB1:/" while GetStorageStatus() is
documented as unreliable for "USBn:" — feeding one to the other re-created the
bug the static fallback list exists to avoid, and only on the OS versions that
have the enumerator.
dual/clone output mode put two full-screen widgets on output ONE, on top of
each other, while output two stayed dark: roHtmlWidget has no output selector,
and a second output is addressed by its display_x/display_y within the
SetScreenModes canvas. Now positioned properly, or refused with a reason.
Also: a manifest missing sha256/size passed `invalid` into typed parameters, a
runtime error at the call the comment already described and did not prevent;
storage_quota was a string where the docs say use a double; and the comment
crediting brightsign_js_objects_enabled with gating require("@brightsign/*")
named the wrong flag — it is nodejs_enabled.
TESTS
The two suites that mattered most were the ones that passed while the code was
broken, because they asserted on source text or against a fake more correct
than the platform. The host-diagnostics regexes now execute the bridge; the
media-cache suite now drives the shipped adapter against a fake tizen.filesystem
written from Samsung's IDL. Ten new rules in the BrightScript linter, each
verified to fail against the source it was written to reject.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
490 lines
23 KiB
JavaScript
490 lines
23 KiB
JavaScript
'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');
|
|
});
|