Files
goon_plugin/__tests__/viewer_integration.test.mjs
robot 542c5786be
CI / test (pull_request) Successful in 23s
feat: add HLS playback support with hls.js and modular viewer architecture
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)
2026-08-12 14:13:55 +00:00

494 lines
16 KiB
JavaScript

import { describe, it } from './runner.mjs';
import { JSDOM } from 'jsdom';
import * as fs from 'fs';
import * as path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const viewerSource = fs.readFileSync(
path.join(__dirname, '../src/viewer/viewer.js'),
'utf-8'
);
/**
* Create a JSDOM environment with mocked Chrome extension APIs.
*
* @param {object} options
* @param {object} options.storageData - Initial chrome.storage.local contents
* @param {string} options.url - Page URL (must include query params viewer.js reads at load)
* @param {string} options.options.hlsMode - 'native' (Safari-like: window.HLS undefined),
* 'mocked' (Hls.js loaded with mocked API), or 'none' (no mocking — CDN fetches hang)
*/
function createBrowserEnv({ storageData = {}, url = 'https://example.com/viewer/viewer.html', hlsMode = 'mocked' } = {}) {
const html = `<!DOCTYPE html><html><body><div id="panels"></div></body></html>`;
const dom = new JSDOM(html, {
url,
runScripts: 'dangerously',
resources: 'usable',
});
const win = dom.window;
// Shared mutable storage object
const storage = { ...storageData };
win.chrome = {
storage: {
local: {
get: (keys) =>
new Promise((resolve) => {
const result = {};
for (const key of (keys || [])) result[key] = storage[key];
resolve(result);
}),
set: (data) =>
new Promise((resolve) => {
for (const [k, v] of Object.entries(data)) storage[k] = v;
resolve();
}),
remove: (keys) =>
new Promise((resolve) => {
for (const key of (keys || [])) delete storage[key];
resolve();
}),
},
},
};
// Mock HLS to avoid hanging on CDN fetch in jsdom.
// jsdom does not fire onerror for dynamically loaded <script> tags,
// so loadHlsJs() would block forever trying CDN fallbacks.
// 'mocked' mode provides a fake Hls.js that records calls for verification.
// 'native' mode sets HLS = undefined so viewer.js takes the Safari native path.
const hlsCalls = { instances: [], loaded: false };
if (hlsMode === 'mocked') {
win.Hls = class MockHLS {
constructor(opts) {
hlsCalls.instances.push({ opts, destroyed: false, events: {}, source: null, media: null });
}
static get Events() {
return {
MANIFEST_PARSED: 'manifestParsed',
ERROR: 'error',
DESTROYING: 'destroying',
};
}
static get ErrorTypes() {
return { NETWORK_ERROR: 'networkError', MEDIA_ERROR: 'mediaError' };
}
static isSupported() {
return true;
}
loadSource(src) {
const inst = hlsCalls.instances[hlsCalls.instances.length - 1];
inst.source = src;
}
attachMedia(media) {
const inst = hlsCalls.instances[hlsCalls.instances.length - 1];
inst.media = media;
}
on(event, handler) {
const inst = hlsCalls.instances[hlsCalls.instances.length - 1];
if (!inst.events[event]) inst.events[event] = [];
inst.events[event].push(handler);
}
destroy() {
const inst = hlsCalls.instances[hlsCalls.instances.length - 1];
inst.destroyed = true;
}
startLoad() {}
recoverMediaError() {}
};
hlsCalls.loaded = true;
} else if (hlsMode === 'native') {
// Simulate Safari — window.HLS exists but isSupported() returns false.
// viewer.js then falls through to video.src = manifestUrl (native HLS).
win.Hls = class MockHLS_Native {
static isSupported() {
return false; // Safari has native HLS, Hls.js is not needed
}
};
}
// Track console output
const consoleLogs = [];
const consoleErrors = [];
const origConsole = { ...win.console };
win.console = {
...origConsole,
log: (...args) => consoleLogs.push(args.map(String).join(' ')),
error: (...args) => {
consoleErrors.push(args.map(String).join(' '));
origConsole.error.apply(origConsole, args);
},
warn: (...args) => {
consoleLogs.push(args.map(String).join(' '));
origConsole.warn.apply(origConsole, args);
},
};
return { dom, win, storage, consoleLogs, consoleErrors, hlsCalls };
}
describe('Integration: viewer.js with HLS entries', () => {
it('should render HLS video entries and use Hls.js to play HLS streams', () => {
const storageData = {};
const viewerId = 'test_viewer_1';
storageData[viewerId] = [
{
url: 'https://example.com/stream',
src: 'https://example.com/stream',
hlsManifest: 'https://cdn.example.com/live/playlist.m3u8',
mediaType: 'hls',
poster: 'https://example.com/thumb.jpg',
tab: 'Live Stream',
},
{
url: 'https://example.com/video.mp4',
src: 'https://example.com/video.mp4',
mediaType: 'direct',
tab: 'Regular Video',
},
];
const url = `https://example.com/viewer/viewer.html?id=${viewerId}&grid=2`;
const { dom, consoleLogs, hlsCalls } = createBrowserEnv({ storageData, url, hlsMode: 'mocked' });
dom.window.eval(viewerSource);
return new Promise((resolve, reject) => {
setTimeout(() => {
try {
const panels = dom.window.document.querySelectorAll('.panel');
if (panels.length !== 4) {
reject(new Error(`Expected 4 panels (2x2 grid), got ${panels.length}`));
return;
}
const videos = dom.window.document.querySelectorAll('video');
if (videos.length < 2) {
reject(new Error(`Expected at least 2 video elements, got ${videos.length}`));
return;
}
// Status overlay
const overlay = dom.window.document.getElementById('viewer-status');
if (!overlay || !overlay.textContent.includes('2 video')) {
reject(new Error(`Missing/incorrect status overlay: "${overlay ? overlay.textContent : 'null'}"`));
return;
}
// Verify Hls.js was actually instantiated for the HLS entry
if (hlsCalls.instances.length < 1) {
reject(new Error('Expected at least 1 HLS instance, got 0'));
return;
}
// Verify the HLS instance was attached to a video and loaded the manifest
const hlsInst = hlsCalls.instances[0];
if (!hlsInst.source || !hlsInst.source.includes('m3u8')) {
reject(new Error(`HLS source not set to m3u8 URL: ${hlsInst.source}`));
return;
}
if (!hlsInst.media || hlsInst.media.tagName !== 'VIDEO') {
reject(new Error('HLS not attached to a <video> element'));
return;
}
resolve();
} catch (e) {
reject(e);
}
}, 500);
});
});
it('should handle legacy entries (no HLS fields) with standard playback', () => {
const storageData = {};
const viewerId = 'test_legacy';
storageData[viewerId] = [
{ url: 'https://example.com/video.mp4', src: 'https://example.com/video.mp4', tab: 'Old Video' },
'https://example.com/another.mp4',
];
const url = `https://example.com/viewer/viewer.html?id=${viewerId}&grid=2`;
const { dom, hlsCalls } = createBrowserEnv({ storageData, url, hlsMode: 'mocked' });
dom.window.eval(viewerSource);
return new Promise((resolve, reject) => {
setTimeout(() => {
try {
const videos = dom.window.document.querySelectorAll('video');
if (videos.length < 2) {
reject(new Error(`Expected at least 2 video elements, got ${videos.length}`));
return;
}
// First video should have src set directly (standard playback)
const video0Src = videos[0].getAttribute('src');
if (!video0Src || !video0Src.includes('example.com')) {
reject(new Error(`Video 0 src not set: ${video0Src}`));
return;
}
// No HLS instances should have been created
if (hlsCalls.instances.length > 0) {
reject(new Error(`Expected 0 HLS instances for legacy entries, got ${hlsCalls.instances.length}`));
return;
}
resolve();
} catch (e) {
reject(e);
}
}, 500);
});
});
it('should show "No viewer ID provided" when id param is missing', () => {
const { dom } = createBrowserEnv();
dom.window.eval(viewerSource);
const panels = dom.window.document.getElementById('panels');
if (!panels.innerHTML.includes('No viewer ID provided')) {
throw new Error(`Expected "No viewer ID provided", got: ${panels.innerHTML}`);
}
});
it('should handle empty video list gracefully', () => {
const storageData = {};
const viewerId = 'test_empty';
storageData[viewerId] = [];
const url = `https://example.com/viewer/viewer.html?id=${viewerId}&grid=2`;
const { dom } = createBrowserEnv({ storageData, url });
dom.window.eval(viewerSource);
return new Promise((resolve, reject) => {
setTimeout(() => {
try {
const panels = dom.window.document.querySelectorAll('.panel');
if (panels.length !== 4) {
reject(new Error(`Expected 4 empty panels, got ${panels.length}`));
return;
}
const emptyPanels = dom.window.document.querySelectorAll('.panel.empty');
if (emptyPanels.length !== 4) {
reject(new Error(`Expected 4 empty panels, got ${emptyPanels.length}`));
return;
}
resolve();
} catch (e) {
reject(e);
}
}, 500);
});
});
it('should render correct grid size based on grid param', () => {
const storageData = {};
const viewerId = 'test_grid3';
storageData[viewerId] = [{ url: 'https://example.com/1.mp4' }];
const url = `https://example.com/viewer/viewer.html?id=${viewerId}&grid=3`;
const { dom } = createBrowserEnv({ storageData, url });
dom.window.eval(viewerSource);
return new Promise((resolve, reject) => {
setTimeout(() => {
try {
const panels = dom.window.document.querySelectorAll('.panel');
if (panels.length !== 9) {
reject(new Error(`Expected 9 panels (3x3), got ${panels.length}`));
return;
}
resolve();
} catch (e) {
reject(e);
}
}, 500);
});
});
it('should render tag labels when tags param is provided', () => {
const storageData = {};
const viewerId = 'test_tags';
storageData[viewerId] = [
{ url: 'https://example.com/1.mp4', tab: 'Stream 1' },
{ url: 'https://example.com/2.mp4', tab: 'Stream 2' },
];
const tags = ['Live Stream 1', 'Live Stream 2'];
const tagsParam = encodeURIComponent(JSON.stringify(tags));
const url = `https://example.com/viewer/viewer.html?id=${viewerId}&grid=2&tags=${tagsParam}`;
const { dom } = createBrowserEnv({ storageData, url });
dom.window.eval(viewerSource);
return new Promise((resolve, reject) => {
setTimeout(() => {
try {
const labels = dom.window.document.querySelectorAll('.panel-label');
if (labels.length < 2) {
reject(new Error(`Expected 2 labels, got ${labels.length}`));
return;
}
if (labels[0].textContent !== 'Live Stream 1') {
reject(new Error(`Label 0 wrong: "${labels[0].textContent}"`));
return;
}
resolve();
} catch (e) {
reject(e);
}
}, 500);
});
});
it('should handle mixed HLS and direct entries in same grid', () => {
const storageData = {};
const viewerId = 'test_mixed';
storageData[viewerId] = [
{
url: 'https://example.com/hls',
hlsManifest: 'https://cdn.example.com/hls/stream.m3u8',
mediaType: 'hls',
tab: 'HLS',
},
{
url: 'https://example.com/direct.mp4',
src: 'https://example.com/direct.mp4',
mediaType: 'direct',
tab: 'MP4',
},
{ url: 'https://example.com/legacy.mp4', tab: 'Legacy' },
];
const url = `https://example.com/viewer/viewer.html?id=${viewerId}&grid=2`;
const { dom, consoleLogs, hlsCalls } = createBrowserEnv({ storageData, url, hlsMode: 'mocked' });
dom.window.eval(viewerSource);
return new Promise((resolve, reject) => {
setTimeout(() => {
try {
const videos = dom.window.document.querySelectorAll('video');
if (videos.length < 3) {
reject(new Error(`Expected at least 3 videos, got ${videos.length}`));
return;
}
// Only one HLS instance (for the HLS entry)
if (hlsCalls.instances.length !== 1) {
reject(new Error(`Expected 1 HLS instance, got ${hlsCalls.instances.length}`));
return;
}
// Direct entry video should have src set directly
const directVideo = videos[1];
if (!directVideo.getAttribute('src') || !directVideo.getAttribute('src').includes('direct.mp4')) {
reject(new Error(`Direct video src wrong: ${directVideo.getAttribute('src')}`));
return;
}
resolve();
} catch (e) {
reject(e);
}
}, 500);
});
});
it('should fall back to native playback when HLS is not supported (Safari mode)', () => {
const storageData = {};
const viewerId = 'test_safari';
storageData[viewerId] = [
{
url: 'https://example.com/stream',
hlsManifest: 'https://cdn.example.com/live/playlist.m3u8',
mediaType: 'hls',
},
];
const url = `https://example.com/viewer/viewer.html?id=${viewerId}&grid=1`;
const { dom } = createBrowserEnv({ storageData, url, hlsMode: 'native' });
dom.window.eval(viewerSource);
return new Promise((resolve, reject) => {
setTimeout(() => {
try {
const videos = dom.window.document.querySelectorAll('video');
if (videos.length !== 1) {
reject(new Error(`Expected 1 video, got ${videos.length}`));
return;
}
// In native mode, video.src should be set to the manifest URL directly
const src = videos[0].getAttribute('src');
if (!src || !src.includes('m3u8')) {
reject(new Error(`Expected src to be m3u8 URL, got: ${src}`));
return;
}
resolve();
} catch (e) {
reject(e);
}
}, 500);
});
});
it('should fall back to src when mediaType=hls but hlsManifest is missing', () => {
const storageData = {};
const viewerId = 'test_missing_manifest';
storageData[viewerId] = [
{
url: 'https://example.com/page',
src: 'https://example.com/video.mp4',
mediaType: 'hls',
// hlsManifest intentionally missing
},
];
const url = `https://example.com/viewer/viewer.html?id=${viewerId}&grid=1`;
const { dom, hlsCalls } = createBrowserEnv({ storageData, url, hlsMode: 'mocked' });
dom.window.eval(viewerSource);
return new Promise((resolve, reject) => {
setTimeout(() => {
try {
const videos = dom.window.document.querySelectorAll('video');
if (videos.length !== 1) {
reject(new Error(`Expected 1 video, got ${videos.length}`));
return;
}
// Since hlsManifest is missing, viewer.js should fall back to resolveSrc
// The src should be set directly (not through HLS)
const src = videos[0].getAttribute('src');
if (!src || !src.includes('video.mp4')) {
reject(new Error(`Expected fallback to src, got: ${src}`));
return;
}
// No HLS instance should have been created
if (hlsCalls.instances.length > 0) {
reject(new Error(`Expected 0 HLS instances, got ${hlsCalls.instances.length}`));
return;
}
resolve();
} catch (e) {
reject(e);
}
}, 500);
});
});
});