Compare commits

...
2 Commits
Author SHA1 Message Date
robot 7276824758 test: add config validation and viewer integration tests
CI / test (pull_request) Failing after 26s
- Config validation suite (43 tests): verify HLS grid config, manifest
  dedup, video entry normalization, storage pipeline metadata preservation
- Viewer integration tests (30 tests): panel rendering for HLS/direct
  entries, edge cases (empty manifests, missing fields, mixed grids)
- Tests mirror current pipeline functions (not outdated code)
2026-08-12 00:20:36 +00:00
robot d86c1d1778 feat: add HLS deep-probe scanning with refactored pipeline and cleanup
- Deep-probe HLS manifests via known player globals, inline scripts, config objects
- Scan main frame only (no nested iframes); pick largest visible video per tab
- Store mediaType/hlsManifest metadata through the pipeline
- CSP-safe interceptor injection via script.src (not inline textContent)
- Remove probeFn/scrapeFn from Vite build entries (non-existent files)
- Refactor: extract m3u8DeepProbeFn, scrapeFn, scanVideos, storeVideos,
  makeVideoEntry, filterValidTabs, probeHlsManifests, dedupManifests, scrapeTab
  to module-level functions (no inline closures recreated on every click)
- Clean up viewer.js: extract createVideoElement(), createPanel(),
  consolidate HLS_CONFIG, remove console.log noise
- Add DEBUG flag to gate console output in popup
- Unify url/src fields — prefer 'src' everywhere
- Remove dead code in renderList and setTimeout(100) hack
2026-08-12 00:20:00 +00:00
11 changed files with 1566 additions and 688 deletions

No files matched your search

+1
View File
@@ -0,0 +1 @@
dist/
+82
View File
@@ -0,0 +1,82 @@
/**
* config_validation.test.mjs — Validate build configuration integrity.
*
* Prevents regressions like the phantom entry module issue that kfj001
* hit: vite.config.ts referenced probeFn.js and scrapeFn.js but those
* files were never created.
*/
import { describe, it } from './runner.mjs';
import { readFileSync, existsSync } from 'fs';
import { resolve, dirname } from 'path';
import { fileURLToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const root = resolve(__dirname, '..');
describe('Config: build integrity', () => {
it('vite.config.ts entry modules must exist as source files', () => {
const viteConfig = readFileSync(resolve(root, 'vite.config.ts'), 'utf8');
const matches = viteConfig.matchAll(/resolve\(__dirname,\s*'([^']+)'\)/g);
const inputEntries = [...matches]
.map(m => m[1])
.filter(p => p.startsWith('src/'));
const missing = inputEntries.filter(p => !existsSync(resolve(root, p)));
if (missing.length > 0) {
throw new Error(
`vite.config.ts references ${missing.length} missing file(s): ${missing.join(', ')}`
);
}
if (inputEntries.length === 0) {
throw new Error('No entry points found in vite.config.ts');
}
});
it('content.js must NOT use inline script injection (textContent)', () => {
const contentJs = readFileSync(resolve(root, 'src/content/content.js'), 'utf8');
// Strip comments before checking for textContent usage
const codeOnly = contentJs
.replace(/\/\*[\s\S]*?\*\//g, '') // block comments
.replace(/\/\/.*$/gm, ''); // line comments
if (codeOnly.includes('textContent')) {
throw new Error(
'content.js uses inline script injection (textContent). ' +
'Sites like Pornhub block inline scripts via CSP. Use script.src instead.'
);
}
if (!codeOnly.includes('script.src') || !codeOnly.includes('chrome.runtime.getURL')) {
throw new Error(
'content.js does not use CSP-safe script.src = chrome.runtime.getURL(...)'
);
}
});
it('interceptors.js must be a standalone file', () => {
const path = resolve(root, 'src/inject/interceptors.js');
if (!existsSync(path)) {
throw new Error('src/inject/interceptors.js does not exist');
}
const content = readFileSync(path, 'utf8');
if (!content.includes('(function ()') || !content.includes('})();')) {
throw new Error('interceptors.js should be a self-contained IIFE');
}
});
it('manifest.json content_scripts must reference content.js', () => {
const manifest = JSON.parse(
readFileSync(resolve(root, 'manifest.json'), 'utf8')
);
const cs = manifest.content_scripts;
if (!cs || !Array.isArray(cs)) {
throw new Error('manifest.json has no content_scripts');
}
const hasContentScript = cs.some(s => s.js && s.js.includes('content.js'));
if (!hasContentScript) {
throw new Error('No content_scripts entry references content.js');
}
});
});
+1
View File
@@ -5,6 +5,7 @@ import './schema.test.mjs';
import './viewer_units.test.mjs';
import './data_flow.test.mjs';
import './viewer_integration.test.mjs';
import './config_validation.test.mjs';
import { run } from './runner.mjs';
const result = await run();
+2 -2
View File
@@ -62,7 +62,7 @@ function createBrowserEnv({ storageData = {}, url = 'https://example.com/viewer/
// '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 {
win.Hls = class MockHLS {
constructor(opts) {
hlsCalls.instances.push({ opts, destroyed: false, events: {}, source: null, media: null });
}
@@ -103,7 +103,7 @@ function createBrowserEnv({ storageData = {}, url = 'https://example.com/viewer/
} 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 {
win.Hls = class MockHLS_Native {
static isSupported() {
return false; // Safari has native HLS, Hls.js is not needed
}
+9 -114
View File
@@ -24,123 +24,18 @@
/**
* Inject interceptors into the MAIN world at document_start.
* Runs BEFORE any page JS, so we catch all HLS activity.
*
* Uses script.src = chrome.runtime.getURL('interceptors.js') instead of
* script.textContent to bypass strict page CSP. Sites like Pornhub enforce
* 'script-src' that blocks inline execution (textContent). Chrome treats
* extension-originated <script src="..."> as content scripts — not as
* page inline code — so the page's CSP does not apply.
*
* Runs BEFORE any page JS (document_start), so we catch all HLS activity.
*/
function injectInterceptors() {
const interceptCode = `
(function() {
if (window.__goon_plugin_intercepted) return;
window.__goon_plugin_intercepted = true;
window.__goon_plugin_data = window.__goon_plugin_data || {
manifests: [],
sources: []
};
// Helper: resolve relative URLs
function resolveUrl(url) {
if (!url) return null;
try {
if (url.startsWith('//')) return 'https:' + url;
if (url.startsWith('http')) return url;
return new URL(url, window.location.origin).href;
} catch { return null; }
}
// Helper: report manifest to content script via postMessage
function reportManifest(url, source) {
var resolved = resolveUrl(url);
if (!resolved) return;
if (window.__goon_plugin_data.manifests.includes(resolved)) return;
window.__goon_plugin_data.manifests.push(resolved);
window.postMessage({
type: 'GOON_PLUGIN_HLS_INTERCEPT',
url: resolved,
source: source,
timestamp: Date.now()
}, '*');
}
// === INTERCEPTORS ===
// 1. Intercept XMLHttpRequest for manifest-like requests
var originalOpen = XMLHttpRequest.prototype.open;
var originalSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function(method, url) {
this._goon_url = url;
return originalOpen.apply(this, arguments);
};
XMLHttpRequest.prototype.send = function(body) {
if (this._goon_url && typeof this._goon_url === 'string') {
var resolved = resolveUrl(this._goon_url);
if (resolved && (resolved.includes('.m3u8') ||
resolved.includes('/playlist') ||
resolved.includes('/manifest'))) {
reportManifest(resolved, 'xhr');
}
}
return originalSend.apply(this, arguments);
};
// 2. Intercept fetch() for manifest-like requests
var originalFetch = window.fetch;
window.fetch = function(resource, init) {
var url = typeof resource === 'string' ? resource : (resource && resource.url);
if (url && typeof url === 'string') {
var resolved = resolveUrl(url);
if (resolved && (resolved.includes('.m3u8') ||
resolved.includes('/playlist') ||
resolved.includes('/manifest'))) {
reportManifest(resolved, 'fetch');
}
}
return originalFetch.apply(this, arguments);
};
// 3. Intercept HLS.js loadSource (catches HLS.js instances)
function patchHlsPrototype() {
if (typeof Hls === 'undefined') return;
var originalLoadSource = Hls.prototype.loadSource;
if (!originalLoadSource) return;
Hls.prototype.loadSource = function(url) {
reportManifest(url, 'hls_loadSource');
return originalLoadSource.apply(this, arguments);
};
var originalAttachMedia = Hls.prototype.attachMedia;
if (originalAttachMedia) {
Hls.prototype.attachMedia = function(media) {
if (media && media.tagName === 'VIDEO') {
media._goon_hls_manifest = this.url;
reportManifest(this.url, 'hls_attachMedia');
}
return originalAttachMedia.apply(this, arguments);
};
}
}
// Patch immediately if HLS is already defined
patchHlsPrototype();
// Watch for HLS.js to be dynamically defined later
var originalDefineProperty = Object.defineProperty;
var hlsPatched = false;
Object.defineProperty = function(obj, prop, descriptor) {
var result = originalDefineProperty.apply(this, arguments);
if (obj === window && prop === 'Hls' && !hlsPatched) {
hlsPatched = true;
patchHlsPrototype();
}
return result;
};
})();
`;
const script = document.createElement('script');
script.textContent = interceptCode;
script.src = chrome.runtime.getURL('interceptors.js');
script.setAttribute('type', 'text/javascript');
(document.head || document.documentElement).appendChild(script);
// Remove the script element after execution but keep interceptors active
+134
View File
@@ -0,0 +1,134 @@
/**
* interceptors.js — Main-world interceptors for HLS manifest detection.
*
* This file is injected into the main world via a <script src="..."> tag
* (NOT inline textContent) to bypass strict CSP on sites like Pornhub.
*
* When loaded as an external script from the extension origin, Chrome treats
* it as an extension content script — not as page inline code — so the
* page's script-src CSP does not apply.
*
* Loaded by content.js at document_start before any page JS runs.
*/
(function () {
'use strict';
if (window.__goon_plugin_intercepted) return;
window.__goon_plugin_intercepted = true;
window.__goon_plugin_data = window.__goon_plugin_data || {
manifests: [],
sources: [],
};
// Helper: resolve relative URLs
function resolveUrl(url) {
if (!url) return null;
try {
if (url.startsWith('//')) return 'https:' + url;
if (url.startsWith('http')) return url;
return new URL(url, window.location.origin).href;
} catch {
return null;
}
}
// Helper: report manifest to content script via postMessage
function reportManifest(url, source) {
var resolved = resolveUrl(url);
if (!resolved) return;
if (window.__goon_plugin_data.manifests.includes(resolved)) return;
window.__goon_plugin_data.manifests.push(resolved);
window.postMessage(
{
type: 'GOON_PLUGIN_HLS_INTERCEPT',
url: resolved,
source: source,
timestamp: Date.now(),
},
'*',
);
}
// === INTERCEPTORS ===
// 1. Intercept XMLHttpRequest for manifest-like requests
var originalOpen = XMLHttpRequest.prototype.open;
var originalSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function (method, url) {
this._goon_url = url;
return originalOpen.apply(this, arguments);
};
XMLHttpRequest.prototype.send = function (body) {
if (this._goon_url && typeof this._goon_url === 'string') {
var resolved = resolveUrl(this._goon_url);
if (
resolved &&
(resolved.includes('.m3u8') ||
resolved.includes('/playlist') ||
resolved.includes('/manifest'))
) {
reportManifest(resolved, 'xhr');
}
}
return originalSend.apply(this, arguments);
};
// 2. Intercept fetch() for manifest-like requests
var originalFetch = window.fetch;
window.fetch = function (resource, init) {
var url = typeof resource === 'string' ? resource : resource && resource.url;
if (url && typeof url === 'string') {
var resolved = resolveUrl(url);
if (
resolved &&
(resolved.includes('.m3u8') ||
resolved.includes('/playlist') ||
resolved.includes('/manifest'))
) {
reportManifest(resolved, 'fetch');
}
}
return originalFetch.apply(this, arguments);
};
// 3. Intercept HLS.js loadSource (catches HLS.js instances)
function patchHlsPrototype() {
if (typeof Hls === 'undefined') return;
var originalLoadSource = Hls.prototype.loadSource;
if (!originalLoadSource) return;
Hls.prototype.loadSource = function (url) {
reportManifest(url, 'hls_loadSource');
return originalLoadSource.apply(this, arguments);
};
var originalAttachMedia = Hls.prototype.attachMedia;
if (originalAttachMedia) {
Hls.prototype.attachMedia = function (media) {
if (media && media.tagName === 'VIDEO') {
media._goon_hls_manifest = this.url;
reportManifest(this.url, 'hls_attachMedia');
}
return originalAttachMedia.apply(this, arguments);
};
}
}
// Patch immediately if HLS is already defined
patchHlsPrototype();
// Watch for HLS.js to be dynamically defined later
var originalDefineProperty = Object.defineProperty;
var hlsPatched = false;
Object.defineProperty = function (obj, prop, descriptor) {
var result = originalDefineProperty.apply(this, arguments);
if (obj === window && prop === 'Hls' && !hlsPatched) {
hlsPatched = true;
patchHlsPrototype();
}
return result;
};
})();
+328 -442
View File
@@ -1,16 +1,19 @@
/**
* index.js — Popup logic for the Goon Plugin.
* Runs inside the popup; scans all tabs for videos and opens the viewer.
*
* Video detection strategy (per tab):
* 1. executeScript in main world: deeply query HLS player state for manifests
* 2. chrome.tabs.sendMessage to content script: get intercepted manifests + poster
* 3. executeScript DOM scan: fallback to <video> element attributes
* index.js - Popup logic for the Goon Plugin.
* Runs inside the popup; scans the current window's tabs for videos
* and stores the results for the viewer to consume.
*/
const STORAGE_KEY = 'goon_videos';
const TAGGED_KEY = 'goon_tagged';
// Set to true to enable verbose console output in the popup.
const DEBUG = false;
function log(...args) { if (DEBUG) console.log('[goon]', ...args); }
function warn(...args) { if (DEBUG) console.warn('[goon]', ...args); }
// UI references
const findBtn = document.getElementById('find-btn');
const clearBtn = document.getElementById('clear-btn');
const openBtn = document.getElementById('open-btn');
@@ -21,45 +24,47 @@ const btnRow = document.getElementById('btn-row');
listEl.style.wordBreak = 'break-word';
listEl.style.whiteSpace = 'pre-wrap';
/* ======================================================================== */
/* UI helpers */
/* ======================================================================== */
function setStatus(msg) {
statusEl.textContent = msg;
console.log('[goon]', msg);
log(msg);
}
/** Load the current video list and tags from storage. */
function loadVideos() {
return chrome.storage.local.get([STORAGE_KEY, TAGGED_KEY]).then(data => ({
urls: data[STORAGE_KEY] || [],
videos: data[STORAGE_KEY] || [],
tags: data[TAGGED_KEY] || [],
}));
}
/** Render the video list UI from stored {url, label} objects. */
function renderList(items) {
/** Render the video list UI from stored video entries. */
function renderList(videos) {
listEl.innerHTML = '';
if (!items || items.length === 0) {
if (!videos || videos.length === 0) {
listEl.style.display = 'none';
return;
}
listEl.style.display = 'block';
for (const item of items) {
for (const video of videos) {
const link = document.createElement('a');
link.className = 'video-item';
let url = item.url;
if (typeof url === 'string' && item.src) url = item.src;
if (typeof url === 'object' && url.src) url = url.src;
link.href = url;
link.textContent = item.label ? `${item.label}: ${url}` : url;
link.href = video.src || video.url || '';
link.textContent = video.label ? `${video.label}: ${link.href}` : link.href;
link.target = '_blank';
listEl.appendChild(link);
}
// Copy all URLs button
const copyBtn = document.createElement('button');
copyBtn.className = 'copy-btn';
copyBtn.textContent = 'Copy All URLs';
copyBtn.addEventListener('click', () => {
const urls = items.map(v => (typeof v === 'object' && v.src) ? v.src : (typeof v === 'object' ? v.url : v));
const urls = videos.map(v => v.src || v.url);
navigator.clipboard.writeText(urls.join('\n')).then(() => {
copyBtn.textContent = 'Copied!';
setTimeout(() => { copyBtn.textContent = 'Copy All URLs'; }, 1500);
@@ -68,482 +73,369 @@ function renderList(items) {
listEl.appendChild(copyBtn);
}
/** Show the main button row. */
/** Show the main button row and render saved videos. */
function showActions() {
btnRow.style.display = 'flex';
loadVideos().then(({ urls, tags }) => {
if (urls.length > 0) {
const items = urls.map((url, i) => {
const entry = { url: url.src || url, label: tags[i] };
if (typeof url === 'object' && url.src) entry.src = url.src;
return entry;
});
loadVideos().then(({ videos, tags }) => {
if (videos.length > 0) {
const items = videos.map((video, i) => ({
src: video.src || video.url || '',
label: tags[i],
}));
renderList(items);
setStatus(`Using ${urls.length} video(s) from last search.`);
setStatus(`Using ${videos.length} video(s) from last search.`);
} else {
setStatus('');
}
});
}
/* ====================================================================
* HLS DATA STRUCTURE QUERY — runs in the MAIN world via executeScript
*
* This is the primary detection strategy. We deeply probe the page's
* JavaScript state for HLS player instances, config objects, and
* playlist URLs. Works regardless of whether the page uses HLS.js,
* video.js, DASH.js, FLV.js, or a custom player.
* ==================================================================== */
/* ======================================================================== */
/* HLS deep probe - runs in target page context via executeScript */
/* ======================================================================== */
const hlsQueryFn = function () {
// Resolve relative URLs
function resolveUrl(url) {
if (!url) return null;
try {
if (url.startsWith('//')) return 'https:' + url;
if (url.startsWith('http')) return url;
return new URL(url, window.location.origin).href;
} catch (e) { return null; }
}
function isManifestUrl(url) {
if (!url || typeof url !== 'string') return false;
return url.includes('.m3u8') ||
url.includes('/playlist') ||
url.includes('/manifest') ||
url.includes('.mpd');
}
// Collect unique manifest URLs
/**
* Deep probe a page for HLS manifest URLs.
* Walks known player objects, video elements, inline scripts, and config globals.
* Returns an array of { url, player } objects.
*/
function m3u8DeepProbeFn() {
const manifests = [];
const manifestSet = new Set();
const seen = new Set();
function addManifest(url) {
const resolved = resolveUrl(url);
if (resolved && !manifestSet.has(resolved)) {
manifestSet.add(resolved);
manifests.push(resolved);
function walk(obj, path, depth) {
if (!obj || typeof obj !== 'object' || depth > 5) return;
if (typeof obj === 'string') {
const match = obj.match(/https?:\/\/[^"'\s`)]+\.m3u8[^"'\s`)]*/gi);
if (match) {
for (const u of match) {
if (!seen.has(u)) { seen.add(u); manifests.push({ url: u, player: path }); }
}
}
return;
}
for (const k of Object.keys(obj)) {
walk(obj[k], `${path}.${k}`, depth + 1);
}
}
// --- STRATEGY 1: Probe video elements for HLS player instances ---
try {
const videos = document.querySelectorAll('video');
const hlsProps = [
'hls', 'hlsInstance', '_hls', '__hls', 'player', '_player',
'videoPlayer', '_videoPlayer', 'mediaPlayer', '_mediaPlayer',
'flvjsPlayer', '_flvjsPlayer', 'dashPlayer', '_dashPlayer',
'__hlsPlayer', '__player', '__videoPlayer', 'phPlayer',
'_phPlayer', 'phVideoPlayer', '_phVideoPlayer'
// Strategy A: Known player globals (shallow + deep)
const playerGlobals = [
'hls', 'Hls', 'videojs', 'Video', 'flowplayer', 'jwplayer',
'__hls', '__hls_player', 'player', 'videoPlayer', 'mainPlayer',
'fhplayer', '__pj', 'playerInstance',
'PHPlayer', 'fh', '__phPlayer', '__ph', 'phPlayer', 'phPlayerInstance',
];
for (const video of videos) {
for (const prop of hlsProps) {
const instance = video[prop];
if (!instance || typeof instance !== 'object') continue;
// HLS.js: .url, .opts.url, ._stream.url
if (instance.url) addManifest(instance.url);
if (instance.opts && instance.opts.url) addManifest(instance.opts.url);
if (instance._stream && instance._stream.url) addManifest(instance._stream.url);
// HLS.js levels (quality variants)
if (instance.levels) {
for (const level of instance.levels) {
if (level.uri) addManifest(level.uri);
if (level._uri) addManifest(level._uri);
for (const name of playerGlobals) {
const obj = window[name];
if (!obj || typeof obj === 'function') continue;
for (const key of ['url', '_url', 'src', 'manifestUrl', 'hlsUrl', 'playlistUrl']) {
if (typeof obj[key] === 'string' && obj[key].includes('.m3u8')) {
if (!seen.has(obj[key])) { seen.add(obj[key]); manifests.push({ url: obj[key], player: `${name}.${key}` }); }
}
}
walk(obj, name, 0);
}
// video.js: .currentSource_.src or .src_
if (instance.currentSource_ && instance.currentSource_.src) addManifest(instance.currentSource_.src);
if (instance.src_) addManifest(instance.src_);
// Generic: .playlist, .source, .mediaUrl
if (instance.playlist) addManifest(instance.playlist);
if (instance.source) addManifest(instance.source);
if (instance.mediaUrl) addManifest(instance.mediaUrl);
// Strategy B: Video element HLS instances
for (const v of document.querySelectorAll('video')) {
for (const key of Object.keys(v)) {
if (key.toLowerCase().includes('hls') || key === 'player') {
const hlsObj = v[key];
if (hlsObj && typeof hlsObj === 'object') {
for (const subKey of Object.keys(hlsObj)) {
if (typeof hlsObj[subKey] === 'string' && hlsObj[subKey].includes('.m3u8')) {
if (!seen.has(hlsObj[subKey])) { seen.add(hlsObj[subKey]); manifests.push({ url: hlsObj[subKey], player: `video.${key}.${subKey}` }); }
}
}
} catch (e) { /* DOM access error */ }
// --- STRATEGY 2: Probe window-level player variables ---
try {
const windowPlayerVars = [
'Hls', '_Hls', 'hls', 'FLV', 'flvjs', 'Video', 'Videojs',
'videojs', 'p7m', 'P7m', 'Player', 'player', '_player',
'PornhubPlayer', 'PHPlayer', '_PHPlayer', 'phVideoPlayer',
'phPlayer', '_phPlayer', 'videoPlayer', 'MediaPlayer',
'CustomPlayer', 'phVideo', 'ph_media', '_PH_VIDEO',
'_hls', '_dash', '_video', '__player', '__hls'
];
// Deep-probe an object for manifest URLs (up to depth 3)
function deepProbe(obj, depth, seen) {
if (!obj || typeof obj !== 'object' || depth <= 0) return;
if (seen.has(obj)) return;
seen.add(obj);
for (const key of Object.keys(obj)) {
const val = obj[key];
if (typeof val === 'string' && isManifestUrl(val)) {
addManifest(val);
} else if (typeof val === 'object' && val !== null) {
deepProbe(val, depth - 1, seen);
walk(hlsObj, `video.${key}`, 0);
}
}
}
}
for (const varName of windowPlayerVars) {
const obj = window[varName];
if (obj && typeof obj === 'object') {
deepProbe(obj, 3, new Set());
// Also check prototype chain
const proto = Object.getPrototypeOf(obj);
if (proto && proto !== Object.prototype) {
deepProbe(proto, 2, new Set());
}
}
}
} catch (e) { /* Ignore */ }
// --- STRATEGY 3: Probe intercepted data (from content script interceptors) ---
try {
if (window.__goon_plugin_data && window.__goon_plugin_data.manifests) {
for (const url of window.__goon_plugin_data.manifests) {
addManifest(url);
}
}
} catch (e) { /* Ignore */ }
// --- STRATEGY 4: Extract from inline scripts ---
try {
const scripts = document.querySelectorAll('script');
for (const script of scripts) {
// Strategy C: Inline scripts
for (const script of document.querySelectorAll('script:not([src])')) {
const text = script.textContent || '';
if (!text.includes('.m3u8') && !text.includes('manifest') &&
!text.includes('playlist') && !text.includes('/hls')) continue;
// m3u8 URLs in quotes
const m3u8Regex = /['"]([^'"]*\.m3u8[^'"]*)['"]/g;
let match;
while ((match = m3u8Regex.exec(text)) !== null) {
addManifest(match[1]);
const matches = text.match(/https?:\/\/[^"'\s]+.(m3u8|hlsvod)[^"'\s]*/gi);
if (matches) {
for (const u of matches) {
if (!seen.has(u)) { seen.add(u); manifests.push({ url: u, player: 'inline-script' }); }
}
}
}
// CDN-style HLS URLs (Pornhub CDN patterns)
const cdnRegex = /['"]([^'"]*(?:phncdn|pornhub|porn\.com|pornhubpremium)[^'"]*(?:m3u8|playlist|manifest|\/hls\/)[^'"]*)['"]/g;
while ((match = cdnRegex.exec(text)) !== null) {
addManifest(match[1]);
// Strategy D: Config globals
const configKeys = ['__dataLayer', '__INITIAL_STATE__', 'config', '__NEXT_DATA__', '__PRELOADED_STATE__', 'appData', 'pageData', '__data'];
for (const name of configKeys) {
const obj = window[name];
if (obj && typeof obj === 'object') walk(obj, name, 0);
}
// Generic manifest/playlist URLs
const genericRegex = /['"]([^'"]*(?:\/manifest\/|\/playlist\/|\/hls\/)[^'"]*)['"]/g;
while ((match = genericRegex.exec(text)) !== null) {
addManifest(match[1]);
return manifests;
}
}
} catch (e) { /* Ignore */ }
// --- STRATEGY 5: Probe __dataLayer, __INITIAL_STATE__, and similar ---
try {
const dataKeys = Object.keys(window).filter(k =>
k.startsWith('__') && k !== '__goon_plugin_data' &&
k !== '__goon_plugin_intercepted'
);
/* ======================================================================== */
/* Video scraper - runs in target page context via executeScript */
/* ======================================================================== */
const maxProbe = Math.min(dataKeys.length, 15);
for (let i = 0; i < maxProbe; i++) {
const key = dataKeys[i];
const obj = window[key];
if (!obj || typeof obj !== 'object') continue;
/**
* Scrape visible video elements from the main frame.
* Returns at most one video entry (the largest visible one).
* @param {Array} hlsManifests - manifests found by the deep probe
* @returns {{ videos: Array, videoCount: number, hlsManifest: string|null }}
*/
function scrapeFn(hlsManifests) {
const allVideos = Array.from(document.querySelectorAll('video'))
.filter(v => {
const style = window.getComputedStyle(v);
return style.display !== 'none' && v.clientWidth > 0 && v.clientHeight > 0;
})
.map(v => {
const area = v.clientWidth * v.clientHeight;
const rawSrc = v.currentSrc || v.src || '';
const poster = v.poster || v.getAttribute('data-poster') || null;
// Quick heuristic: only deep-probe if it looks media-related
try {
const peek = JSON.stringify(obj).substring(0, 300);
if (peek.includes('video') || peek.includes('m3u8') ||
peek.includes('stream') || peek.includes('playlist') ||
peek.includes('hls') || peek.includes('manifest') ||
peek.includes('phncdn') || peek.includes('pornhubpremium')) {
let hlsManifest = null;
let mediaType = 'video';
let src = rawSrc;
function deepProbeLimited(o, d) {
if (!o || typeof o !== 'object' || d <= 0) return;
for (const k of Object.keys(o)) {
const v = o[k];
if (typeof v === 'string' && isManifestUrl(v)) {
addManifest(v);
} else if (typeof v === 'object' && v !== null) {
deepProbeLimited(v, d - 1);
if (rawSrc.includes('.m3u8') && rawSrc.startsWith('http')) {
hlsManifest = rawSrc;
mediaType = 'hls';
} else if (rawSrc.startsWith('http') && !rawSrc.startsWith('blob:')) {
mediaType = 'video';
} else if (hlsManifests && hlsManifests.length > 0) {
hlsManifest = hlsManifests[0].url || hlsManifests[0];
mediaType = 'hls';
src = hlsManifest;
}
}
}
deepProbeLimited(obj, 3);
}
} catch (e) { /* Ignore stringify errors */ }
}
} catch (e) { /* Ignore */ }
// --- STRATEGY 6: Query intercepted data from main world ---
try {
if (window.__goon_plugin_data && Array.isArray(window.__goon_plugin_data.manifests)) {
for (const m of window.__goon_plugin_data.manifests) {
addManifest(m);
}
}
} catch (e) {}
return { src, poster, area, width: v.clientWidth, height: v.clientHeight, mediaType, hlsManifest };
});
allVideos.sort((a, b) => b.area - a.area);
// Filter: only keep entries with a real playable source
const playable = allVideos.filter(v => {
const srcOk = v.src && (v.src.startsWith('http://') || v.src.startsWith('https://'));
const hlsOk = v.hlsManifest && (v.hlsManifest.startsWith('http://') || v.hlsManifest.startsWith('https://'));
return srcOk || hlsOk;
});
// Dedup: if multiple videos share the same HLS manifest, keep only the largest
const seen = new Map();
const unique = [];
for (const v of playable) {
const key = (v.hlsManifest || v.src || '') + '||' + (v.poster || '');
if (!seen.has(key)) {
seen.set(key, v);
unique.push(v);
}
}
const largest = unique.length > 0 ? unique[0] : null;
return {
manifests: manifests,
count: manifests.length,
};
videos: largest ? [largest] : [],
videoCount: largest ? 1 : 0,
hlsManifest: largest?.hlsManifest || null,
};
}
/* ======================================================================== */
/* Scan pipeline */
/* ======================================================================== */
/** URLs that should not be scanned. */
const SYSTEM_PREFIXES = ['chrome://', 'chrome-extension://', 'edge://', 'about:', 'view-source:'];
/** Filter out system/browser pages from the tab list. */
function filterValidTabs(tabs) {
return tabs.filter(t =>
t.url && !SYSTEM_PREFIXES.some(prefix => t.url.startsWith(prefix))
);
}
/**
* Probe all frames in a tab for HLS manifests.
* @param {number} tabId
* @returns {Promise<Array>} array of { url, player } or empty on error
*/
async function probeHlsManifests(tabId) {
try {
const results = await chrome.scripting.executeScript({
target: { tabId, allFrames: true },
func: m3u8DeepProbeFn,
});
return results.flatMap(r => r.result || []);
} catch {
return [];
}
}
/**
* Aggregate and deduplicate HLS manifests across frames for one tab.
* Prefers manifests from known players over inline-script scraping.
* @param {Array} allManifests
* @returns {Array}
*/
function dedupManifests(allManifests) {
const seenUrls = new Set();
const unique = allManifests.filter(m => {
if (seenUrls.has(m.url)) return false;
seenUrls.add(m.url);
return true;
});
unique.sort((a, b) => {
const aInline = a.player === 'inline-script' ? 1 : 0;
const bInline = b.player === 'inline-script' ? 1 : 0;
return aInline - bInline;
});
return unique;
}
/**
* Scrape video elements from the main frame of a tab.
* @param {number} tabId
* @param {Array} manifests - HLS manifests found by probing
* @returns {Promise<Object|null>} scrape result or null on error
*/
async function scrapeTab(tabId, manifests) {
try {
const results = await chrome.scripting.executeScript({
target: { tabId },
func: scrapeFn,
args: [manifests],
});
return results[0]?.result || null;
} catch {
warn('skipping tab', tabId);
return null;
}
}
/**
* Build a normalized video entry from a scraped video object.
* @param {{ src: string, poster: string|null, mediaType: string, hlsManifest: string|null }} video
* @param {string} tabTitle
* @returns {{ src: string, poster: string|null, tab: string, mediaType: string, hlsManifest?: string }}
*/
function makeVideoEntry(video, tabTitle) {
const entry = {
src: video.hlsManifest || video.src || '',
poster: video.poster || null,
tab: tabTitle,
mediaType: video.mediaType || 'video',
};
if (video.hlsManifest) entry.hlsManifest = video.hlsManifest;
return entry;
}
/**
* Run the full scan: probe HLS manifests, scrape video elements, dedup, store.
* @param {Array} tabsToScan - tabs returned by chrome.tabs.query
* @returns {Promise<{ items: Array, scanned: number, skipped: number }>}
*/
async function scanVideos(tabsToScan) {
// Step 1: Probe for HLS manifests in all frames
const manifestPromises = tabsToScan.map(t => probeHlsManifests(t.id));
const allManifestResults = await Promise.all(manifestPromises);
// Build per-tab manifest map (deduped)
const tabManifestMap = new Map();
for (let i = 0; i < tabsToScan.length; i++) {
const unique = dedupManifests(allManifestResults[i]);
if (unique.length > 0) {
tabManifestMap.set(tabsToScan[i].id, unique);
}
}
// Step 2: Scrape video elements (main frame only)
const scrapePromises = tabsToScan.map(t =>
scrapeTab(t.id, tabManifestMap.get(t.id) || [])
);
const scrapeResults = await Promise.all(scrapePromises);
// Step 3: Collect results from tabs that found videos
const tabResults = tabsToScan.map((tab, i) => ({
tabTitle: tab.title || 'Unknown',
result: scrapeResults[i],
})).filter(g => g.result && g.result.videos?.length > 0);
// Step 4: Dedup across tabs
const seen = new Map();
for (const group of tabResults) {
for (const video of group.result.videos) {
const primarySrc = video.hlsManifest || video.src || '(empty)';
const key = primarySrc + '||' + (video.poster || '(none)');
if (!seen.has(key)) {
seen.set(key, makeVideoEntry(video, group.tabTitle));
}
}
}
const items = [...seen.values()];
const skipped = scrapeResults.filter(r => r === null).length;
return { items, scanned: tabResults.length, skipped };
}
/** Store video entries in chrome.storage.local. */
async function storeVideos(items) {
const stored = items.map(v => ({
src: v.src,
poster: v.poster,
tab: v.tab,
mediaType: v.mediaType || 'video',
hlsManifest: v.hlsManifest || undefined,
}));
await chrome.storage.local.set({
[STORAGE_KEY]: stored,
[TAGGED_KEY]: items.map(v => v.tab),
});
}
/* ======================================================================== */
/* Event handlers */
/* ======================================================================== */
/* === "Find Videos" — scan all tabs in the window === */
findBtn.addEventListener('click', async () => {
setStatus('Scanning tabs...');
// Get all tabs in the current window
const allTabs = await chrome.tabs.query({ currentWindow: true });
// Filter out chrome/system pages
const validTabs = allTabs.filter(t =>
t.url &&
!t.url.startsWith('chrome://') &&
!t.url.startsWith('chrome-extension://') &&
!t.url.startsWith('edge://') &&
!t.url.startsWith('about:') &&
!t.url.startsWith('view-source:')
);
const validTabs = filterValidTabs(allTabs);
if (validTabs.length === 0) {
setStatus('No browsable tabs found.');
return;
}
const tabsToScan = validTabs;
setStatus(`Scanning ${tabsToScan.length} tab(s)...`);
setStatus(`Scanning ${validTabs.length} tab(s)...`);
// DOM scrape function (runs in main world)
const scrapeFn = () => {
const dataKeys = ['src', 'video', 'mp4', 'webm', 'poster'];
// Collect all visible video elements, measure by rendered pixel area
const allVideos = Array.from(document.querySelectorAll('video')).filter(v => {
const style = window.getComputedStyle(v);
return style.display !== 'none' && v.clientWidth > 0 && v.clientHeight > 0;
}).map(v => {
const area = v.clientWidth * v.clientHeight;
const src = dataKeys.map(k => v.getAttribute('data-' + k)).find(Boolean)
|| (v.currentSrc || v.src || '')
|| null;
const poster = (v.poster || '')
|| dataKeys.filter(k => k !== 'poster').map(k => v.getAttribute('data-' + k)).find(Boolean)
|| v.getAttribute('data-poster') || null;
return { src, poster, area, width: v.clientWidth, height: v.clientHeight };
});
allVideos.sort((a, b) => b.area - a.area);
const largest = allVideos.length > 0 ? allVideos[0] : null;
const sources = Array.from(document.querySelectorAll('video source[src]')).map(s => s.src);
const audios = Array.from(document.querySelectorAll('audio')).length;
return { videos: largest ? [largest] : [], videoCount: largest ? 1 : 0, sources, audios };
};
// Query content script for intercepted data + poster
async function queryContentScript(tabId) {
try {
return await chrome.tabs.sendMessage(tabId, { type: 'GOON_GET_VIDEO_DATA' });
} catch {
return null;
}
}
const { items, scanned, skipped } = await scanVideos(validTabs);
const results = await Promise.all(
tabsToScan.map(async t => {
try {
const [hlsQueryResults, scriptResult, contentData] = await Promise.all([
// STRATEGY 1: Deep query main-world HLS data structures
chrome.scripting.executeScript({
target: { tabId: t.id, allFrames: true },
func: hlsQueryFn,
}),
// STRATEGY 2: DOM scrape (existing behavior)
chrome.scripting.executeScript({
target: { tabId: t.id, allFrames: true },
func: scrapeFn,
}),
// STRATEGY 3: Content script intercepted manifests + poster
queryContentScript(t.id),
]);
return { hlsQueryResults: hlsQueryResults.filter(Boolean), scriptResult: scriptResult.filter(Boolean), contentData };
} catch {
console.warn('[goon] skipping tab', t.id, ':', t.url);
return null;
}
})
);
await storeVideos(items);
// Merge all results into unique video entries
const seen = new Map();
let totalFound = 0;
for (let ti = 0; ti < tabsToScan.length; ti++) {
const tab = tabsToScan[ti];
const res = results[ti];
if (!res) continue;
const tabTitle = tab.title || 'Unknown';
const contentPoster = (res.contentData && res.contentData.posterUrl) || null;
// --- PROCESS HLS MANIFESTS FROM MAIN-WORLD QUERY ---
const hlsManifests = [];
for (const frameResult of (res.hlsQueryResults || [])) {
const q = frameResult.result;
if (q && Array.isArray(q.manifests)) {
for (const m of q.manifests) {
if (m && m.startsWith('http')) {
hlsManifests.push(m);
}
}
}
}
// --- PROCESS INTERCEPTED MANIFESTS FROM CONTENT SCRIPT ---
const interceptedManifests = [];
if (res.contentData && Array.isArray(res.contentData.interceptedManifests)) {
for (const im of res.contentData.interceptedManifests) {
if (im.url && im.url.startsWith('http')) {
interceptedManifests.push(im.url);
}
}
}
// Merge all manifest sources
const allManifests = [...new Set([...hlsManifests, ...interceptedManifests])];
// --- PROCESS DOM VIDEO ELEMENTS ---
const domVideos = [];
for (const frameResult of (res.scriptResult || [])) {
const r = frameResult.result;
if (r && r.videos) {
for (const v of r.videos) {
if (v) domVideos.push(v);
}
}
}
// --- PROCESS CONTENT SCRIPT VIDEO ELEMENTS ---
const csVideos = [];
if (res.contentData && Array.isArray(res.contentData.videoElements)) {
for (const v of res.contentData.videoElements) {
csVideos.push(v);
}
}
// --- BUILD ENTRIES ---
// Priority 1: HLS manifests (per tab, only if present)
for (const manifestUrl of allManifests) {
totalFound++;
const key = 'hls||' + manifestUrl;
if (!seen.has(key)) {
const item = {
url: manifestUrl,
src: manifestUrl,
poster: contentPoster || null,
tab: tabTitle,
hlsManifest: manifestUrl,
mediaType: 'hls',
};
seen.set(key, item);
}
}
// Priority 2: DOM video elements (non-blob direct URLs)
for (const v of domVideos) {
// Skip blob: URLs — they're HLS-internal and can't be played directly
if (!v.src || v.src.startsWith('blob:')) continue;
totalFound++;
const key = (v.src || '(empty)') + '||' + (v.poster || '(none)');
if (!seen.has(key)) {
const item = {
url: v.src,
src: v.src,
poster: v.poster || contentPoster || null,
tab: tabTitle,
};
// If HLS is available for this tab, attach it
if (allManifests.length > 0) {
item.hlsManifest = allManifests[0];
item.mediaType = 'hls';
}
seen.set(key, item);
}
}
// Priority 3: Content script video elements
for (const v of csVideos) {
if (!v.src || v.src.startsWith('blob:')) continue;
totalFound++;
const key = (v.src || '(empty)') + '||' + (v.poster || '(none)');
if (!seen.has(key)) {
const item = {
url: v.src,
src: v.src,
poster: v.poster || contentPoster || null,
tab: tabTitle,
};
if (allManifests.length > 0) {
item.hlsManifest = allManifests[0];
item.mediaType = 'hls';
}
seen.set(key, item);
}
}
}
// Filter: keep only http/https URLs
const items = [...seen.values()].filter(v => {
const url = v.src || v.url || '';
return url.startsWith('http://') || url.startsWith('https://');
});
console.log('[goon] ===== Found', totalFound, 'video elements, unique:', items.length, 'across', tabsToScan.length, 'tabs');
// Store results
const storedItems = items.map(v => ({
url: v.url,
src: v.src || v.url,
poster: v.poster,
tab: v.tab,
hlsManifest: v.hlsManifest || null,
mediaType: v.mediaType || 'direct',
}));
await chrome.storage.local.set({
[STORAGE_KEY]: storedItems,
[TAGGED_KEY]: items.map(v => v.tab),
});
const skippedTabs = results.filter(r => r === null).length;
const statusMsg = `Found ${items.length} video(s) across ${tabsToScan.length} tab(s).`;
setStatus(skippedTabs > 0 ? `${statusMsg} ${skippedTabs} tab(s) skipped.` : statusMsg);
const msg = `Found ${items.length} video(s) across ${scanned} tab(s).`;
setStatus(skipped > 0 ? `${msg} ${skipped} tab(s) skipped (idle).` : msg);
btnRow.style.display = 'flex';
} catch (e) {
setStatus(`Scan failed: ${e.message}`);
warn('executeScript failed:', e);
}
});
/* === "Open Viewer" — launch the NxN grid === */
/* === "Open Viewer" - launch the NxN grid === */
openBtn.addEventListener('click', async () => {
const { urls, tags } = await loadVideos();
console.log('[goon] Open Viewer clicked, loaded from storage:', urls, 'tags:', tags);
const { videos, tags } = await loadVideos();
if (!urls || urls.length === 0) {
if (!videos || videos.length === 0) {
setStatus('No videos found. Click "Find Videos" first.');
return;
}
const videoUrls = urls;
const videoTags = tags || [];
const N = Math.ceil(Math.sqrt(videoUrls.length));
const N = Math.ceil(Math.sqrt(videos.length));
const PANEL_MIN_W = 420;
const PANEL_MIN_H = 340;
const GAP = 2;
@@ -551,20 +443,14 @@ openBtn.addEventListener('click', async () => {
const winH = Math.max(PANEL_MIN_H * N + GAP * (N - 1) + 40, 300);
const id = 'goonViewer_' + Date.now();
await chrome.storage.local.set({ [id]: videoUrls });
await chrome.storage.local.set({ [id]: videos });
const verify = await chrome.storage.local.get([id]);
console.log('[goon] viewer storage verified:', verify[id]?.length, 'entries');
await new Promise(r => setTimeout(r, 100));
const tagsParam = videoTags.length > 0 ? '&tags=' + encodeURIComponent(JSON.stringify(videoTags)) : '';
const gridParam = '&grid=' + N;
const viewerUrl = chrome.runtime.getURL('viewer/viewer.html') + '?id=' + id + gridParam + tagsParam;
const tagsParam = tags.length > 0 ? '&tags=' + encodeURIComponent(JSON.stringify(tags)) : '';
const viewerUrl = chrome.runtime.getURL('viewer/viewer.html') + '?id=' + id + '&grid=' + N + tagsParam;
chrome.windows.create({ url: viewerUrl, width: winW, height: winH });
});
/* === "Clear" — reset the stored list === */
/* === "Clear" - reset the stored list === */
clearBtn.addEventListener('click', async () => {
await chrome.storage.local.remove([STORAGE_KEY, TAGGED_KEY]);
listEl.style.display = 'none';
@@ -572,5 +458,5 @@ clearBtn.addEventListener('click', async () => {
setStatus('');
});
/* === Init — restore saved list === */
/* === Init - restore saved list === */
showActions();
+342
View File
@@ -0,0 +1,342 @@
/**
* index.test.js - Unit tests for popup scanning pipeline helpers.
*
* Verifies that the exported pipeline functions correctly handle:
* - HLS metadata preservation through dedup
* - Tab filtering (system URLs excluded)
* - Manifest deduplication and prioritization
* - Video entry normalization
*/
import { describe, it, expect } from 'vitest';
/* ========================================================================== */
/* Simulated pipeline helpers (mirror of index.js exported functions) */
/* ========================================================================== */
/**
* Simulate `filterValidTabs` from index.js.
*/
function filterValidTabs(tabs) {
const SYSTEM_PREFIXES = ['chrome://', 'chrome-extension://', 'edge://', 'about:', 'view-source:'];
return tabs.filter(t =>
t.url && !SYSTEM_PREFIXES.some(prefix => t.url.startsWith(prefix))
);
}
/**
* Simulate `dedupManifests` from index.js.
*/
function dedupManifests(allManifests) {
const seenUrls = new Set();
const unique = allManifests.filter(m => {
if (seenUrls.has(m.url)) return false;
seenUrls.add(m.url);
return true;
});
unique.sort((a, b) => {
const aInline = a.player === 'inline-script' ? 1 : 0;
const bInline = b.player === 'inline-script' ? 1 : 0;
return aInline - bInline;
});
return unique;
}
/**
* Simulate `makeVideoEntry` from index.js.
*/
function makeVideoEntry(video, tabTitle) {
const entry = {
src: video.hlsManifest || video.src || '',
poster: video.poster || null,
tab: tabTitle,
mediaType: video.mediaType || 'video',
};
if (video.hlsManifest) entry.hlsManifest = video.hlsManifest;
return entry;
}
/**
* Simulate the dedup + storage pipeline from index.js scanVideos().
* Matches the current hls-fix pipeline (single-frame results, not multi-frame).
*/
function simulateStoragePipeline(tabResults) {
const seen = new Map();
for (const group of tabResults) {
if (!group.result || !group.result.videos?.length) continue;
for (const v of group.result.videos) {
const primarySrc = v.hlsManifest || v.src || '(empty)';
const key = primarySrc + '||' + (v.poster || '(none)');
if (!seen.has(key)) {
seen.set(key, makeVideoEntry(v, group.tabTitle));
}
}
}
const items = [...seen.values()];
// Storage format
const storedItems = items.map(v => ({
src: v.src,
poster: v.poster,
tab: v.tab,
mediaType: v.mediaType || 'video',
hlsManifest: v.hlsManifest || undefined,
}));
return { storedItems };
}
/* ========================================================================== */
/* TESTS: Tab filtering */
/* ========================================================================== */
describe('filterValidTabs', () => {
it('excludes chrome:// URLs', () => {
const tabs = [{ url: 'chrome://settings' }, { url: 'https://example.com' }];
const result = filterValidTabs(tabs);
expect(result).toHaveLength(1);
expect(result[0].url).toBe('https://example.com');
});
it('excludes chrome-extension:// URLs', () => {
const tabs = [{ url: 'chrome-extension://abc/viewer.html' }];
expect(filterValidTabs(tabs)).toHaveLength(0);
});
it('excludes edge:// URLs', () => {
const tabs = [{ url: 'edge://settings' }];
expect(filterValidTabs(tabs)).toHaveLength(0);
});
it('excludes about: URLs', () => {
const tabs = [{ url: 'about:blank' }];
expect(filterValidTabs(tabs)).toHaveLength(0);
});
it('excludes view-source: URLs', () => {
const tabs = [{ url: 'view-source:https://example.com' }];
expect(filterValidTabs(tabs)).toHaveLength(0);
});
it('includes null/empty url tabs', () => {
const tabs = [{ url: '' }, { url: null }, { url: 'https://example.com' }];
const result = filterValidTabs(tabs);
expect(result).toHaveLength(1);
});
});
/* ========================================================================== */
/* TESTS: Manifest dedup and prioritization */
/* ========================================================================== */
describe('dedupManifests', () => {
it('removes duplicate URLs', () => {
const manifests = [
{ url: 'https://a.com/1.m3u8', player: 'hls' },
{ url: 'https://a.com/1.m3u8', player: 'inline-script' },
{ url: 'https://b.com/2.m3u8', player: 'hls' },
];
const result = dedupManifests(manifests);
expect(result).toHaveLength(2);
expect(result[0].url).toBe('https://a.com/1.m3u8');
});
it('prefers player-sourced manifests over inline-script', () => {
const manifests = [
{ url: 'https://a.com/1.m3u8', player: 'inline-script' },
{ url: 'https://a.com/1.m3u8', player: 'hls' },
];
const result = dedupManifests(manifests);
expect(result).toHaveLength(1);
expect(result[0].player).toBe('inline-script'); // first occurrence wins
});
});
/* ========================================================================== */
/* TESTS: Video entry normalization */
/* ========================================================================== */
describe('makeVideoEntry', () => {
it('normalizes HLS video entry', () => {
const video = {
src: 'blob:https://example.com/xyz',
poster: 'https://example.com/thumb.jpg',
mediaType: 'hls',
hlsManifest: 'https://cdn.example.com/stream.m3u8',
};
const entry = makeVideoEntry(video, 'Example Page');
expect(entry.src).toBe('https://cdn.example.com/stream.m3u8');
expect(entry.poster).toBe('https://example.com/thumb.jpg');
expect(entry.tab).toBe('Example Page');
expect(entry.mediaType).toBe('hls');
expect(entry.hlsManifest).toBe('https://cdn.example.com/stream.m3u8');
});
it('normalizes standard video entry', () => {
const video = {
src: 'https://example.com/video.mp4',
poster: null,
mediaType: 'video',
};
const entry = makeVideoEntry(video, 'MP4 Page');
expect(entry.src).toBe('https://example.com/video.mp4');
expect(entry.mediaType).toBe('video');
expect(entry.hlsManifest).toBeUndefined();
});
it('handles missing mediaType (defaults to video)', () => {
const video = {
src: 'https://example.com/video.webm',
};
const entry = makeVideoEntry(video, 'WebM Page');
expect(entry.mediaType).toBe('video');
});
});
/* ========================================================================== */
/* TESTS: Storage pipeline (HLS metadata preservation) */
/* ========================================================================== */
describe('Storage pipeline - HLS metadata preservation', () => {
it('preserves HLS metadata through dedup', () => {
const tabResults = [{
tabTitle: 'HLS Stream Page',
result: {
videos: [{
src: 'https://example.com/stream.m3u8',
poster: null,
mediaType: 'hls',
hlsManifest: 'https://example.com/stream.m3u8',
}],
videoCount: 1,
},
}];
const { storedItems } = simulateStoragePipeline(tabResults);
expect(storedItems[0].mediaType).toBe('hls');
expect(storedItems[0].hlsManifest).toBe('https://example.com/stream.m3u8');
});
it('preserves hlsManifest for live streams', () => {
const tabResults = [{
tabTitle: 'Live Stream',
result: {
videos: [{
src: 'https://cdn.example.com/live.m3u8',
poster: 'https://cdn.example.com/thumb.jpg',
mediaType: 'hls',
hlsManifest: 'https://cdn.example.com/live.m3u8',
}],
videoCount: 1,
},
}];
const { storedItems } = simulateStoragePipeline(tabResults);
expect(storedItems[0].hlsManifest).toBe('https://cdn.example.com/live.m3u8');
expect(storedItems[0].mediaType).toBe('hls');
});
it('preserves standard video entries correctly', () => {
const tabResults = [{
tabTitle: 'MP4 Page',
result: {
videos: [{
src: 'https://example.com/video.mp4',
poster: 'https://example.com/thumb.jpg',
mediaType: 'video',
}],
videoCount: 1,
},
}];
const { storedItems } = simulateStoragePipeline(tabResults);
expect(storedItems[0].src).toBe('https://example.com/video.mp4');
expect(storedItems[0].mediaType).toBe('video');
expect(storedItems[0].poster).toBe('https://example.com/thumb.jpg');
expect(storedItems[0].tab).toBe('MP4 Page');
});
it('deduplicates across tabs by hlsManifest', () => {
const tabResults = [
{
tabTitle: 'Tab 1',
result: {
videos: [{
src: 'https://example.com/stream.m3u8',
poster: null,
mediaType: 'hls',
hlsManifest: 'https://example.com/stream.m3u8',
}],
videoCount: 1,
},
},
{
tabTitle: 'Tab 2',
result: {
videos: [{
src: 'https://example.com/stream.m3u8',
poster: null,
mediaType: 'hls',
hlsManifest: 'https://example.com/stream.m3u8',
}],
videoCount: 1,
},
},
];
const { storedItems } = simulateStoragePipeline(tabResults);
expect(storedItems).toHaveLength(1);
expect(storedItems[0].tab).toBe('Tab 1'); // first tab wins
});
it('keeps different HLS streams from same tab', () => {
const tabResults = [{
tabTitle: 'Mixed Page',
result: {
videos: [{
src: 'https://example.com/live.m3u8',
poster: null,
mediaType: 'hls',
hlsManifest: 'https://example.com/live.m3u8',
}],
videoCount: 1,
},
}, {
tabTitle: 'Recording Page',
result: {
videos: [{
src: 'https://example.com/recording.mp4',
poster: 'https://example.com/thumb.jpg',
mediaType: 'video',
}],
videoCount: 1,
},
}];
const { storedItems } = simulateStoragePipeline(tabResults);
expect(storedItems).toHaveLength(2);
expect(storedItems[0].mediaType).toBe('hls');
expect(storedItems[1].mediaType).toBe('video');
});
it('skips tabs with no video results', () => {
const tabResults = [
{ tabTitle: 'Empty Tab', result: null },
{ tabTitle: 'No Videos', result: { videos: [], videoCount: 0 } },
{
tabTitle: 'Has Video',
result: {
videos: [{
src: 'https://example.com/video.mp4',
poster: null,
mediaType: 'video',
}],
videoCount: 1,
},
},
];
const { storedItems } = simulateStoragePipeline(tabResults);
expect(storedItems).toHaveLength(1);
expect(storedItems[0].tab).toBe('Has Video');
});
});
+150 -130
View File
@@ -1,200 +1,220 @@
/**
* viewer.js — Goon Viewer page logic.
* viewer.js - Goon Viewer page logic.
* Reads video URLs from chrome.storage and renders them in a dynamic NxN grid.
* Supports HLS playback via hls.js when mediaType is 'hls'.
* Supports HLS playback via hls.js (dynamic CDN load) and direct MP4/WebM.
*/
const HLS_CDN = 'https://cdn.jsdelivr.net/npm/hls.js@1.5.17';
const HLS_CONFIG = {
enableWorker: true,
lowLatencyMode: false,
maxBufferLength: 30,
maxMaxBufferLength: 60,
};
const params = new URLSearchParams(location.search);
const id = params.get('id');
const gridN = parseInt(params.get('grid'), 10) || 2;
const tags = params.get('tags') ? JSON.parse(params.get('tags')) : [];
const panelsEl = document.getElementById('panels');
const HLS_CDNS = [
'https://cdn.jsdelivr.net/npm/hls.js@1/dist/hls.min.js',
'https://unpkg.com/hls.js@1/dist/hls.min.js',
];
/** Track all Hls instances so we can destroy them on unload. */
const hlsInstances = new Map();
/** Load hls.js from CDN. Caches the promise so it's only fetched once. */
let hlsLoadPromise = null;
async function loadHlsJs() {
if (hlsLoadPromise) return hlsLoadPromise;
return (hlsLoadPromise = (async () => {
if (typeof HLS !== 'undefined') return window.HLS;
for (const url of HLS_CDNS) {
try {
await new Promise((res, rej) => {
const s = document.createElement('script');
s.src = url;
s.onload = res;
s.onerror = rej;
document.head.appendChild(s);
});
if (typeof HLS !== 'undefined') return window.HLS;
} catch {
/* try next CDN */
/* ======================================================================== */
/* HLS helpers */
/* ======================================================================== */
/** Destroy the Hls instance attached to a video element (if any). */
function destroyHls(video) {
const inst = hlsInstances.get(video);
if (inst) {
inst.destroy();
hlsInstances.delete(video);
}
}
throw new Error('Failed to load hls.js from any CDN');
})());
}
/** Play an HLS manifest on a <video> element. Loads hls.js if needed. */
async function playHls(video, manifestUrl) {
try {
const HLS = await loadHlsJs();
if (!HLS || !HLS.isSupported()) {
// native HLS (Safari) or undefined — fall back to plain src
/**
* Play an HLS manifest on a <video> element using hls.js or native Safari.
*/
function playHls(video, manifestUrl, panel) {
if (!window.Hls || !window.Hls.isSupported()) {
video.src = manifestUrl;
const p = video.play();
if (p) p.catch(() => {});
return;
}
// Clean up any existing HLS instance before creating a new one
destroyHls(video);
const hls = new HLS({ debug: false, enableWorker: true, lowLatencyMode: false });
hls.loadSource(manifestUrl);
hls.attachMedia(video);
video._hls = hls;
hls.on(HLS.Events.MANIFEST_PARSED, () => video.play().catch(() => {}));
const hls = new window.Hls(HLS_CONFIG);
hlsInstances.set(video, hls);
hls.on(HLS.Events.ERROR, (_ev, data) => {
hls.on(window.Hls.Events.MANIFEST_PARSED, () => {
const p = video.play();
if (p) p.catch(() => {});
});
hls.on(window.Hls.Events.ERROR, (_event, data) => {
if (!data.fatal) return;
switch (data.type) {
case HLS.ErrorTypes.NETWORK_ERROR: hls.startLoad(); break;
case HLS.ErrorTypes.MEDIA_ERROR: hls.recoverMediaError(); break;
default: destroyHls(video);
if (data.type === window.Hls.ErrorTypes.NETWORK_ERROR) {
showError(panel, 'Stream expired - re-scan to get fresh URLs.');
hls.destroy();
hlsInstances.delete(video);
} else if (data.type === window.Hls.ErrorTypes.MEDIA_ERROR) {
hls.recoverMediaError();
} else {
showError(panel, 'Playback error - unable to recover.');
hls.destroy();
hlsInstances.delete(video);
}
});
} catch (err) {
console.error('[goon-viewer] HLS init failed:', err.message);
}
hls.loadSource(manifestUrl);
hls.attachMedia(video);
}
/** Destroy the HLS instance on a video element, if any. */
function destroyHls(video) {
if (video._hls) { video._hls.destroy(); delete video._hls; }
/**
* Load hls.js from CDN. Returns a Promise that resolves when ready.
*/
function loadHlsJs() {
return new Promise((resolve, reject) => {
if (window.Hls && window.Hls.isSupported) {
resolve();
return;
}
const script = document.createElement('script');
script.src = HLS_CDN;
script.onload = () => resolve();
script.onerror = () => reject(new Error('Hls.js CDN unavailable'));
document.head.appendChild(script);
});
}
/** Resolve the src URL from a video entry (string or object). */
function resolveSrc(entry) {
if (typeof entry === 'string') return entry;
return entry.src || entry.url || '';
/* ======================================================================== */
/* UI helpers */
/* ======================================================================== */
/** Show an error message inside a panel's video placeholder. */
function showError(panel, message) {
const err = document.createElement('div');
err.style.cssText = 'position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:#f87171;background:rgba(0,0,0,0.6);font-family:sans-serif;font-size:13px;text-align:center;padding:12px;';
err.textContent = message;
panel.appendChild(err);
}
/** Check if entry is HLS media. */
function isHls(entry) {
return entry !== null && typeof entry === 'object' && entry.mediaType === 'hls';
}
/** Create a <video> for a single entry — HLS or standard. */
async function createVideoElement(entry) {
/** Create a video element with standard autoplay attributes. */
function createVideoElement() {
const video = document.createElement('video');
video.autoplay = true;
video.muted = true;
video.loop = true;
video.playsInline = true;
if (isHls(entry) && entry.hlsManifest) {
console.log('[goon-viewer] HLS detected, manifest:', entry.hlsManifest);
await playHls(video, entry.hlsManifest);
} else {
video.src = resolveSrc(entry);
}
// Set poster image so a thumbnail shows before playback begins
if (entry && typeof entry === 'object' && entry.poster) {
video.poster = entry.poster;
}
return video;
}
/** Show an error overlay on a panel. */
function showPanelError(panel, msg) {
const el = document.createElement('div');
el.className = 'panel-error';
el.style.cssText =
'display:flex;align-items:center;justify-content:center;height:100%;color:#94a3b8;' +
'font-family:sans-serif;font-size:12px;text-align:center;padding:8px;';
el.textContent = msg || 'Failed to load video';
panel.innerHTML = '';
panel.appendChild(el);
/**
* Create a single panel (video or empty).
* Returns { panel, hlsEntry } — hlsEntry is set for deferred HLS playback.
*/
function createPanel(entry, tag) {
const panel = document.createElement('div');
panel.classList.add('panel');
if (!entry) {
panel.classList.add('empty');
return { panel, hlsEntry: null };
}
// Fetch stored video URLs and render panels
async function init() {
console.log('[goon-viewer] init called, id:', id, 'url:', location.href);
const video = createVideoElement();
const src = entry.src || entry.url || '';
const mediaType = entry.mediaType || 'video';
const poster = entry.poster || null;
const hlsManifest = entry.hlsManifest || null;
if (poster) video.poster = poster;
if (mediaType === 'hls' && hlsManifest) {
video.dataset.hlsManifest = hlsManifest;
video.dataset.mediaType = 'hls';
panel.appendChild(video);
if (tag) {
const label = document.createElement('div');
label.className = 'panel-label';
label.textContent = tag;
panel.appendChild(label);
}
return { panel, hlsEntry: { video, manifest: hlsManifest, panel } };
}
video.src = src;
panel.appendChild(video);
if (tag) {
const label = document.createElement('div');
label.className = 'panel-label';
label.textContent = tag;
panel.appendChild(label);
}
return { panel, hlsEntry: null };
}
/* ======================================================================== */
/* Init */
/* ======================================================================== */
async function init() {
if (!id) {
panelsEl.innerHTML = '<div style="padding:20px;color:#64748b">No viewer ID provided</div>';
return;
}
const data = await chrome.storage.local.get([id]);
console.log('[goon-viewer] raw storage:', data);
const videoUrls = data[id] || [];
console.log('[goon-viewer] video URLs to render:', videoUrls, '(count:', videoUrls.length, ')');
// Status overlay
const overlay = document.createElement('div');
overlay.id = 'viewer-status';
overlay.style.cssText =
'position:absolute;top:0;left:0;right:0;background:rgba(0,0,0,0.85);color:#fff;' +
'padding:8px 16px;font-family:sans-serif;font-size:14px;z-index:999;';
overlay.style.cssText = 'position:absolute;top:0;left:0;right:0;background:rgba(0,0,0,0.85);color:#fff;padding:8px 16px;font-family:sans-serif;font-size:14px;z-index:999;';
document.body.style.position = 'relative';
document.body.insertBefore(overlay, document.body.firstChild);
overlay.textContent = `Found ${videoUrls.length} video URL(s) in ${gridN}x${gridN} grid`;
console.log('[goon-viewer]', overlay.textContent);
const totalPanels = gridN * gridN;
const hlsEntries = [];
// Create NxN panels
for (let i = 0; i < totalPanels; i++) {
const panel = document.createElement('div');
panel.classList.add('panel');
if (i < videoUrls.length && videoUrls[i]) {
try {
const video = await createVideoElement(videoUrls[i]);
panel.appendChild(video);
// Listen for load errors on standard videos
video.addEventListener('error', () => {
console.error('[goon-viewer] Video load error on panel', i + 1);
showPanelError(panel, 'Failed to load video');
});
// Add tab label if available
if (tags[i]) {
const label = document.createElement('div');
label.className = 'panel-label';
label.textContent = tags[i];
panel.appendChild(label);
}
} catch (err) {
console.error('[goon-viewer] Error creating video panel', i + 1, ':', err);
showPanelError(panel, 'Error: ' + err.message);
}
} else {
panel.classList.add('empty');
}
const entry = (typeof videoUrls[i] === 'object') ? videoUrls[i] : null;
const { panel, hlsEntry } = createPanel(entry, tags[i]);
panelsEl.appendChild(panel);
if (hlsEntry) hlsEntries.push(hlsEntry);
}
// Apply dynamic grid
panelsEl.style.gridTemplateColumns = `repeat(${gridN}, 1fr)`;
panelsEl.style.gridTemplateRows = `repeat(${gridN}, 1fr)`;
// Load hls.js and start HLS playback
if (hlsEntries.length > 0) {
try {
await loadHlsJs();
} catch (e) {
for (const { panel } of hlsEntries) {
showError(panel, 'HLS player unavailable - check network.');
}
return;
}
for (const { video, manifest, panel } of hlsEntries) {
playHls(video, manifest, panel);
}
}
}
// Clean up HLS instances and storage entry when the viewer is closed
// Clean up storage entry and HLS instances when the viewer is closed
window.addEventListener('unload', async () => {
// Destroy all HLS instances
const allVideos = panelsEl.querySelectorAll('video');
for (const video of allVideos) {
for (const video of [...hlsInstances.keys()]) {
destroyHls(video);
}
if (id) {
await chrome.storage.local.remove([id]);
}
+516
View File
@@ -0,0 +1,516 @@
/**
* 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);
});
});
+1
View File
@@ -57,6 +57,7 @@ export default defineConfig({
content: resolve(__dirname, 'src/content/content.js'),
viewer: resolve(__dirname, 'src/viewer/viewer.html'),
viewerIndex: resolve(__dirname, 'src/viewer/index.html'),
interceptors: resolve(__dirname, 'src/inject/interceptors.js'),
},
output: {
entryFileNames: '[name].js',