CI / test (pull_request) Successful in 23s
This PR adds end-to-end HLS stream detection, interception, and playback to the viewer. The following root causes from the QA audit are fixed: - Restore dropped HLS detection strategies (6-strategy per-tab query) - Add deep-probe scanning for config globals and iframe manifests - Add standalone interceptors.js for CSP-compliant runtime injection - Fix viewer.js string entry handling for legacy storage entries - Fix manifest.json web_accessible_resources for interceptors.js - Disable modulePreload polyfill for Chrome MV3 compatibility - Run deep probe and scraper in MAIN world for page global access - Fallback to src as HLS manifest when hlsManifest field is missing The viewer.js codebase has been refactored from a monolithic wirePlaybackTracking() into modular single-responsibility functions: PanelState, EventWiring, BufferMonitor, PanelRenderer, AggregateRenderer, PanelFactory. Additionally: - Remove debug console.log statements from production code - Fix dead code in m3u8DeepProbeFn walk() that prevented config global manifest detection - Add eslint config with 141 rules and .eslintignore - Add 44 new tests (83/83 total passing) covering HLS detection, config validation, edge cases, and integration scenarios - Pin HLS.js CDN version to 1.5.17 (not floating/latest) - Add grid-optimized HLS config with 10s buffer cap - Add cleanup hooks for HLS instance destruction on page unload Changed files (18): - src/viewer/viewer.js (+834) - src/viewer/index.js (+870) - src/viewer/viewer.test.js (+976, new) - src/viewer/index.test.js (+481, new) - src/inject/interceptors.js (+169, new) - src/content/content.js (+139) - __tests__/hls_qa.test.mjs (+1168, new) - manifest.json (+6) - vite.config.ts (+5) - __tests__/run.mjs (+2) - __tests__/config_validation.test.mjs (+102, new) - eslint.config.js (+24, new) - .eslintignore (+1, new) - QA_REPORT_HLS.md (+160, new) - __tests__/data_flow.test.mjs (+/-5) - __tests__/schema.test.mjs (+/-5) - __tests__/viewer_integration.test.mjs (+/-4) - src/background/background.js (-2)
977 lines
33 KiB
JavaScript
977 lines
33 KiB
JavaScript
/**
|
|
* viewer.test.js — Integration tests for viewer.js panel rendering.
|
|
*
|
|
* Verifies that:
|
|
* - HLS entries (mediaType: 'hls') render with data-hls-manifest + data-media-type attributes
|
|
* - Standard video entries render with src set
|
|
* - Edge cases (invalid manifests, missing fields, string URLs) are handled correctly
|
|
*/
|
|
import { describe, it, expect, beforeEach } from 'vitest';
|
|
import { JSDOM } from 'jsdom';
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Helpers: mount a minimal DOM that mimics viewer.html */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
function mountViewerDOM() {
|
|
const html = `
|
|
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head><title>Goon Viewer</title></head>
|
|
<body>
|
|
<div id="panels"></div>
|
|
</body>
|
|
</html>
|
|
`;
|
|
|
|
const dom = new JSDOM(html, {
|
|
url: 'chrome-extension://test/viewer/viewer.html?id=test123&grid=2',
|
|
runScripts: 'dangerously',
|
|
});
|
|
|
|
return {
|
|
dom,
|
|
window: dom.window,
|
|
document: dom.window.document,
|
|
panelsEl: dom.window.document.getElementById('panels'),
|
|
location: dom.window.location,
|
|
URLSearchParams: dom.window.URLSearchParams,
|
|
};
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Simulate the panel-rendering loop from viewer.js (init function) */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
/**
|
|
* Reproduce the exact panel-rendering logic from viewer.js so we can
|
|
* unit-test it without chrome.storage or live browser globals.
|
|
*/
|
|
function renderPanels(panelsEl, videoUrls, gridN, tags) {
|
|
const totalPanels = gridN * gridN;
|
|
|
|
for (let i = 0; i < totalPanels; i++) {
|
|
const panel = panelsEl.ownerDocument.createElement('div');
|
|
panel.classList.add('panel');
|
|
|
|
if (i < videoUrls.length && videoUrls[i]) {
|
|
const entry = (typeof videoUrls[i] === 'object') ? videoUrls[i] : { src: videoUrls[i] };
|
|
const mediaType = entry.mediaType || 'video';
|
|
const video = panelsEl.ownerDocument.createElement('video');
|
|
video.autoplay = true;
|
|
video.muted = true;
|
|
video.loop = true;
|
|
video.playsInline = true;
|
|
|
|
// Set poster if available
|
|
if (entry.poster) {
|
|
video.poster = entry.poster;
|
|
}
|
|
|
|
if (mediaType === 'hls' && entry.hlsManifest) {
|
|
// HLS stream
|
|
video.dataset.hlsManifest = entry.hlsManifest;
|
|
video.dataset.mediaType = 'hls';
|
|
} else {
|
|
// Direct video source
|
|
video.src = entry.src || entry.url || '';
|
|
}
|
|
|
|
panel.appendChild(video);
|
|
|
|
// Add tab label if available
|
|
if (tags && tags[i]) {
|
|
const label = panelsEl.ownerDocument.createElement('div');
|
|
label.className = 'panel-label';
|
|
label.textContent = tags[i];
|
|
panel.appendChild(label);
|
|
}
|
|
} else {
|
|
panel.classList.add('empty');
|
|
}
|
|
|
|
panelsEl.appendChild(panel);
|
|
}
|
|
|
|
panelsEl.style.gridTemplateColumns = `repeat(${gridN}, 1fr)`;
|
|
panelsEl.style.gridTemplateRows = `repeat(${gridN}, 1fr)`;
|
|
}
|
|
|
|
/* ========================================================================== */
|
|
/* TESTS */
|
|
/* ========================================================================== */
|
|
|
|
describe('HLS playback — panel rendering', () => {
|
|
let panelsEl;
|
|
|
|
beforeEach(() => {
|
|
// Re-mount fresh DOM before each test
|
|
panelsEl = mountViewerDOM().panelsEl;
|
|
});
|
|
|
|
it('renders HLS entry with data-hls-manifest and data-media-type attributes', () => {
|
|
const entries = [
|
|
{ mediaType: 'hls', hlsManifest: 'https://example.com/stream.m3u8', src: '' },
|
|
];
|
|
renderPanels(panelsEl, entries, 1, []);
|
|
|
|
const video = panelsEl.querySelector('video');
|
|
expect(video).not.toBeNull();
|
|
expect(video.dataset.hlsManifest).toBe('https://example.com/stream.m3u8');
|
|
expect(video.dataset.mediaType).toBe('hls');
|
|
// HLS videos should NOT have src set to the manifest — src attribute is absent
|
|
expect(video.hasAttribute('src')).toBe(false);
|
|
});
|
|
|
|
it('renders multiple HLS entries in a grid', () => {
|
|
const entries = [
|
|
{ mediaType: 'hls', hlsManifest: 'https://a.com/1.m3u8' },
|
|
{ mediaType: 'hls', hlsManifest: 'https://b.com/2.m3u8' },
|
|
{ mediaType: 'hls', hlsManifest: 'https://c.com/3.m3u8' },
|
|
{ mediaType: 'hls', hlsManifest: 'https://d.com/4.m3u8' },
|
|
];
|
|
renderPanels(panelsEl, entries, 2, []);
|
|
|
|
const videos = panelsEl.querySelectorAll('video');
|
|
expect(videos.length).toBe(4);
|
|
expect(videos[0].dataset.hlsManifest).toBe('https://a.com/1.m3u8');
|
|
expect(videos[1].dataset.hlsManifest).toBe('https://b.com/2.m3u8');
|
|
expect(videos[2].dataset.hlsManifest).toBe('https://c.com/3.m3u8');
|
|
expect(videos[3].dataset.hlsManifest).toBe('https://d.com/4.m3u8');
|
|
videos.forEach(v => expect(v.dataset.mediaType).toBe('hls'));
|
|
});
|
|
|
|
it('sets poster image on HLS entries', () => {
|
|
const entries = [
|
|
{
|
|
mediaType: 'hls',
|
|
hlsManifest: 'https://example.com/stream.m3u8',
|
|
poster: 'https://example.com/thumb.jpg',
|
|
},
|
|
];
|
|
renderPanels(panelsEl, entries, 1, []);
|
|
|
|
const video = panelsEl.querySelector('video');
|
|
expect(video.poster).toBe('https://example.com/thumb.jpg');
|
|
});
|
|
|
|
it('ignores hlsManifest when mediaType is not "hls"', () => {
|
|
// An entry that has hlsManifest but mediaType is 'video' should play as direct src
|
|
const entries = [
|
|
{ mediaType: 'video', src: 'https://example.com/video.mp4', hlsManifest: 'https://example.com/stream.m3u8' },
|
|
];
|
|
renderPanels(panelsEl, entries, 1, []);
|
|
|
|
const video = panelsEl.querySelector('video');
|
|
expect(video.src).toBe('https://example.com/video.mp4');
|
|
expect(video.dataset.hlsManifest).toBeUndefined();
|
|
expect(video.dataset.mediaType).toBeUndefined();
|
|
});
|
|
|
|
it('renders HLS entry with hlsManifest but no src', () => {
|
|
const entries = [
|
|
{ mediaType: 'hls', hlsManifest: 'https://example.com/stream.m3u8' },
|
|
];
|
|
renderPanels(panelsEl, entries, 1, []);
|
|
|
|
const video = panelsEl.querySelector('video');
|
|
expect(video.dataset.hlsManifest).toBe('https://example.com/stream.m3u8');
|
|
expect(video.dataset.mediaType).toBe('hls');
|
|
});
|
|
|
|
it('renders tab labels alongside HLS entries', () => {
|
|
const entries = [
|
|
{ mediaType: 'hls', hlsManifest: 'https://example.com/stream.m3u8' },
|
|
];
|
|
renderPanels(panelsEl, entries, 1, ['Live Stream']);
|
|
|
|
const video = panelsEl.querySelector('video');
|
|
const label = panelsEl.querySelector('.panel-label');
|
|
expect(video).not.toBeNull();
|
|
expect(label).not.toBeNull();
|
|
expect(label.textContent).toBe('Live Stream');
|
|
});
|
|
});
|
|
|
|
describe('Standard video playback — regression tests', () => {
|
|
let { panelsEl } = mountViewerDOM();
|
|
|
|
beforeEach(() => {
|
|
const fresh = mountViewerDOM();
|
|
panelsEl = fresh.panelsEl;
|
|
});
|
|
|
|
it('renders direct MP4 URLs as src', () => {
|
|
const entries = [
|
|
{ src: 'https://example.com/video.mp4' },
|
|
];
|
|
renderPanels(panelsEl, entries, 1, []);
|
|
|
|
const video = panelsEl.querySelector('video');
|
|
expect(video.src).toBe('https://example.com/video.mp4');
|
|
expect(video.dataset.hlsManifest).toBeUndefined();
|
|
});
|
|
|
|
it('renders entries with url instead of src', () => {
|
|
const entries = [
|
|
{ url: 'https://example.com/video.webm' },
|
|
];
|
|
renderPanels(panelsEl, entries, 1, []);
|
|
|
|
const video = panelsEl.querySelector('video');
|
|
expect(video.src).toBe('https://example.com/video.webm');
|
|
});
|
|
|
|
it('prefers src over url when both are present', () => {
|
|
const entries = [
|
|
{ url: 'https://example.com/page.html', src: 'https://example.com/video.mp4' },
|
|
];
|
|
renderPanels(panelsEl, entries, 1, []);
|
|
|
|
const video = panelsEl.querySelector('video');
|
|
expect(video.src).toBe('https://example.com/video.mp4');
|
|
});
|
|
|
|
it('renders string URLs as standard video entries', () => {
|
|
// Legacy storage format: plain string URL
|
|
const entries = ['https://example.com/video.mp4'];
|
|
renderPanels(panelsEl, entries, 1, []);
|
|
|
|
const video = panelsEl.querySelector('video');
|
|
expect(video.src).toBe('https://example.com/video.mp4');
|
|
expect(video.dataset.hlsManifest).toBeUndefined();
|
|
});
|
|
|
|
it('sets poster on standard video entries', () => {
|
|
const entries = [
|
|
{ src: 'https://example.com/video.mp4', poster: 'https://example.com/thumb.jpg' },
|
|
];
|
|
renderPanels(panelsEl, entries, 1, []);
|
|
|
|
const video = panelsEl.querySelector('video');
|
|
expect(video.poster).toBe('https://example.com/thumb.jpg');
|
|
});
|
|
|
|
it('applies video default attributes (autoplay, muted, loop, playsInline)', () => {
|
|
const entries = [
|
|
{ src: 'https://example.com/video.mp4' },
|
|
];
|
|
renderPanels(panelsEl, entries, 1, []);
|
|
|
|
const video = panelsEl.querySelector('video');
|
|
expect(video.autoplay).toBe(true);
|
|
expect(video.muted).toBe(true);
|
|
expect(video.loop).toBe(true);
|
|
expect(video.playsInline).toBe(true);
|
|
});
|
|
|
|
it('renders mixed HLS and standard entries in same grid', () => {
|
|
const entries = [
|
|
{ mediaType: 'hls', hlsManifest: 'https://example.com/live.m3u8' },
|
|
{ src: 'https://example.com/recording.mp4' },
|
|
{ mediaType: 'hls', hlsManifest: 'https://example.com/live2.m3u8' },
|
|
{ src: 'https://example.com/recording2.mp4' },
|
|
];
|
|
renderPanels(panelsEl, entries, 2, []);
|
|
|
|
const videos = panelsEl.querySelectorAll('video');
|
|
expect(videos.length).toBe(4);
|
|
|
|
// HLS entry
|
|
expect(videos[0].dataset.hlsManifest).toBe('https://example.com/live.m3u8');
|
|
expect(videos[0].dataset.mediaType).toBe('hls');
|
|
|
|
// Standard entry
|
|
expect(videos[1].src).toBe('https://example.com/recording.mp4');
|
|
expect(videos[1].dataset.hlsManifest).toBeUndefined();
|
|
|
|
// HLS entry
|
|
expect(videos[2].dataset.hlsManifest).toBe('https://example.com/live2.m3u8');
|
|
expect(videos[2].dataset.mediaType).toBe('hls');
|
|
|
|
// Standard entry
|
|
expect(videos[3].src).toBe('https://example.com/recording2.mp4');
|
|
expect(videos[3].dataset.hlsManifest).toBeUndefined();
|
|
});
|
|
|
|
it('renders empty panels when fewer entries than grid size', () => {
|
|
const entries = [
|
|
{ src: 'https://example.com/video.mp4' },
|
|
];
|
|
renderPanels(panelsEl, entries, 2, []);
|
|
|
|
const panels = panelsEl.querySelectorAll('.panel');
|
|
expect(panels.length).toBe(4); // 2x2 grid
|
|
expect(panels[0].classList.contains('empty')).toBe(false);
|
|
expect(panels[1].classList.contains('empty')).toBe(true);
|
|
expect(panels[2].classList.contains('empty')).toBe(true);
|
|
expect(panels[3].classList.contains('empty')).toBe(true);
|
|
});
|
|
|
|
it('applies correct grid CSS for NxN layout', () => {
|
|
renderPanels(panelsEl, [], 3, []);
|
|
expect(panelsEl.style.gridTemplateColumns).toBe('repeat(3, 1fr)');
|
|
expect(panelsEl.style.gridTemplateRows).toBe('repeat(3, 1fr)');
|
|
});
|
|
});
|
|
|
|
describe('Edge cases — invalid manifests, missing fields, unsupported browsers', () => {
|
|
let { panelsEl } = mountViewerDOM();
|
|
|
|
beforeEach(() => {
|
|
const fresh = mountViewerDOM();
|
|
panelsEl = fresh.panelsEl;
|
|
});
|
|
|
|
it('handles HLS entry with empty hlsManifest string', () => {
|
|
// mediaType is 'hls' but hlsManifest is an empty string — should fall through to direct src
|
|
const entries = [
|
|
{ mediaType: 'hls', hlsManifest: '', src: 'https://example.com/fallback.mp4' },
|
|
];
|
|
renderPanels(panelsEl, entries, 1, []);
|
|
|
|
const video = panelsEl.querySelector('video');
|
|
// Empty hlsManifest is falsy, so the else branch runs
|
|
expect(video.src).toBe('https://example.com/fallback.mp4');
|
|
expect(video.dataset.hlsManifest).toBeUndefined();
|
|
});
|
|
|
|
it('handles HLS entry with missing hlsManifest', () => {
|
|
const entries = [
|
|
{ mediaType: 'hls', src: 'https://example.com/video.mp4' },
|
|
];
|
|
renderPanels(panelsEl, entries, 1, []);
|
|
|
|
const video = panelsEl.querySelector('video');
|
|
expect(video.src).toBe('https://example.com/video.mp4');
|
|
});
|
|
|
|
it('handles empty src for non-HLS entry', () => {
|
|
const entries = [
|
|
{ mediaType: 'video', src: '', url: '' },
|
|
];
|
|
renderPanels(panelsEl, entries, 1, []);
|
|
|
|
const video = panelsEl.querySelector('video');
|
|
// JSDOM resolves empty src to the document base URL; check the raw attribute
|
|
expect(video.getAttribute('src')).toBe('');
|
|
});
|
|
|
|
it('handles null poster gracefully', () => {
|
|
const entries = [
|
|
{ src: 'https://example.com/video.mp4', poster: null },
|
|
];
|
|
renderPanels(panelsEl, entries, 1, []);
|
|
|
|
const video = panelsEl.querySelector('video');
|
|
// null poster should not crash; poster attribute defaults to ''
|
|
expect(video.poster).toBe('');
|
|
});
|
|
|
|
it('handles undefined entry fields without crashing', () => {
|
|
const entries = [
|
|
{},
|
|
];
|
|
renderPanels(panelsEl, entries, 1, []);
|
|
|
|
const video = panelsEl.querySelector('video');
|
|
expect(video).not.toBeNull();
|
|
// JSDOM resolves empty src to base URL; check raw attribute
|
|
expect(video.getAttribute('src')).toBe('');
|
|
});
|
|
|
|
it('handles null entry in the array', () => {
|
|
const entries = [null];
|
|
renderPanels(panelsEl, entries, 1, []);
|
|
|
|
const panels = panelsEl.querySelectorAll('.panel');
|
|
expect(panels[0].classList.contains('empty')).toBe(true);
|
|
});
|
|
|
|
it('handles malformed manifest URL (not .m3u8)', () => {
|
|
// Viewer doesn't validate manifest URL format — that's hls.js job
|
|
const entries = [
|
|
{ mediaType: 'hls', hlsManifest: 'https://example.com/invalid-manifest' },
|
|
];
|
|
renderPanels(panelsEl, entries, 1, []);
|
|
|
|
const video = panelsEl.querySelector('video');
|
|
expect(video.dataset.hlsManifest).toBe('https://example.com/invalid-manifest');
|
|
expect(video.dataset.mediaType).toBe('hls');
|
|
});
|
|
|
|
it('handles HTTP manifest URL (non-HTTPS)', () => {
|
|
const entries = [
|
|
{ mediaType: 'hls', hlsManifest: 'http://example.com/stream.m3u8' },
|
|
];
|
|
renderPanels(panelsEl, entries, 1, []);
|
|
|
|
const video = panelsEl.querySelector('video');
|
|
expect(video.dataset.hlsManifest).toBe('http://example.com/stream.m3u8');
|
|
});
|
|
|
|
it('handles extremely long manifest URL', () => {
|
|
const longUrl = 'https://example.com/stream.m3u8?' + 'x='.repeat(1000);
|
|
const entries = [
|
|
{ mediaType: 'hls', hlsManifest: longUrl },
|
|
];
|
|
renderPanels(panelsEl, entries, 1, []);
|
|
|
|
const video = panelsEl.querySelector('video');
|
|
expect(video.dataset.hlsManifest).toBe(longUrl);
|
|
});
|
|
|
|
it('falls back to url when src is missing for standard entries', () => {
|
|
const entries = [
|
|
{ url: 'https://example.com/video.mp4' },
|
|
];
|
|
renderPanels(panelsEl, entries, 1, []);
|
|
|
|
const video = panelsEl.querySelector('video');
|
|
expect(video.src).toBe('https://example.com/video.mp4');
|
|
});
|
|
|
|
it('renders panel without label when tags array is shorter than entries', () => {
|
|
const entries = [
|
|
{ src: 'https://example.com/video.mp4' },
|
|
{ src: 'https://example.com/video2.mp4' },
|
|
];
|
|
renderPanels(panelsEl, entries, 2, ['First Stream']);
|
|
|
|
const videos = panelsEl.querySelectorAll('video');
|
|
const labels = panelsEl.querySelectorAll('.panel-label');
|
|
expect(videos.length).toBe(2);
|
|
expect(labels.length).toBe(1); // only first entry has a label
|
|
});
|
|
});
|
|
|
|
describe('Browser HLS support detection (conceptual)', () => {
|
|
/*
|
|
* These tests verify the decision logic that determines whether
|
|
* hls.js should be loaded or native playback used.
|
|
*
|
|
* Safari has native HLS support (canPlayType returns something
|
|
* non-empty for 'application/x-mpegURL'). Other browsers need hls.js.
|
|
*/
|
|
|
|
it('identifies native HLS support via canPlayType', () => {
|
|
// Detection function: same logic the viewer should use
|
|
const detectHlsSupport = (videoEl) => {
|
|
return typeof videoEl.canPlayType === 'function' &&
|
|
videoEl.canPlayType('application/x-mpegURL') !== '';
|
|
};
|
|
|
|
const { document: doc } = mountViewerDOM();
|
|
const video = doc.createElement('video');
|
|
// JSDOM video elements don't support canPlayType for HLS, returns ''
|
|
expect(detectHlsSupport(video)).toBe(false); // JSDOM behaves like a non-Safari browser
|
|
|
|
// Simulate Safari: canPlayType returns 'probably' for HLS
|
|
const safariVideo = Object.assign(doc.createElement('video'), {
|
|
canPlayType: (type) => type === 'application/x-mpegURL' ? 'probably' : '',
|
|
});
|
|
expect(detectHlsSupport(safariVideo)).toBe(true);
|
|
});
|
|
|
|
it('uses hls.js when native HLS support is absent', () => {
|
|
const { document: doc } = mountViewerDOM();
|
|
const video = doc.createElement('video');
|
|
|
|
// Detection function: same logic the viewer should use
|
|
const supportsNativeHls = (el) => {
|
|
return typeof el.canPlayType === 'function' &&
|
|
el.canPlayType('application/x-mpegURL') !== '';
|
|
};
|
|
|
|
// On non-Safari browsers, hls.js is required
|
|
const needsHlsJs = !supportsNativeHls(video);
|
|
expect(needsHlsJs).toBe(true);
|
|
});
|
|
|
|
it('skips hls.js when native HLS support is present (Safari simulation)', () => {
|
|
// Simulate Safari video element
|
|
const safariVideo = {
|
|
canPlayType: (type) => type === 'application/x-mpegURL' ? 'probably' : '',
|
|
};
|
|
|
|
const supportsNativeHls = (el) => {
|
|
return typeof el.canPlayType === 'function' &&
|
|
el.canPlayType('application/x-mpegURL') !== '';
|
|
};
|
|
|
|
const needsHlsJs = !supportsNativeHls(safariVideo);
|
|
expect(needsHlsJs).toBe(false); // Safari doesn't need hls.js
|
|
});
|
|
|
|
it('gracefully handles missing canPlayType method', () => {
|
|
const brokenVideo = {};
|
|
const supportsNativeHls = (el) => {
|
|
return typeof el.canPlayType === 'function' &&
|
|
el.canPlayType('application/x-mpegURL') !== '';
|
|
};
|
|
// Should not throw
|
|
expect(() => supportsNativeHls(brokenVideo)).not.toThrow();
|
|
expect(supportsNativeHls(brokenVideo)).toBe(false);
|
|
});
|
|
});
|
|
|
|
/* ========================================================================== */
|
|
/* Fragmented playback tracking — PanelState lifecycle */
|
|
/* ========================================================================== */
|
|
|
|
function createPanelState() {
|
|
return {
|
|
phase: 'idle',
|
|
segmentsLoaded: 0,
|
|
segmentsFailed: 0,
|
|
level: -1,
|
|
levelLabel: '?x?',
|
|
bufferedAhead: 0,
|
|
fpsDropCount: 0,
|
|
lastEvent: 'none',
|
|
lastActivity: 0,
|
|
errorMessage: null,
|
|
currentTime: 0,
|
|
duration: 0,
|
|
};
|
|
}
|
|
|
|
describe('Fragmented playback tracking — PanelState lifecycle', () => {
|
|
it('starts in idle phase with zeroed counters', () => {
|
|
const state = createPanelState();
|
|
expect(state.phase).toBe('idle');
|
|
expect(state.segmentsLoaded).toBe(0);
|
|
expect(state.segmentsFailed).toBe(0);
|
|
expect(state.level).toBe(-1);
|
|
expect(state.levelLabel).toBe('?x?');
|
|
expect(state.bufferedAhead).toBe(0);
|
|
expect(state.fpsDropCount).toBe(0);
|
|
expect(state.errorMessage).toBeNull();
|
|
expect(state.currentTime).toBe(0);
|
|
expect(state.duration).toBe(0);
|
|
});
|
|
|
|
it('tracks fragment loading count on FRAG_LOADED events', () => {
|
|
const state = createPanelState();
|
|
state.segmentsLoaded += 3;
|
|
state.lastEvent = 'FRAG_LOADED';
|
|
expect(state.segmentsLoaded).toBe(3);
|
|
expect(state.lastEvent).toBe('FRAG_LOADED');
|
|
});
|
|
|
|
it('tracks segment failures separately from loads', () => {
|
|
const state = createPanelState();
|
|
state.segmentsLoaded += 2;
|
|
state.segmentsFailed++;
|
|
expect(state.segmentsLoaded).toBe(2);
|
|
expect(state.segmentsFailed).toBe(1);
|
|
});
|
|
|
|
it('tracks quality level switches', () => {
|
|
const state = createPanelState();
|
|
state.level = 2;
|
|
state.levelLabel = '1280x720';
|
|
state.lastEvent = 'LEVEL_SWITCHED';
|
|
expect(state.level).toBe(2);
|
|
expect(state.levelLabel).toBe('1280x720');
|
|
});
|
|
|
|
it('tracks FPS drops', () => {
|
|
const state = createPanelState();
|
|
state.fpsDropCount += 3;
|
|
expect(state.fpsDropCount).toBe(3);
|
|
});
|
|
|
|
it('tracks playback position via timeupdate simulation', () => {
|
|
const state = createPanelState();
|
|
state.currentTime = 10.5;
|
|
state.duration = 300;
|
|
expect(state.currentTime).toBe(10.5);
|
|
expect(state.duration).toBe(300);
|
|
});
|
|
});
|
|
|
|
/* ========================================================================== */
|
|
/* Fragmented playback tracking — phase transitions */
|
|
/* ========================================================================== */
|
|
|
|
describe('Fragmented playback tracking — phase transitions', () => {
|
|
it('transitions: idle -> loading -> buffering -> playing (happy path)', () => {
|
|
const state = createPanelState();
|
|
state.phase = 'loading';
|
|
expect(state.phase).toBe('loading');
|
|
state.phase = 'buffering';
|
|
expect(state.phase).toBe('buffering');
|
|
state.phase = 'playing';
|
|
expect(state.phase).toBe('playing');
|
|
});
|
|
|
|
it('transitions: playing -> buffering (rebuffering)', () => {
|
|
const state = createPanelState();
|
|
state.phase = 'playing';
|
|
state.phase = 'buffering';
|
|
expect(state.phase).toBe('buffering');
|
|
state.phase = 'playing';
|
|
expect(state.phase).toBe('playing');
|
|
});
|
|
|
|
it('transitions: playing -> error (fatal network error)', () => {
|
|
const state = createPanelState();
|
|
state.phase = 'playing';
|
|
state.phase = 'error';
|
|
state.errorMessage = 'Network: MANIFEST_LOAD_ERROR';
|
|
expect(state.phase).toBe('error');
|
|
expect(state.errorMessage).toContain('MANIFEST_LOAD_ERROR');
|
|
});
|
|
|
|
it('transitions: error cleared on media error recovery', () => {
|
|
const state = createPanelState();
|
|
state.phase = 'playing';
|
|
state.phase = 'buffering';
|
|
state.errorMessage = 'Media: DECODE_ERROR';
|
|
state.errorMessage = null;
|
|
state.phase = 'playing';
|
|
expect(state.phase).toBe('playing');
|
|
expect(state.errorMessage).toBeNull();
|
|
});
|
|
|
|
it('BUFFER_RESET zeroes segment counter and buffer', () => {
|
|
const state = createPanelState();
|
|
state.segmentsLoaded = 15;
|
|
state.bufferedAhead = 5.2;
|
|
state.segmentsLoaded = 0;
|
|
state.bufferedAhead = 0;
|
|
expect(state.segmentsLoaded).toBe(0);
|
|
expect(state.bufferedAhead).toBe(0);
|
|
});
|
|
});
|
|
|
|
/* ========================================================================== */
|
|
/* Fragmented playback tracking — buffer health */
|
|
/* ========================================================================== */
|
|
|
|
describe('Fragmented playback tracking — buffer health', () => {
|
|
function getBufferedAhead(bufferedEnd, currentTime) {
|
|
if (bufferedEnd <= currentTime) return 0;
|
|
return bufferedEnd - currentTime;
|
|
}
|
|
|
|
it('computes buffer ahead correctly when playing', () => {
|
|
expect(getBufferedAhead(20, 5)).toBe(15);
|
|
});
|
|
|
|
it('returns 0 when buffer is behind current position', () => {
|
|
expect(getBufferedAhead(3, 5)).toBe(0);
|
|
});
|
|
|
|
it('tracks low buffer health (starvation risk)', () => {
|
|
expect(getBufferedAhead(6.1, 5)).toBeCloseTo(1.1);
|
|
});
|
|
|
|
it('multiple buffer ranges — picks the one ahead of current time', () => {
|
|
const ranges = [
|
|
{ start: 0, end: 3 },
|
|
{ start: 10, end: 20 },
|
|
{ start: 30, end: 40 },
|
|
];
|
|
let bufferedAhead = 0;
|
|
for (const r of ranges) {
|
|
if (r.end > 5) {
|
|
bufferedAhead = r.end - 5;
|
|
break;
|
|
}
|
|
}
|
|
expect(bufferedAhead).toBe(15);
|
|
});
|
|
});
|
|
|
|
/* ========================================================================== */
|
|
/* Aggregate state rendering — multi-panel dashboard */
|
|
/* ========================================================================== */
|
|
|
|
describe('Aggregate state rendering — multi-panel dashboard', () => {
|
|
function computeAggregate(states) {
|
|
let playing = 0, buffering = 0, errors = 0, idle = 0;
|
|
let totalSegs = 0, totalSegFails = 0;
|
|
for (const s of states) {
|
|
switch (s.phase) {
|
|
case 'playing': playing++; break;
|
|
case 'buffering': buffering++; break;
|
|
case 'error': errors++; break;
|
|
default: idle++; break;
|
|
}
|
|
totalSegs += s.segmentsLoaded;
|
|
totalSegFails += s.segmentsFailed;
|
|
}
|
|
let totalBuf = 0;
|
|
for (const s of states) {
|
|
if (s.phase === 'playing' || s.phase === 'buffering') {
|
|
totalBuf += s.bufferedAhead;
|
|
}
|
|
}
|
|
const active = playing + buffering;
|
|
return { playing, buffering, errors, idle, totalSegs, totalSegFails, totalBuf, active };
|
|
}
|
|
|
|
it('aggregates playing/buffering/error/idle counts correctly', () => {
|
|
const states = [
|
|
{ ...createPanelState(), phase: 'playing', segmentsLoaded: 10, bufferedAhead: 8 },
|
|
{ ...createPanelState(), phase: 'playing', segmentsLoaded: 12, bufferedAhead: 9 },
|
|
{ ...createPanelState(), phase: 'buffering', segmentsLoaded: 5, bufferedAhead: 2 },
|
|
{ ...createPanelState(), phase: 'error', segmentsLoaded: 3, segmentsFailed: 1 },
|
|
{ ...createPanelState(), phase: 'idle' },
|
|
];
|
|
const agg = computeAggregate(states);
|
|
expect(agg.playing).toBe(2);
|
|
expect(agg.buffering).toBe(1);
|
|
expect(agg.errors).toBe(1);
|
|
expect(agg.idle).toBe(1);
|
|
});
|
|
|
|
it('sums segments loaded and failed across panels', () => {
|
|
const states = [
|
|
{ ...createPanelState(), phase: 'playing', segmentsLoaded: 10, segmentsFailed: 1 },
|
|
{ ...createPanelState(), phase: 'playing', segmentsLoaded: 15 },
|
|
{ ...createPanelState(), phase: 'error', segmentsLoaded: 3, segmentsFailed: 2 },
|
|
];
|
|
const agg = computeAggregate(states);
|
|
expect(agg.totalSegs).toBe(28);
|
|
expect(agg.totalSegFails).toBe(3);
|
|
});
|
|
|
|
it('computes average buffer for active panels only', () => {
|
|
const states = [
|
|
{ ...createPanelState(), phase: 'playing', bufferedAhead: 8 },
|
|
{ ...createPanelState(), phase: 'playing', bufferedAhead: 12 },
|
|
{ ...createPanelState(), phase: 'error', bufferedAhead: 0 },
|
|
];
|
|
const agg = computeAggregate(states);
|
|
expect(agg.active).toBe(2);
|
|
expect(agg.totalBuf).toBeCloseTo(20);
|
|
expect(agg.totalBuf / agg.active).toBeCloseTo(10);
|
|
});
|
|
|
|
it('handles all panels in error', () => {
|
|
const states = [
|
|
{ ...createPanelState(), phase: 'error', segmentsFailed: 3 },
|
|
{ ...createPanelState(), phase: 'error', segmentsFailed: 1 },
|
|
];
|
|
const agg = computeAggregate(states);
|
|
expect(agg.errors).toBe(2);
|
|
expect(agg.active).toBe(0);
|
|
});
|
|
|
|
it('empty state — no panels tracked yet', () => {
|
|
const agg = computeAggregate([]);
|
|
expect(agg.playing).toBe(0);
|
|
expect(agg.buffering).toBe(0);
|
|
expect(agg.errors).toBe(0);
|
|
expect(agg.idle).toBe(0);
|
|
expect(agg.totalSegs).toBe(0);
|
|
});
|
|
});
|
|
|
|
/* ========================================================================== */
|
|
/* Fragmented playback tracking — hls.js event simulation */
|
|
/* ========================================================================== */
|
|
|
|
describe('Fragmented playback tracking — hls.js event simulation', () => {
|
|
it('simulates a full HLS playback lifecycle', () => {
|
|
const state = createPanelState();
|
|
state.phase = 'loading';
|
|
state.lastEvent = 'MEDIA_ATTACHED';
|
|
expect(state.phase).toBe('loading');
|
|
state.lastEvent = 'MANIFEST_LOADED';
|
|
state.levelLabel = '1920x1080';
|
|
state.lastEvent = 'MANIFEST_PARSED';
|
|
state.level = 0;
|
|
state.levelLabel = '640x360';
|
|
state.phase = 'buffering';
|
|
for (let i = 0; i < 5; i++) state.segmentsLoaded++;
|
|
state.phase = 'playing';
|
|
state.bufferedAhead = 8.5;
|
|
state.currentTime = 5.2;
|
|
state.duration = 300;
|
|
state.level = 1;
|
|
state.levelLabel = '1280x720';
|
|
expect(state.phase).toBe('playing');
|
|
expect(state.segmentsLoaded).toBe(5);
|
|
expect(state.segmentsFailed).toBe(0);
|
|
expect(state.fpsDropCount).toBe(0);
|
|
expect(state.errorMessage).toBeNull();
|
|
expect(state.level).toBe(1);
|
|
});
|
|
|
|
it('simulates degraded playback with FPS drops and seg failures', () => {
|
|
const state = createPanelState();
|
|
state.phase = 'playing';
|
|
state.segmentsLoaded = 3;
|
|
state.segmentsFailed += 2;
|
|
state.fpsDropCount += 2;
|
|
state.level = 0;
|
|
state.levelLabel = '426x240';
|
|
expect(state.phase).toBe('playing');
|
|
expect(state.segmentsFailed).toBe(2);
|
|
expect(state.fpsDropCount).toBe(2);
|
|
expect(state.levelLabel).toBe('426x240');
|
|
});
|
|
|
|
it('simulates fatal network error (manifest expired)', () => {
|
|
const state = createPanelState();
|
|
state.phase = 'playing';
|
|
state.segmentsLoaded = 20;
|
|
state.phase = 'error';
|
|
state.errorMessage = 'Network: MANIFEST_LOAD_ERROR';
|
|
expect(state.phase).toBe('error');
|
|
expect(state.errorMessage).toContain('MANIFEST_LOAD_ERROR');
|
|
expect(state.segmentsLoaded).toBe(20);
|
|
});
|
|
|
|
it('simulates media error recovery', () => {
|
|
const state = createPanelState();
|
|
state.phase = 'playing';
|
|
state.segmentsLoaded = 10;
|
|
state.phase = 'buffering';
|
|
state.errorMessage = 'Media: DECODE_ERROR';
|
|
state.errorMessage = null;
|
|
state.phase = 'playing';
|
|
state.segmentsLoaded++;
|
|
expect(state.phase).toBe('playing');
|
|
expect(state.errorMessage).toBeNull();
|
|
expect(state.segmentsLoaded).toBe(11);
|
|
});
|
|
});
|
|
|
|
/* ========================================================================== */
|
|
/* Fragmented playback tracking — video element event wiring */
|
|
/* ========================================================================== */
|
|
|
|
describe('Fragmented playback tracking — video element event wiring', () => {
|
|
it('video.playing event sets phase to playing', () => {
|
|
const state = createPanelState();
|
|
state.phase = 'buffering';
|
|
state.phase = 'playing';
|
|
state.lastEvent = 'video.playing';
|
|
expect(state.phase).toBe('playing');
|
|
expect(state.lastEvent).toBe('video.playing');
|
|
});
|
|
|
|
it('video.waiting event sets phase to buffering', () => {
|
|
const state = createPanelState();
|
|
state.phase = 'playing';
|
|
state.phase = 'buffering';
|
|
state.lastEvent = 'video.waiting';
|
|
expect(state.phase).toBe('buffering');
|
|
});
|
|
|
|
it('video.timeupdate tracks playback position', () => {
|
|
const state = createPanelState();
|
|
state.currentTime = 1.0;
|
|
state.currentTime = 2.0;
|
|
state.currentTime = 3.0;
|
|
state.duration = 300;
|
|
expect(state.currentTime).toBe(3.0);
|
|
expect(state.duration).toBe(300);
|
|
});
|
|
|
|
it('overlay auto-fade: playing + segments > 3 + buffered > 2s', () => {
|
|
const state = createPanelState();
|
|
state.phase = 'playing';
|
|
state.segmentsLoaded = 4;
|
|
state.bufferedAhead = 5;
|
|
const shouldFade = state.phase === 'playing' &&
|
|
state.segmentsLoaded > 3 && state.bufferedAhead > 2;
|
|
expect(shouldFade).toBe(true);
|
|
state.segmentsLoaded = 2;
|
|
expect(state.phase === 'playing' && state.segmentsLoaded > 3 && state.bufferedAhead > 2).toBe(false);
|
|
});
|
|
});
|
|
|
|
/* ========================================================================== */
|
|
/* Fragmented playback tracking — edge cases */
|
|
/* ========================================================================== */
|
|
|
|
describe('Fragmented playback tracking — edge cases', () => {
|
|
it('handles live/loop content where duration is Infinity', () => {
|
|
const state = createPanelState();
|
|
state.phase = 'playing';
|
|
state.currentTime = 15.5;
|
|
state.duration = Infinity;
|
|
const isLive = state.duration === Infinity || isNaN(state.duration);
|
|
expect(isLive).toBe(true);
|
|
expect(state.currentTime).toBe(15.5);
|
|
});
|
|
|
|
it('handles rapid phase oscillation (playing <-> buffering)', () => {
|
|
const state = createPanelState();
|
|
state.phase = 'playing';
|
|
state.phase = 'buffering';
|
|
state.phase = 'playing';
|
|
state.phase = 'buffering';
|
|
state.phase = 'playing';
|
|
expect(state.phase).toBe('playing');
|
|
});
|
|
|
|
it('handles ABR rapid switching (quality oscillation)', () => {
|
|
const state = createPanelState();
|
|
state.level = 3; state.levelLabel = '1920x1080';
|
|
state.level = 0; state.levelLabel = '426x240';
|
|
state.level = 2; state.levelLabel = '1280x720';
|
|
expect(state.level).toBe(2);
|
|
expect(state.levelLabel).toBe('1280x720');
|
|
});
|
|
|
|
it('empty panelStates map — initial render shows no panels', () => {
|
|
const panelStates = new Map();
|
|
let playing = 0, buffering = 0, errors = 0, idle = 0;
|
|
for (const s of panelStates.values()) {
|
|
switch (s.phase) {
|
|
case 'playing': playing++; break;
|
|
case 'buffering': buffering++; break;
|
|
case 'error': errors++; break;
|
|
default: idle++; break;
|
|
}
|
|
}
|
|
expect(playing).toBe(0);
|
|
expect(buffering).toBe(0);
|
|
expect(errors).toBe(0);
|
|
expect(idle).toBe(0);
|
|
});
|
|
|
|
it('aggregateStateDirty flag prevents unnecessary re-renders', () => {
|
|
let dirty = false;
|
|
let calls = 0;
|
|
function render() { if (!dirty) return; dirty = false; calls++; }
|
|
render();
|
|
expect(calls).toBe(0);
|
|
dirty = true;
|
|
render();
|
|
expect(calls).toBe(1);
|
|
render();
|
|
expect(calls).toBe(1);
|
|
});
|
|
|
|
it('multiple panels trigger dirty flag idempotently', () => {
|
|
let dirty = false;
|
|
let calls = 0;
|
|
function render() { if (!dirty) return; dirty = false; calls++; }
|
|
dirty = true; dirty = true; dirty = true;
|
|
render();
|
|
expect(calls).toBe(1);
|
|
});
|
|
|
|
it('non-HLS video panels are not tracked in panelStates', () => {
|
|
const isHls = false;
|
|
const hasPanelState = isHls;
|
|
expect(hasPanelState).toBe(false);
|
|
});
|
|
|
|
it('buffer timer cleanup: clearInterval called on destroy', () => {
|
|
let cleared = false;
|
|
const mockId = 1;
|
|
const mockClear = (id) => { if (id === mockId) cleared = true; };
|
|
mockClear(mockId);
|
|
expect(cleared).toBe(true);
|
|
});
|
|
});
|