Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
"start": "electron .",
"icon:render": "electron scripts/render-icon.cjs",
"tv:icon": "electron scripts/render-tv-icon.cjs",
"test": "npm run test:static && node tests/vidsrc-catalog.test.cjs && node tests/player-readiness.test.cjs && node tests/prune-old-downloads.test.cjs",
"test": "npm run test:static && node tests/vidsrc-catalog.test.cjs && node tests/player-readiness.test.cjs && node tests/prune-old-downloads.test.cjs && node tests/tv-staging.test.cjs",
"test:consumer": "npm test && npm run test:desktop-smoke && npm run test:player",
"test:player": "node tests/run-player-smoke.cjs",
"test:all": "npm run test:consumer && npm run test:tv-smoke",
Expand Down
22 changes: 11 additions & 11 deletions scripts/stage-tv.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ const run = async () => {
fs.writeFileSync(stagedLgManifest, JSON.stringify(lgManifest, null, 2) + '\n');
const stagedSamsungManifest = path.join(buildRoot, 'samsung', 'config.xml');
const samsungManifest = fs.readFileSync(stagedSamsungManifest, 'utf8')
.replace(/(<widget\b[^>]*\bversion=")[^"]+("[^>]*>)/, `$1${version}$2`);
.replace(/(<widget\b[^>]*\bversion=")[^"]+("[^>]*>)/, (_, before, after) => `${before}${version}${after}`);
fs.writeFileSync(stagedSamsungManifest, samsungManifest);

const icon = path.join(root, 'assets', 'tv-icon.png');
Expand Down Expand Up @@ -91,16 +91,16 @@ const run = async () => {
const logoData = `data:image/svg+xml;base64,${fs.readFileSync(path.join(root, 'assets', 'harbor-mark.svg')).toString('base64')}`;
const iconData = `data:image/png;base64,${fs.readFileSync(icon).toString('base64')}`;
const lgHtml = fs.readFileSync(path.join(tvRoot, 'index.html'), 'utf8')
.replace("script-src 'self'; style-src 'self';", `script-src 'nonce-${lgNonce}'; style-src 'nonce-${lgNonce}';`)
.replace('<link rel="icon" href="../assets/icon.png" />', `<link rel="icon" href="${iconData}" />`)
.replace('<link rel="stylesheet" href="tv.css" />', `<style nonce="${lgNonce}">${fs.readFileSync(path.join(tvRoot, 'tv.css'), 'utf8')}</style>`)
.replaceAll('src="../assets/harbor-mark.svg"', `src="${logoData}"`)
.replace('<script src="../shared/series-metadata.js"></script>', `<script nonce="${lgNonce}">${inlineScript(seriesMetadataSource)}</script>`)
.replace('<script src="../shared/live-tv.js"></script>', `<script nonce="${lgNonce}">${inlineScript(liveTvSource)}</script>`)
.replace('<script src="../shared/watch-browse.js"></script>', `<script nonce="${lgNonce}">${inlineScript(watchBrowseSource)}</script>`)
.replace('<script src="config.js"></script>', `<script nonce="${lgNonce}">${inlineScript(tvConfigSource)}</script>`)
.replace('<script src="../node_modules/hls.js/dist/hls.min.js"></script>', `<script nonce="${lgNonce}">${inlineScript(hlsSource)}</script>`)
.replace('<script src="tv.js"></script>', `<script nonce="${lgNonce}">${inlineScript(fs.readFileSync(path.join(tvRoot, 'tv.js'), 'utf8'))}</script>`);
.replace("script-src 'self'; style-src 'self';", () => `script-src 'nonce-${lgNonce}'; style-src 'nonce-${lgNonce}';`)
.replace('<link rel="icon" href="../assets/icon.png" />', () => `<link rel="icon" href="${iconData}" />`)
.replace('<link rel="stylesheet" href="tv.css" />', () => `<style nonce="${lgNonce}">${fs.readFileSync(path.join(tvRoot, 'tv.css'), 'utf8')}</style>`)
.replaceAll('src="../assets/harbor-mark.svg"', () => `src="${logoData}"`)
.replace('<script src="../shared/series-metadata.js"></script>', () => `<script nonce="${lgNonce}">${inlineScript(seriesMetadataSource)}</script>`)
.replace('<script src="../shared/live-tv.js"></script>', () => `<script nonce="${lgNonce}">${inlineScript(liveTvSource)}</script>`)
.replace('<script src="../shared/watch-browse.js"></script>', () => `<script nonce="${lgNonce}">${inlineScript(watchBrowseSource)}</script>`)
.replace('<script src="config.js"></script>', () => `<script nonce="${lgNonce}">${inlineScript(tvConfigSource)}</script>`)
.replace('<script src="../node_modules/hls.js/dist/hls.min.js"></script>', () => `<script nonce="${lgNonce}">${inlineScript(hlsSource)}</script>`)
.replace('<script src="tv.js"></script>', () => `<script nonce="${lgNonce}">${inlineScript(fs.readFileSync(path.join(tvRoot, 'tv.js'), 'utf8'))}</script>`);
fs.writeFileSync(path.join(lgRoot, 'index.html'), lgHtml);
for (const file of ['config.js', 'tv.css', 'tv.js']) {
fs.copyFileSync(path.join(tvRoot, file), path.join(lgRoot, file));
Expand Down
97 changes: 97 additions & 0 deletions tests/tv-staging-smoke.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const net = require('node:net');
const { spawn } = require('node:child_process');
const { pathToFileURL } = require('node:url');
const root = path.resolve(__dirname, '..');
const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
const reservePort = () => new Promise((resolve, reject) => {
const server = net.createServer();
server.once('error', reject);
server.listen(0, '127.0.0.1', () => {
const port = server.address().port;
server.close(error => error ? reject(error) : resolve(port));
});
});

(async () => {
const stagedPage = process.argv[2] ? path.resolve(process.argv[2]) : path.join(root, 'tv/build/lg/index.html');
assert.ok(fs.existsSync(stagedPage), 'Run tv:stage before staged-page smoke verification');
const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-staging-smoke-'));
const port = await reservePort();
const args = [path.join(root, 'tests/tv-harness.cjs'), String(port), '--in-process-gpu', '--disable-gpu-sandbox'];
if (process.platform === 'linux' && process.env.CI) args.unshift('--no-sandbox');
const child = spawn(require('electron'), args, { cwd: root, env: { ...process.env, HARBOR_QA_PROFILE: profile }, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] });
let output = '', socket;
child.stdout.on('data', chunk => { output += chunk; });
child.stderr.on('data', chunk => { output += chunk; });
child.on('error', error => { output += error.message; });
const watchdog = setTimeout(() => child.kill(), 45000);
try {
let target;
for (let i = 0; i < 200; i++) {
try { target = (await fetch(`http://127.0.0.1:${port}/json`).then(r => r.json())).find(t => /\/tv\/index.html/.test(t.url)); } catch {}
if (target || child.exitCode !== null) break;
await delay(100);
}
assert.ok(target, `TV renderer loaded: ${output}`);
socket = new WebSocket(target.webSocketDebuggerUrl);
await new Promise((resolve, reject) => { socket.addEventListener('open', resolve, { once: true }); socket.addEventListener('error', reject, { once: true }); });
const pending = new Map(), exceptions = [];
let id = 0;
socket.addEventListener('message', event => {
const message = JSON.parse(event.data);
if (message.method === 'Runtime.exceptionThrown') exceptions.push(message.params.exceptionDetails.text);
if (pending.has(message.id)) { pending.get(message.id)(message); pending.delete(message.id); }
});
const command = async (method, params = {}) => {
const key = ++id;
const result = new Promise(resolve => pending.set(key, resolve));
socket.send(JSON.stringify({ id: key, method, params }));
let timeout;
const message = await Promise.race([result, new Promise((_, reject) => { timeout = setTimeout(() => reject(Error(`CDP timeout: ${method}`)), 10000); })]).finally(() => clearTimeout(timeout));
if (message.error) throw Error(JSON.stringify(message.error));
return message.result;
};
const evaluate = async expression => {
const result = await command('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true, userGesture: true });
if (result.exceptionDetails) throw Error(JSON.stringify(result.exceptionDetails));
return result.result.value;
};
await command('Runtime.enable');
await command('Page.enable');
await command('Page.addScriptToEvaluateOnNewDocument', { source: `
window.fetch = () => Promise.reject(Error('Offline staging fixture'));
const samples = 8000 * 60, buffer = new ArrayBuffer(44 + samples * 2), view = new DataView(buffer);
const put = (offset, text) => [...text].forEach((c, i) => view.setUint8(offset + i, c.charCodeAt(0)));
put(0, 'RIFF'); view.setUint32(4, 36 + samples * 2, true); put(8, 'WAVE'); put(12, 'fmt '); view.setUint32(16, 16, true); view.setUint16(20, 1, true); view.setUint16(22, 1, true); view.setUint32(24, 8000, true); view.setUint32(28, 16000, true); view.setUint16(32, 2, true); view.setUint16(34, 16, true); put(36, 'data'); view.setUint32(40, samples * 2, true);
window.audioFixture = URL.createObjectURL(new Blob([buffer], { type: 'audio/wav' }));
const source = Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, 'src');
Object.defineProperty(HTMLMediaElement.prototype, 'src', { ...source, set(value) { source.set.call(this, this.tagName === 'AUDIO' ? window.audioFixture : value); } });
` });
exceptions.length = 0;
await command('Page.navigate', { url: pathToFileURL(stagedPage).href });
await delay(700);
const page = await evaluate(`({ hls: typeof Hls, scripts: document.scripts.length, external: [...document.scripts].filter(s => s.src).length, bogusText: [...document.body.childNodes].filter(n => n.nodeType === Node.TEXT_NODE && n.textContent.trim()).length })`);
assert.deepEqual(page, { hls: 'function', scripts: 6, external: 0, bogusText: 0 }, 'Actual staged HLS executes without injected tags or leaked JS text');
for (const width of [1920, 1280, 720]) {
await command('Emulation.setDeviceMetricsOverride', { width, height: width === 1920 ? 1080 : 720, deviceScaleFactor: 1, mobile: false });
await delay(80);
assert.equal(await evaluate('document.documentElement.scrollWidth <= innerWidth'), true, `Generated page has no overflow at ${width}px`);
}
await evaluate(`document.querySelector('.tv-nav [data-section="Listen"]').click()`);
await delay(200);
await evaluate(`document.querySelector('.media-card').click(); document.querySelector('#detail-play').click()`);
await delay(1600);
assert.deepEqual(await evaluate(`({ playing: !document.querySelector('#tv-audio').paused, duration: document.querySelector('#tv-audio').duration, statusHidden: document.querySelector('#player-status').hidden })`), { playing: true, duration: 60, statusHidden: true }, 'Staged page retains local WAV playback readiness');
assert.deepEqual(exceptions, [], 'Generated scripts raise no uncaught runtime exceptions');
console.log('Actual LG staged page passed: Hls defined, six inline scripts, no bogus nodes, three viewport sizes, local WAV playback.');
} finally {
clearTimeout(watchdog);
socket?.close();
if (child.exitCode === null) { child.kill(); await Promise.race([new Promise(resolve => child.once('exit', resolve)), delay(3000)]); }
fs.rmSync(profile, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 });
}
})().catch(error => { console.error(error); process.exitCode = 1; });
55 changes: 55 additions & 0 deletions tests/tv-staging.test.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const vm = require('node:vm');
const { spawnSync } = require('node:child_process');

const root = path.resolve(__dirname, '..');
const fixture = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-tv-staging-'));
const tokens = "$& $1 $` $' $$";
const sources = new Map();
try {
for (const directory of ['scripts', 'tv', 'shared', 'assets', 'node_modules/hls.js/dist']) {
fs.mkdirSync(path.join(fixture, directory), { recursive: true });
}
fs.copyFileSync(path.join(root, 'scripts/stage-tv.cjs'), path.join(fixture, 'scripts/stage-tv.cjs'));
for (const directory of ['lg', 'samsung']) {
fs.cpSync(path.join(root, 'tv', directory), path.join(fixture, 'tv', directory), { recursive: true });
}
fs.copyFileSync(path.join(root, 'tv/index.html'), path.join(fixture, 'tv/index.html'));
fs.writeFileSync(path.join(fixture, 'package.json'), JSON.stringify({ version: '2.3.4' }));
for (const file of ['tv/config.js', 'tv/tv.js', 'shared/series-metadata.js', 'shared/live-tv.js', 'shared/watch-browse.js', 'node_modules/hls.js/dist/hls.min.js']) {
// Include replacement metacharacters and an HTML script terminator in executable JS.
const source = `window[${JSON.stringify(file)}] = ${JSON.stringify(tokens + ' </script>')};\n`;
sources.set(file, source);
fs.writeFileSync(path.join(fixture, file), source);
}
const css = `:root { --literal: ${JSON.stringify(tokens)}; }\n`;
fs.writeFileSync(path.join(fixture, 'tv/tv.css'), css);
for (const file of ['harbor-mark.svg', 'tv-icon.png']) fs.copyFileSync(path.join(root, 'assets', file), path.join(fixture, 'assets', file));
const result = spawnSync(process.execPath, [path.join(fixture, 'scripts/stage-tv.cjs')], {
encoding: 'utf8', env: { ...process.env, TMDB_API_KEY: '', NODE_PATH: path.join(root, 'node_modules') }
});
assert.equal(result.status, 0, result.stderr);
const html = fs.readFileSync(path.join(fixture, 'tv/build/lg/index.html'), 'utf8');
const scripts = [...html.matchAll(/<script nonce="harbor-lg-bundle">([\s\S]*?)<\/script>/g)].map(match => match[1]);
assert.equal(scripts.length, sources.size, 'All six bundled scripts retain their HTML boundaries');
const context = vm.createContext({ window: {} });
for (const [file, source] of sources) {
// HTML order is shared libraries, config, HLS, then TV; match by file marker.
const actual = scripts.find(script => script.includes(JSON.stringify(file)));
assert.equal(actual, source.replaceAll('</script', '<\\/script'), `${file}: literal replacement tokens survive staging`);
vm.runInContext(actual, context, { filename: file });
assert.equal(context.window[file], tokens + ' </script>', `${file}: generated JS retains its original value`);
}
assert.ok(html.includes(`<style nonce="harbor-lg-bundle">${css}</style>`), 'CSS replacement tokens survive staging');
assert.ok(html.includes("script-src 'nonce-harbor-lg-bundle'; style-src 'nonce-harbor-lg-bundle';"), 'Nonce CSP remains in place');
assert.equal(/<script[^>]*\bsrc=/.test(html), false, 'LG page has no external script tags');
for (const platform of ['android-assets', 'samsung']) {
assert.equal(fs.readFileSync(path.join(fixture, 'tv/build', platform, 'shared/hls.min.js'), 'utf8'), sources.get('node_modules/hls.js/dist/hls.min.js'));
}
console.log('TV staging regression passed: literal tokens in all JS/CSS injections, script escaping, CSP, and external platform copies.');
} finally {
fs.rmSync(fixture, { recursive: true, force: true });
}
Loading