mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
396 lines
16 KiB
JavaScript
396 lines
16 KiB
JavaScript
import { describe, it } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { readFileSync } from 'node:fs';
|
|
import { dirname, resolve } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const root = resolve(__dirname, '..');
|
|
|
|
const readSrc = (relPath) => readFileSync(resolve(root, relPath), 'utf-8');
|
|
|
|
const liveNewsSrc = readSrc('src/components/LiveNewsPanel.ts');
|
|
const liveNewsSvc = readSrc('src/services/live-news.ts');
|
|
const youtubeApi = readSrc('api/youtube/live.js');
|
|
const sidecarSrc = readSrc('src-tauri/sidecar/local-api-server.mjs');
|
|
const indexHtml = readSrc('index.html');
|
|
|
|
// ── Extract channel IDs and DIRECT_HLS_MAP keys from source ──
|
|
|
|
const extractArrayIds = (arrayName) => {
|
|
const pattern = new RegExp(`const ${arrayName}[^=]*=[^\\[]*\\[([\\s\\S]*?)\\];`);
|
|
const match = liveNewsSrc.match(pattern);
|
|
if (!match) return [];
|
|
return [...match[1].matchAll(/id:\s*'([^']+)'/g)].map(m => m[1]);
|
|
};
|
|
|
|
const fullIds = extractArrayIds('FULL_LIVE_CHANNELS');
|
|
const techIds = extractArrayIds('TECH_LIVE_CHANNELS');
|
|
const optionalIds = extractArrayIds('OPTIONAL_LIVE_CHANNELS');
|
|
const allChannelIds = new Set([...fullIds, ...techIds, ...optionalIds]);
|
|
|
|
const hlsMapMatch = liveNewsSrc.match(/const DIRECT_HLS_MAP[^{]*\{([\s\S]*?)\};/);
|
|
const hlsMapEntries = hlsMapMatch
|
|
? [...hlsMapMatch[1].matchAll(/'([^']+)':\s*'([^']+)'/g)].map(m => ({ id: m[1], url: m[2] }))
|
|
: [];
|
|
|
|
const hlsMapIds = new Set(hlsMapEntries.map(e => e.id));
|
|
|
|
// ── 1. DIRECT_HLS_MAP integrity ──
|
|
|
|
describe('DIRECT_HLS_MAP integrity', () => {
|
|
it('has at least 6 entries', () => {
|
|
assert.ok(hlsMapEntries.length >= 6, `Expected ≥6 entries, got ${hlsMapEntries.length}`);
|
|
});
|
|
|
|
it('every key maps to an existing channel definition', () => {
|
|
for (const { id } of hlsMapEntries) {
|
|
assert.ok(allChannelIds.has(id), `DIRECT_HLS_MAP key '${id}' has no matching channel`);
|
|
}
|
|
});
|
|
|
|
it('every mapped channel has a fallbackVideoId or hlsUrl', () => {
|
|
for (const { id } of hlsMapEntries) {
|
|
const channelDef = liveNewsSrc.match(new RegExp(`id:\\s*'${id}'[^}]*}`));
|
|
assert.ok(channelDef, `Channel '${id}' definition not found`);
|
|
const hasFallback = /fallbackVideoId:\s*'[^']+'/.test(channelDef[0]);
|
|
const hasHlsUrl = /hlsUrl:\s*'[^']+'/.test(channelDef[0]);
|
|
const hasHandle = /handle:\s*'[^']+'/.test(channelDef[0]);
|
|
assert.ok(hasFallback || hasHlsUrl || hasHandle,
|
|
`Channel '${id}' in DIRECT_HLS_MAP lacks fallbackVideoId, hlsUrl, and handle`);
|
|
}
|
|
});
|
|
|
|
it('all HLS URLs use HTTPS', () => {
|
|
for (const { id, url } of hlsMapEntries) {
|
|
assert.ok(url.startsWith('https://'), `HLS URL for '${id}' is not HTTPS: ${url}`);
|
|
}
|
|
});
|
|
|
|
it('all HLS URLs contain .m3u8', () => {
|
|
for (const { id, url } of hlsMapEntries) {
|
|
assert.ok(url.includes('.m3u8'), `HLS URL for '${id}' does not contain .m3u8: ${url}`);
|
|
}
|
|
});
|
|
|
|
it('no duplicate channel IDs in the map', () => {
|
|
const ids = hlsMapEntries.map(e => e.id);
|
|
assert.equal(ids.length, new Set(ids).size, 'Duplicate IDs in DIRECT_HLS_MAP');
|
|
});
|
|
});
|
|
|
|
// ── 2. Channel data integrity ──
|
|
|
|
describe('channel data integrity', () => {
|
|
it('all FULL_LIVE_CHANNELS have fallbackVideoId', () => {
|
|
for (const id of fullIds) {
|
|
const match = liveNewsSrc.match(new RegExp(`id:\\s*'${id}'[^}]*}`, 's'));
|
|
assert.ok(match, `Channel '${id}' not found`);
|
|
assert.match(match[0], /fallbackVideoId:\s*'[^']+'/,
|
|
`FULL channel '${id}' missing fallbackVideoId`);
|
|
}
|
|
});
|
|
|
|
it('no channel ID appears in multiple arrays with conflicting definitions', () => {
|
|
const allIds = [...fullIds, ...techIds, ...optionalIds];
|
|
const counts = {};
|
|
for (const id of allIds) counts[id] = (counts[id] || 0) + 1;
|
|
for (const [id, count] of Object.entries(counts)) {
|
|
if (count > 1) {
|
|
const defs = [...liveNewsSrc.matchAll(new RegExp(`id:\\s*'${id}'[^}]*}`, 'g'))].map(m => m[0]);
|
|
const handles = defs.map(d => d.match(/handle:\s*'([^']+)'/)?.[1]);
|
|
const uniqueHandles = new Set(handles);
|
|
assert.equal(uniqueHandles.size, 1,
|
|
`Channel '${id}' has conflicting handles across arrays: ${[...uniqueHandles].join(', ')}`);
|
|
}
|
|
}
|
|
});
|
|
|
|
it('TRT World handle is @TRTWorld (not @taborrtworld)', () => {
|
|
const trt = liveNewsSrc.match(/id:\s*'trt-world'[^}]*}/);
|
|
assert.ok(trt, 'trt-world channel not found');
|
|
assert.match(trt[0], /handle:\s*'@TRTWorld'/,
|
|
'TRT World handle should be @TRTWorld');
|
|
});
|
|
|
|
it('euronews handle is @euronews (not typo)', () => {
|
|
const match = liveNewsSrc.match(/id:\s*'euronews'[^}]*}/);
|
|
assert.ok(match, 'euronews channel not found');
|
|
assert.match(match[0], /handle:\s*'@euronews'/,
|
|
'euronews handle should be @euronews');
|
|
});
|
|
});
|
|
|
|
// ── 3. renderNativeHlsPlayer safety checks ──
|
|
|
|
describe('renderNativeHlsPlayer safety', () => {
|
|
it('validates HLS URL starts with https://', () => {
|
|
assert.match(liveNewsSrc, /hlsUrl\.startsWith\('https:\/\/'\)/,
|
|
'Must validate HLS URL is HTTPS before creating video element');
|
|
});
|
|
|
|
it('captures activeChannel ref for race safety in error handler', () => {
|
|
assert.match(liveNewsSrc, /const failedChannel\s*=\s*this\.activeChannel/,
|
|
'Error handler must capture channel ref to avoid race conditions');
|
|
});
|
|
|
|
it('sets cooldown on HLS failure', () => {
|
|
assert.match(liveNewsSrc, /this\.hlsFailureCooldown\.set\(failedChannel\.id/,
|
|
'Must set cooldown timestamp on failure');
|
|
});
|
|
|
|
it('checks activeChannel identity before fallback', () => {
|
|
assert.match(liveNewsSrc, /this\.activeChannel\.id\s*===\s*failedChannel\.id/,
|
|
'Must verify channel hasn\'t changed before falling back');
|
|
});
|
|
|
|
it('explicitly stops video element on error', () => {
|
|
assert.match(liveNewsSrc, /video\.pause\(\);\s*\n\s*video\.removeAttribute\('src'\)/,
|
|
'Must pause and clear src on error for explicit cleanup');
|
|
});
|
|
});
|
|
|
|
// ── 4. getDirectHlsUrl cooldown logic ──
|
|
|
|
describe('getDirectHlsUrl cooldown', () => {
|
|
it('checks cooldown map before returning URL', () => {
|
|
assert.match(liveNewsSrc, /this\.hlsFailureCooldown\.get\(channelId\)/,
|
|
'Must check cooldown before returning HLS URL');
|
|
});
|
|
|
|
it('uses HLS_COOLDOWN_MS for timeout comparison', () => {
|
|
assert.match(liveNewsSrc, /Date\.now\(\)\s*-\s*failedAt\s*<\s*this\.HLS_COOLDOWN_MS/,
|
|
'Must compare against HLS_COOLDOWN_MS');
|
|
});
|
|
|
|
it('cooldown is at least 1 minute', () => {
|
|
const match = liveNewsSrc.match(/HLS_COOLDOWN_MS\s*=\s*(\d+)\s*\*\s*(\d+)\s*\*\s*(\d+)/);
|
|
assert.ok(match, 'HLS_COOLDOWN_MS not found');
|
|
const ms = Number(match[1]) * Number(match[2]) * Number(match[3]);
|
|
assert.ok(ms >= 60_000, `Cooldown too short: ${ms}ms (need ≥60s)`);
|
|
});
|
|
});
|
|
|
|
// ── 5. Player decision tree ordering ──
|
|
|
|
describe('player decision tree', () => {
|
|
it('switchChannel checks direct HLS before videoId validation', () => {
|
|
const switchMethod = liveNewsSrc.slice(
|
|
liveNewsSrc.indexOf('private async switchChannel'),
|
|
liveNewsSrc.indexOf('private showOfflineMessage'),
|
|
);
|
|
const hlsPos = switchMethod.indexOf('getDirectHlsUrl(channel.id)');
|
|
const videoIdPos = switchMethod.indexOf("!/^[\\w-]{10,12}$/.test(channel.videoId)");
|
|
assert.ok(hlsPos > 0, 'getDirectHlsUrl not found in switchChannel');
|
|
assert.ok(videoIdPos > 0, 'videoId validation not found in switchChannel');
|
|
assert.ok(hlsPos < videoIdPos,
|
|
'Direct HLS check must come BEFORE videoId validation in switchChannel');
|
|
});
|
|
|
|
it('initializePlayer checks direct HLS before videoId validation', () => {
|
|
const initMethod = liveNewsSrc.slice(
|
|
liveNewsSrc.indexOf('private async initializePlayer'),
|
|
liveNewsSrc.indexOf('private startBotCheckTimeout'),
|
|
);
|
|
const hlsPos = initMethod.indexOf('getDirectHlsUrl(this.activeChannel.id)');
|
|
const videoIdPos = initMethod.indexOf("!/^[\\w-]{10,12}$/.test(this.activeChannel.videoId)");
|
|
assert.ok(hlsPos > 0, 'getDirectHlsUrl not found in initializePlayer');
|
|
assert.ok(videoIdPos > 0, 'videoId validation not found in initializePlayer');
|
|
assert.ok(hlsPos < videoIdPos,
|
|
'Direct HLS check must come BEFORE videoId validation in initializePlayer');
|
|
});
|
|
});
|
|
|
|
// ── 6. resolveChannelVideo skips YouTube API for direct HLS ──
|
|
|
|
describe('resolveChannelVideo optimization', () => {
|
|
it('skips fetchLiveVideoInfo for desktop direct-HLS channels', () => {
|
|
const resolve = liveNewsSrc.slice(
|
|
liveNewsSrc.indexOf('private async resolveChannelVideo'),
|
|
liveNewsSrc.indexOf('private async switchChannel'),
|
|
);
|
|
const directHlsPos = resolve.indexOf('getDirectHlsUrl(channel.id)');
|
|
const fetchPos = resolve.indexOf('fetchLiveVideoInfo');
|
|
assert.ok(directHlsPos > 0, 'getDirectHlsUrl not in resolveChannelVideo');
|
|
assert.ok(fetchPos > 0, 'fetchLiveVideoInfo not in resolveChannelVideo');
|
|
assert.ok(directHlsPos < fetchPos,
|
|
'Direct HLS early return must come before fetchLiveVideoInfo call');
|
|
});
|
|
});
|
|
|
|
// ── 7. YouTube API: hlsUrl extraction ──
|
|
|
|
describe('YouTube API hlsManifestUrl extraction', () => {
|
|
it('extracts hlsManifestUrl from page HTML', () => {
|
|
assert.match(youtubeApi, /hlsManifestUrl/,
|
|
'API must extract hlsManifestUrl');
|
|
});
|
|
|
|
it('unescapes \\u0026 in HLS URL', () => {
|
|
assert.match(youtubeApi, /\\\\u0026/,
|
|
'Must unescape \\u0026 to & in HLS URLs');
|
|
});
|
|
|
|
it('only sets hlsUrl when videoId is present', () => {
|
|
assert.match(youtubeApi, /hlsMatch\s*&&\s*videoId/,
|
|
'hlsUrl must only be set when a live videoId was found');
|
|
});
|
|
|
|
it('includes hlsUrl in response JSON', () => {
|
|
assert.match(youtubeApi, /JSON\.stringify\(\{[^}]*hlsUrl/,
|
|
'Response must include hlsUrl field');
|
|
});
|
|
});
|
|
|
|
// ── 8. live-news.ts service ──
|
|
|
|
describe('fetchLiveVideoInfo service', () => {
|
|
it('exports fetchLiveVideoInfo function', () => {
|
|
assert.match(liveNewsSvc, /export async function fetchLiveVideoInfo/,
|
|
'Must export fetchLiveVideoInfo');
|
|
});
|
|
|
|
it('returns hlsUrl from API response', () => {
|
|
assert.match(liveNewsSvc, /hlsUrl\s*=\s*data\.hlsUrl/,
|
|
'Must propagate hlsUrl from API response');
|
|
});
|
|
|
|
it('caches hlsUrl alongside videoId', () => {
|
|
assert.match(liveNewsSvc, /liveVideoCache\.set\([^)]*hlsUrl/,
|
|
'Cache must store hlsUrl');
|
|
});
|
|
|
|
it('returns null hlsUrl on error', () => {
|
|
assert.match(liveNewsSvc, /return\s*\{\s*videoId:\s*null,\s*hlsUrl:\s*null\s*\}/,
|
|
'Error path must return null for both videoId and hlsUrl');
|
|
});
|
|
|
|
it('keeps deprecated fetchLiveVideoId for backwards compat', () => {
|
|
assert.match(liveNewsSvc, /@deprecated/,
|
|
'fetchLiveVideoId should be marked deprecated');
|
|
assert.match(liveNewsSvc, /export async function fetchLiveVideoId/,
|
|
'fetchLiveVideoId must still be exported');
|
|
});
|
|
});
|
|
|
|
// ── 9. Sidecar YouTube embed endpoint ──
|
|
|
|
describe('sidecar youtube-embed endpoint', () => {
|
|
it('registers /api/youtube-embed route', () => {
|
|
assert.match(sidecarSrc, /\/api\/youtube-embed/,
|
|
'Sidecar must handle /api/youtube-embed');
|
|
});
|
|
|
|
it('validates videoId format', () => {
|
|
assert.match(sidecarSrc, /\[A-Za-z0-9_-\]\{11\}/,
|
|
'Must validate videoId is exactly 11 chars');
|
|
});
|
|
|
|
it('rejects invalid videoId with 400', () => {
|
|
assert.match(sidecarSrc, /status:\s*400/,
|
|
'Invalid videoId must return 400');
|
|
});
|
|
|
|
it('whitelists video quality values', () => {
|
|
assert.match(sidecarSrc, /small.*medium.*large.*hd720.*hd1080/,
|
|
'Must whitelist quality parameter values');
|
|
});
|
|
|
|
it('is exempt from auth gate (before auth middleware)', () => {
|
|
const embedPos = sidecarSrc.indexOf('/api/youtube-embed');
|
|
const authPos = sidecarSrc.indexOf('Global auth gate');
|
|
assert.ok(embedPos > 0 && authPos > 0, 'Both positions must exist');
|
|
assert.ok(embedPos < authPos,
|
|
'youtube-embed must be BEFORE auth gate (iframe src cannot carry auth headers)');
|
|
});
|
|
|
|
it('uses mute param (not hardcoded) in playerVars', () => {
|
|
const embedSection = sidecarSrc.slice(
|
|
sidecarSrc.indexOf('/api/youtube-embed'),
|
|
sidecarSrc.indexOf('Global auth gate'),
|
|
);
|
|
assert.match(embedSection, /mute:\$\{mute\}/,
|
|
'playerVars.mute must use the mute param, not hardcoded mute:1');
|
|
assert.doesNotMatch(embedSection, /playerVars:\{[^}]*mute:1[^}]*\}/,
|
|
'playerVars must NOT hardcode mute:1');
|
|
});
|
|
|
|
it('has postMessage bridge for play/pause/mute/unmute', () => {
|
|
const embedSection = sidecarSrc.slice(
|
|
sidecarSrc.indexOf('/api/youtube-embed'),
|
|
sidecarSrc.indexOf('Global auth gate'),
|
|
);
|
|
assert.match(embedSection, /case'play':.*playVideo/,
|
|
'postMessage bridge must handle play command');
|
|
assert.match(embedSection, /case'pause':.*pauseVideo/,
|
|
'postMessage bridge must handle pause command');
|
|
assert.match(embedSection, /case'mute':.*\.mute\(\)/,
|
|
'postMessage bridge must handle mute command');
|
|
assert.match(embedSection, /case'unmute':.*\.unMute\(\)/,
|
|
'postMessage bridge must handle unmute command');
|
|
});
|
|
|
|
it('has play overlay for autoplay failures', () => {
|
|
const embedSection = sidecarSrc.slice(
|
|
sidecarSrc.indexOf('/api/youtube-embed'),
|
|
sidecarSrc.indexOf('Global auth gate'),
|
|
);
|
|
assert.match(embedSection, /play-overlay/,
|
|
'Embed must include a play overlay for WKWebView autoplay fallback');
|
|
assert.match(embedSection, /setTimeout.*started.*overlay/s,
|
|
'Play overlay must show after timeout if video has not started');
|
|
});
|
|
|
|
it('sends yt-ready postMessage to parent on ready', () => {
|
|
const embedSection = sidecarSrc.slice(
|
|
sidecarSrc.indexOf('/api/youtube-embed'),
|
|
sidecarSrc.indexOf('Global auth gate'),
|
|
);
|
|
assert.match(embedSection, /postMessage\(\{type:'yt-ready'\}/,
|
|
'Must send yt-ready message to parent window');
|
|
});
|
|
});
|
|
|
|
// ── 10. Optional channels with fallbackVideoId ──
|
|
|
|
describe('optional channels fallback coverage', () => {
|
|
const highPriorityOptional = ['abc-news', 'nbc-news', 'wion', 'rt'];
|
|
|
|
for (const id of highPriorityOptional) {
|
|
it(`${id} has a fallback path`, () => {
|
|
const match = liveNewsSrc.match(new RegExp(`id:\\s*'${id}'[^}]*}`));
|
|
assert.ok(match, `Channel '${id}' not found in OPTIONAL_LIVE_CHANNELS`);
|
|
const hasFallback = /fallbackVideoId:\s*'[A-Za-z0-9_-]{11}'/.test(match[0]);
|
|
const hasHlsUrl = /hlsUrl:\s*'[^']+'/.test(match[0]);
|
|
const hasHandle = /handle:\s*'[^']+'/.test(match[0]);
|
|
assert.ok(hasFallback || hasHlsUrl || hasHandle,
|
|
`Optional channel '${id}' must have fallbackVideoId, hlsUrl, or handle`);
|
|
});
|
|
}
|
|
|
|
it('channels with useFallbackOnly also have fallbackVideoId or hlsUrl', () => {
|
|
const useFallbackMatches = [...liveNewsSrc.matchAll(/id:\s*'([^']+)'[^}]*useFallbackOnly:\s*true[^}]*}/g)];
|
|
for (const m of useFallbackMatches) {
|
|
const channelId = m[1];
|
|
const hasFallback = /fallbackVideoId:\s*'[^']+'/.test(m[0]);
|
|
const hasHlsUrl = /hlsUrl:\s*'[^']+'/.test(m[0]);
|
|
assert.ok(hasFallback || hasHlsUrl,
|
|
`Channel '${channelId}' has useFallbackOnly but no fallbackVideoId or hlsUrl`);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ── 11. CSP allows sidecar iframe ──
|
|
|
|
describe('CSP configuration', () => {
|
|
it('frame-src allows http://127.0.0.1:*', () => {
|
|
assert.match(indexHtml, /frame-src[^;]*http:\/\/127\.0\.0\.1:\*/,
|
|
'CSP frame-src must allow sidecar localhost origin for YouTube embed iframe');
|
|
});
|
|
|
|
it('media-src allows https: for CDN HLS streams', () => {
|
|
assert.match(indexHtml, /media-src[^;]*https:/,
|
|
'CSP media-src must allow HTTPS for direct HLS CDN streams');
|
|
});
|
|
});
|