Compare commits

...
16 Commits
Author SHA1 Message Date
kfj001 7336643ea4 Merge pull request 'feat: full HLS.js support — detection via interceptors + deep probe, playback via hls.js in viewer' (#2) from hls-fix into master
CI / test (push) Failing after 16m10s
Reviewed-on: http://192.168.1.74:30008/kfj001/goon_plugin/pulls/2
Reviewed-by: kfj001 <kfj001@gmail.com>
2026-08-23 18:22:41 -07:00
kfj001 63e5c600dc Adds step to generate artifacts in ci/cd env.
CI / test (pull_request) Successful in 11m30s
2026-08-24 01:10:08 +00:00
kfj001 b6a11d9e46 fixes broken unit test
CI / test (pull_request) Failing after 11m2s
2026-08-23 17:38:14 -07:00
kfj001 c9f49b58d8 Working on pornhub
CI / test (pull_request) Failing after 10m34s
2026-08-20 10:59:05 -07:00
kfj001 001cf146ee Attempted fix at pornhub video
CI / test (pull_request) Failing after 10m39s
2026-08-16 23:00:47 -07:00
kfj001 b234c8dcae fix: HLS playback — CORS proxy for phncdn.com + diagnostic logging
Problem: Even after fixing the hls.js file naming (was 'hls.min.js', now
'hls.js'), videos don't play in the viewer. Likely root cause is CORS
blocking chrome-extension:// requests to phncdn.com CDN URLs that lack
CORS headers for extension origins.

Solution — two-layer fix:

1. Service worker proxy (background.js):
   - New GOON_PROXY_FETCH handler fetches any URL via native fetch()
     (no CORS restrictions in the service worker)
   - Returns base64-encoded response data to the viewer

2. Custom HLS loader (viewer/viewer.js):
   - HlsLoader implements hls.js Loader interface
   - For phncdn.com/pornhubcdn URLs: proxies via GOON_PROXY_FETCH
   - For all other URLs: uses native XHR (bypasses proxy overhead)
   - Config passed to hls.js only when manifest URL needs proxying

3. Diagnostics (viewer/viewer.js):
   - CORS check at page load for phncdn.com and per-video URL
   - Full storage dump of video entries in console
   - Manifest URL logging on each HLS load attempt
   - Proxy activation logging for debugging
2026-08-16 16:42:12 -07:00
kfj001 fa0a0d76cd fix: rename hls.min.js -> hls.js to match vite build output
The vite config outputs 'hls.js' but manifest.json and viewer.js
both referenced 'hls.min.js', causing chrome.runtime.getURL() to
return a 404 — triggering 'HLS player unavailable'.

- Update web_accessible_resources entry in manifest.json
- Update loadHlsJs() chrome.runtime.getURL reference
- Add debug log of manifest URL being loaded for diagnostics
2026-08-16 16:28:55 -07:00
kfj001 509948def1 feat: deep probe strategies G (non-enumerable/Symbol) and H (full window scan)
Strategy G — Independent non-enumerable + Symbol property detection on video elements.
Uses Object.getOwnPropertyNames/Symbols to catch properties hidden by enumerable:false or Symbol keys.
Walks nested objects for URL/levels/config signatures matching HLS instance heuristics.

Strategy H — Full window global enumeration with player-like key filtering.
Catches previously-unknown globals the player wrapper exposes internally.

Content script bridge — merge content-level intercepted manifests into
window.__goon_plugin_data so both interceptors and executeScript share findings.

Add phncdn_reconstructed to dedup priority ranking.
2026-08-16 16:22:07 -07:00
kfj001 877feec731 feat: Strategy 8 — enhance phncdn.com network fallback detection
- Log ALL phncdn.com requests (not just .m3u8) to chrome.storage.local
- Reconstruct manifest URLs from segment URL patterns (/videos/{id}/playlist.m3u8)
- Add GOON_GET_PHNCDN_REQUESTS message handler for popup consumption
- Add queryPhncdnRequests() consumer in viewer/index.js
- Integrate reconstructed manifests into scan pipeline (step 1.6)
2026-08-16 16:17:29 -07:00
robot a1f4d2241b fix: vendor hls.js locally (CSP) and handle null executeScript results
CI / test (pull_request) Failing after 24s
Two fixes addressing kfj001 review #37 on commit b19035a:

1. Vendor hls.js bundle locally — chrome.runtime.getURL('hls.min.js')
   instead of CDN, bypassing site CSP (script-src 'self') blocks.
   Added hls.min.js to vite.config.ts entry points, manifest web_accessible_resources.

2. Handle null entries in executeScript results — destroyed frames
   return null when allFrames:true, causing 'Cannot read properties of
   null (reading 'result')' in probeHlsManifests() and scrapeTab().
   Added null guards before accessing .result.

83/83 tests pass. Build: 10 modules, 413KB hls bundle.
2026-08-13 08:55:04 +00:00
robot b19035ad29 fix: capture HLS segment playback state — prevent fragmented / lost overlay states
CI / test (pull_request) Successful in 22s
Bug fixes:
- FRAG_LOADING: stop updating lastActivity and re-rendering during active
  playback. It fires continuously as hls.js proactively fetches future
  segments, causing overlay flickering between buffering/playing.
- BUFFER_RESET: stop clearing segmentsLoaded. The event fires during
  quality switches when hls.js flushes and rebuilds the MediaSource buffer,
  but segments were already played. Clearing the counter made the overlay
  show '0 seg' mid-playback.
- FRAG_BUFFERED: add bufferedAhead update immediately after a fragment is
  buffered, instead of waiting for the next 1s monitor tick.
- LEVEL_SWITCHED: only set phase='switching' when buffer is low (< 2s).
  With meaningful buffer the switch is instant (already-buffered segments),
  so skip the intermediate state to avoid panels getting stuck.
- Buffer monitor: add change-detection so renderPanelState only fires when
  phase, segments, or quality actually changed — not every second.
- wireVideoEvents: add 'ended' event handler for completeness.
2026-08-13 06:29:22 +00:00
robot 8e9f7f96f1 refactor: audit fixes — clean up bugs, dead code, and type mismatches
CI / test (pull_request) Successful in 23s
- viewer.js: remove undefined state.segmentsBuffered assignment in BUFFER_RESET
- viewer.js: add missing 'switching' and 'ended' phases to PHASE_ICONS
- index.js: remove redundant Strategy B3 (duplicated B1 video-element walk)
- index.js: filter standard <video> DOM attributes from custom-property scans
  (m3u8DeepProbeFn B1 and scrapeFn Priority 3) to avoid iterating built-in
  properties that can never contain HLS data
- background.js: remove unconditional return true (handlers are synchronous)
  and remove dead error-response for unknown message types
- types.ts: align MediaType union ('direct' | 'hls') with actual runtime
  value used in code ('video' | 'hls')
- package.json: remove hls.js from dependencies (loaded from CDN at runtime,
  never imported as an npm module)
2026-08-13 04:59:59 +00:00
Hermes QA ae8608148d refactor: clean up and refactor PR code
CI / test (pull_request) Successful in 23s
- Refactor duplicated video event listener boilerplate in wireVideoEvents()
  into onVideoEvent() helper (viewer.js: -57 lines)
- Fix 3 ESLint no-unused-vars warnings (bare catch instead of unused _e)
- Fix 1 additional no-unused-vars: unused 'data' param in MANIFEST_LOADED
- Remove dead 'Hls.cast' entry from playerRank map (no strategy produces it)
- Remove deprecated .eslintignore file (ESLint v9 uses flat config only)
- Add __tests__/ to ESLint ignores in flat config

Net: -20 lines (46 ins, 66 del). 83/83 tests pass, lint clean (0 warnings)
2026-08-13 02:58:41 +00:00
robot e11be9a20a fix: HLS playback tracking and detection fallback for sandboxed iframes
CI / test (pull_request) Successful in 23s
Viewer fixes (6):
- FRAG_LOADING: only set buffering phase when video is actually stalled
  (paused or readyState < HAVE_FUTURE_DATA), prevents overlay flicker
- FRAG_BUFFERED: allow playing transition from any phase except error,
  require readyState >= 3 and video not paused
- Add FRAG_DECRYPTED, FRAG_PARSING_INIT_SEGMENT, FRAG_PARSING_USERDATA
  event tracking for full fragment pipeline visibility
- BUFFER_RESET: also reset segmentsBuffered counter (was missing)
- LEVEL_SWITCHED: set phase to 'switching' for accurate UI feedback
- Fatal network errors: track in segmentsFailed counter
- Add ENDLIST event handler for stream completion detection

Detection fixes:
- Add filterManifests(): prefer .m3u8 manifest URLs over .ts segments,
  reconstruct manifest URL from segment paths when needed
- Add fallback in scanVideos(): when scrapeFn returns no visible <video>
  elements (sandboxed iframe), create video entries from intercepted
  manifest data. Fixes '0 videos detected' issue on Pornhub and similar
  sites where the video player is in an inaccessible iframe.
2026-08-12 23:51:32 +00:00
robot 3cf402d3c7 fix: Hls.cast dead code, double-patch interceptors, manifest URL patterns
CI / test (pull_request) Successful in 32s
Hls.cast() is a TypeScript identity cast in hls.js — returns input unchanged,
NOT a reverse-lookup. Replaced with scanning video element properties for
HLS instance references real players store as custom properties.

Fixed double-patching bug in interceptors.js and expanded URL pattern
matching to cover /hls/, /playlist/, /manifest/, .hlsvod paths.

83/83 tests pass, clean build (9 modules).
2026-08-12 21:21:03 +00:00
robot 542c5786be feat: add HLS playback support with hls.js and modular viewer architecture
CI / test (pull_request) Successful in 23s
This PR adds end-to-end HLS stream detection, interception, and playback
to the viewer. The following root causes from the QA audit are fixed:

- Restore dropped HLS detection strategies (6-strategy per-tab query)
- Add deep-probe scanning for config globals and iframe manifests
- Add standalone interceptors.js for CSP-compliant runtime injection
- Fix viewer.js string entry handling for legacy storage entries
- Fix manifest.json web_accessible_resources for interceptors.js
- Disable modulePreload polyfill for Chrome MV3 compatibility
- Run deep probe and scraper in MAIN world for page global access
- Fallback to src as HLS manifest when hlsManifest field is missing

The viewer.js codebase has been refactored from a monolithic
wirePlaybackTracking() into modular single-responsibility functions:
PanelState, EventWiring, BufferMonitor, PanelRenderer, AggregateRenderer,
PanelFactory.

Additionally:
- Remove debug console.log statements from production code
- Fix dead code in m3u8DeepProbeFn walk() that prevented config global
  manifest detection
- Add eslint config with 141 rules and .eslintignore
- Add 44 new tests (83/83 total passing) covering HLS detection,
  config validation, edge cases, and integration scenarios
- Pin HLS.js CDN version to 1.5.17 (not floating/latest)
- Add grid-optimized HLS config with 10s buffer cap
- Add cleanup hooks for HLS instance destruction on page unload

Changed files (18):
- src/viewer/viewer.js (+834)
- src/viewer/index.js (+870)
- src/viewer/viewer.test.js (+976, new)
- src/viewer/index.test.js (+481, new)
- src/inject/interceptors.js (+169, new)
- src/content/content.js (+139)
- __tests__/hls_qa.test.mjs (+1168, new)
- manifest.json (+6)
- vite.config.ts (+5)
- __tests__/run.mjs (+2)
- __tests__/config_validation.test.mjs (+102, new)
- eslint.config.js (+24, new)
- .eslintignore (+1, new)
- QA_REPORT_HLS.md (+160, new)
- __tests__/data_flow.test.mjs (+/-5)
- __tests__/schema.test.mjs (+/-5)
- __tests__/viewer_integration.test.mjs (+/-4)
- src/background/background.js (-2)
2026-08-12 14:13:55 +00:00
24 changed files with 6728 additions and 732 deletions

No files matched your search

+2 -1
View File
@@ -2,7 +2,8 @@
"permissions": { "permissions": {
"allow": [ "allow": [
"Bash(npm run *)", "Bash(npm run *)",
"Bash(node --check /home/kyle/Documents/goon_plugin/src/viewer/index.js)" "Bash(node --check /home/kyle/Documents/goon_plugin/src/viewer/index.js)",
"Bash(open -a \"Microsoft Edge\" --args --remote-debugging-port=9222 --profile-directory=Default)"
] ]
} }
} }
+2
View File
@@ -19,6 +19,8 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: npm ci run: npm ci
- name: Build Articacts
run: npm run build
- name: Run tests - name: Run tests
run: npm test run: npm test
+102
View File
@@ -0,0 +1,102 @@
/**
* 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');
}
});
it('manifest.json must declare interceptors.js in web_accessible_resources', () => {
const manifest = JSON.parse(
readFileSync(resolve(root, 'manifest.json'), 'utf8')
);
const war = manifest.web_accessible_resources;
if (!war || !Array.isArray(war)) {
throw new Error(
'manifest.json has no web_accessible_resources. ' +
'interceptors.js needs this for chrome.runtime.getURL() to work.'
);
}
const allResources = war.flatMap(entry => entry.resources || []);
if (!allResources.includes('interceptors.js')) {
throw new Error(
'manifest.json web_accessible_resources must include interceptors.js. ' +
'Without it, the browser blocks chrome.runtime.getURL("interceptors.js").'
);
}
});
});
+4 -1
View File
@@ -1,7 +1,10 @@
import { describe, it } from './runner.mjs'; import { describe, it } from './runner.mjs';
import Ajv from 'ajv'; import Ajv from 'ajv';
import addFormats from 'ajv-formats'; import addFormats from 'ajv-formats';
import schema from '../src/viewer/schema.json' with { type: 'json' }; import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const schema = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), '../src/viewer/schema.json'), 'utf8'));
const ajv = new Ajv({ strict: false }); const ajv = new Ajv({ strict: false });
addFormats(ajv); addFormats(ajv);
+932
View File
@@ -0,0 +1,932 @@
// HLS.js detection and tracking verification tests
// Covers scenarios specific to sites that use hls.js for video playback:
// - Dynamic/late HLS.js loading (polling + MutationObserver)
// - Content script message collection
// - Video playback state machine transitions
// - HLS.js error recovery
// - Manifest URL edge cases (query strings, protocol-relative)
// - End-to-end interceptor -> content script -> deep probe pipeline
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'
);
const interceptorSource = fs.readFileSync(
path.join(__dirname, '../src/inject/interceptors.js'),
'utf-8'
);
const contentSource = fs.readFileSync(
path.join(__dirname, '../src/content/content.js'),
'utf-8'
);
const indexSource = fs.readFileSync(
path.join(__dirname, '../src/viewer/index.js'),
'utf-8'
);
/* ========================================================================== */
/* Helpers */
/* ========================================================================== */
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;
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();
}),
},
},
};
const hlsCalls = { instances: [], loaded: false };
if (hlsMode === 'mocked') {
win.Hls = class MockHLS {
constructor(opts) {
this.levels = [];
hlsCalls.instances.push({
opts, destroyed: false, events: {}, source: null, media: null,
levels: [],
});
}
static get Events() {
return {
MANIFEST_PARSED: 'manifestParsed',
MANIFEST_LOADED: 'manifestLoaded',
FRAG_LOADING: 'fragLoading',
FRAG_LOADED: 'fragLoaded',
FRAG_BUFFERED: 'fragBuffered',
BUFFER_APPENDED: 'bufferAppended',
BUFFER_FLUSHED: 'bufferFlushed',
BUFFER_RESET: 'bufferReset',
LEVEL_SWITCHED: 'levelSwitched',
LEVEL_UPDATED: 'levelUpdated',
FPS_DROP: 'fpsDrop',
FPS_MAX_LEVEL_DROP: 'fpsMaxLevelDrop',
MEDIA_ATTACHED: 'mediaAttached',
MEDIA_DETACHED: 'mediaDetached',
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;
}
const consoleLogs = [];
win.console = {
log: (...args) => consoleLogs.push(args.map(String).join(' ')),
error: (...args) => consoleLogs.push('ERROR: ' + args.map(String).join(' ')),
warn: (...args) => consoleLogs.push('WARN: ' + args.map(String).join(' ')),
};
return { dom, win, storage, consoleLogs, hlsCalls };
}
/* ========================================================================== */
/* Test: Dynamic/late HLS.js loading (polling + MutationObserver) */
/* ========================================================================== */
describe('HLS.js: Dynamic late loading detection', () => {
it('should patch Hls prototype via polling when Hls appears after interceptors load', () => {
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', {
url: 'https://example.com/watch',
runScripts: 'dangerously',
});
const win = dom.window;
const intercepted = [];
win.postMessage = (msg) => {
if (msg.type === 'GOON_PLUGIN_HLS_INTERCEPT') intercepted.push(msg);
};
win.fetch = () => {};
// Load interceptors BEFORE Hls is defined
win.eval(interceptorSource);
// Simulate hls.js loading dynamically AFTER interceptors
win.Hls = class {
loadSource(url) { this._url = url; }
attachMedia(media) {}
};
Object.defineProperty(win.Hls.prototype, 'url', {
get() { return this._url || ''; }
});
// Wait for polling to detect and patch Hls (polls every 50ms)
return new Promise((resolve, reject) => {
setTimeout(() => {
try {
const hls = new win.Hls();
hls.loadSource('https://cdn.example.com/late.m3u8');
const hlsIntercepts = intercepted.filter(m => m.source === 'hls_loadSource');
if (hlsIntercepts.length === 0) {
throw new Error('Late-loaded HLS was not intercepted via polling. Sources: ' + intercepted.map(m => m.source).join(', '));
}
resolve();
} catch (e) { reject(e); }
}, 200);
});
});
it('should detect HLS.js loaded via <script> tag injection (MutationObserver)', () => {
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', {
url: 'https://example.com/watch',
runScripts: 'dangerously',
});
const win = dom.window;
const intercepted = [];
win.postMessage = (msg) => {
if (msg.type === 'GOON_PLUGIN_HLS_INTERCEPT') intercepted.push(msg);
};
win.fetch = () => {};
// Load interceptors before Hls exists
win.eval(interceptorSource);
if (win.eval('typeof Hls') !== 'undefined') {
throw new Error('Hls should not be defined before script tag injection');
}
// Simulate the page adding a <script> tag for hls.js
const scriptEl = win.document.createElement('script');
scriptEl.src = 'https://cdn.example.com/hls.min.js';
win.document.head.appendChild(scriptEl);
// After the MutationObserver fires, define Hls (simulating script load)
win.Hls = class {
loadSource(url) { this._url = url; }
attachMedia(media) {}
};
Object.defineProperty(win.Hls.prototype, 'url', {
get() { return this._url || ''; }
});
return new Promise((resolve, reject) => {
setTimeout(() => {
try {
const hls = new win.Hls();
hls.loadSource('https://cdn.example.com/mutation-observer.m3u8');
const hlsIntercepts = intercepted.filter(m => m.source === 'hls_loadSource');
if (hlsIntercepts.length === 0) {
throw new Error('MutationObserver did not trigger Hls patching');
}
resolve();
} catch (e) { reject(e); }
}, 200);
});
});
it('should NOT re-patch Hls prototype if already patched (idempotent patch)', () => {
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', {
url: 'https://example.com/watch',
runScripts: 'dangerously',
});
const win = dom.window;
const intercepted = [];
win.postMessage = (msg) => {
if (msg.type === 'GOON_PLUGIN_HLS_INTERCEPT') intercepted.push(msg);
};
win.fetch = () => {};
win.Hls = class {
loadSource(url) { this._url = url; }
};
Object.defineProperty(win.Hls.prototype, 'url', {
get() { return this._url || ''; }
});
// Load interceptors twice
win.eval(interceptorSource);
win.eval(interceptorSource);
win.fetch('https://cdn.example.com/stream.m3u8');
if (intercepted.length !== 1) {
throw new Error(`Expected 1 intercept after double injection, got ${intercepted.length}`);
}
});
});
/* ========================================================================== */
/* Test: Content script message collection */
/* ========================================================================== */
describe('HLS.js: Content script manifest collection', () => {
it('should collect manifests from postMessage (GOON_PLUGIN_HLS_INTERCEPT)', () => {
if (!contentSource.includes("event.data.type === 'GOON_PLUGIN_HLS_INTERCEPT'")) {
throw new Error('Content script missing GOON_PLUGIN_HLS_INTERCEPT message handler');
}
if (!contentSource.includes('interceptedSources.has(url)')) {
throw new Error('Content script does not deduplicate intercepted manifests');
}
if (!contentSource.includes('interceptedManifests.push')) {
throw new Error('Content script does not collect intercepted manifests');
}
});
it('should respond to GOON_GET_VIDEO_DATA with intercepted manifests', () => {
if (!contentSource.includes("message.type === 'GOON_GET_VIDEO_DATA'")) {
throw new Error('Content script missing GOON_GET_VIDEO_DATA message handler');
}
if (!contentSource.includes('interceptedManifests: interceptedManifests')) {
throw new Error('Content script does not return intercepted manifests in response');
}
});
it('should filter postMessage from non-self sources', () => {
if (!contentSource.includes('event.source !== window')) {
throw new Error('Content script does not filter iframe/cross-origin postMessages');
}
});
it('should inject interceptors via script.src (not inline textContent)', () => {
// Verify content.js uses script.src = chrome.runtime.getURL(...) pattern
// This is the CSP-safe approach vs inline script.textContent
const injectFn = contentSource.match(/function injectInterceptors\(\)[\s\S]*?\n\}/);
if (!injectFn) throw new Error('injectInterceptors function not found');
if (!injectFn[0].includes('script.src = chrome.runtime.getURL')) {
throw new Error('Content script does not use script.src = chrome.runtime.getURL for injection');
}
// Verify it does NOT set script.textContent (which CSP blocks)
// We check actual assignment, not comments — strip comments first
const noComments = injectFn[0].replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*$/gm, '');
if (noComments.includes('script.textContent')) {
throw new Error('Content script uses inline script.textContent injection — blocked by CSP');
}
});
});
/* ========================================================================== */
/* Test: Video playback state machine transitions */
/* ========================================================================== */
describe('HLS.js: Playback state machine', () => {
it('should transition from idle -> loading -> buffering -> playing on normal playback', () => {
const storageData = {};
const viewerId = 'test_state_machine';
storageData[viewerId] = [{ url: 'x', src: 'x', hlsManifest: 'https://cdn.example.com/stream.m3u8', mediaType: 'hls' }];
const url = `https://example.com/viewer/viewer.html?id=${viewerId}&grid=1`;
const { win, hlsCalls } = createBrowserEnv({ storageData, url, hlsMode: 'mocked' });
win.eval(viewerSource);
return new Promise((resolve, reject) => {
setTimeout(() => {
try {
const inst = hlsCalls.instances[0];
if (!inst) throw new Error('No HLS instance');
// 1. MEDIA_ATTACHED -> loading
inst.events['mediaAttached'][0]('mediaAttached');
// 2. MANIFEST_LOADED -> loading
inst.events['manifestLoaded'][0]('manifestLoaded', { levels: [{ width: 1280, height: 720 }] });
// 3. FRAG_LOADING (video.paused=true) -> buffering
const video = win.document.querySelector('video');
Object.defineProperty(video, 'paused', { value: true, configurable: true });
Object.defineProperty(video, 'readyState', { value: 1, configurable: true });
inst.events['fragLoading'][0]('fragLoading');
// 4. FRAG_BUFFERED (video not paused, readyState >= 3) -> playing
Object.defineProperty(video, 'paused', { value: false, configurable: true });
Object.defineProperty(video, 'readyState', { value: 4, configurable: true });
inst.events['fragBuffered'][0]('fragBuffered');
const overlay = win.document.querySelector('.panel-overlay');
if (!overlay) throw new Error('No panel overlay found');
if (!overlay.textContent.includes('playing')) {
throw new Error(`Expected overlay to show 'playing' state, got: ${overlay.textContent}`);
}
resolve();
} catch (e) { reject(e); }
}, 500);
});
});
it('should transition to switching state during level change', () => {
const storageData = {};
const viewerId = 'test_level_switch';
storageData[viewerId] = [{ url: 'x', src: 'x', hlsManifest: 'https://cdn.example.com/stream.m3u8', mediaType: 'hls' }];
const url = `https://example.com/viewer/viewer.html?id=${viewerId}&grid=1`;
const { win, hlsCalls } = createBrowserEnv({ storageData, url, hlsMode: 'mocked' });
win.eval(viewerSource);
return new Promise((resolve, reject) => {
setTimeout(() => {
try {
const inst = hlsCalls.instances[0];
const hls = inst.hlsInstance; // the actual mock instance
// Set levels on the actual Hls instance (viewer.js reads hls.levels)
// The mock instance is the one returned by the MockHLS constructor
// We need to set it on the real instance, not our tracking proxy
const levelSwitchedHandlers = inst.events['levelSwitched'];
if (!levelSwitchedHandlers) throw new Error('LEVEL_SWITCHED handler not registered');
// Simulate level switch
levelSwitchedHandlers[0]('levelSwitched', { level: 0 });
// Check overlay for switching state
const overlay = win.document.querySelector('.panel-overlay');
if (!overlay) throw new Error('No panel overlay');
if (!overlay.textContent.includes('switching')) {
throw new Error(`Expected 'switching' state during level change, got: ${overlay.textContent}`);
}
resolve();
} catch (e) { reject(e); }
}, 500);
});
});
it('should transition from any non-error state to playing on FRAG_BUFFERED', () => {
const storageData = {};
const viewerId = 'test_direct_playing';
storageData[viewerId] = [{ url: 'x', src: 'x', hlsManifest: 'https://cdn.example.com/stream.m3u8', mediaType: 'hls' }];
const url = `https://example.com/viewer/viewer.html?id=${viewerId}&grid=1`;
const { win, hlsCalls } = createBrowserEnv({ storageData, url, hlsMode: 'mocked' });
win.eval(viewerSource);
return new Promise((resolve, reject) => {
setTimeout(() => {
try {
const inst = hlsCalls.instances[0];
const video = win.document.querySelector('video');
Object.defineProperty(video, 'paused', { value: false, configurable: true });
Object.defineProperty(video, 'readyState', { value: 4, configurable: true });
const fragBufferedHandlers = inst.events['fragBuffered'];
if (!fragBufferedHandlers) throw new Error('FRAG_BUFFERED handler not registered');
fragBufferedHandlers[0]('fragBuffered');
const overlay = win.document.querySelector('.panel-overlay');
if (!overlay) throw new Error('No panel overlay');
if (!overlay.textContent.includes('playing')) {
throw new Error(`Expected direct transition to 'playing', got: ${overlay.textContent}`);
}
resolve();
} catch (e) { reject(e); }
}, 500);
});
});
});
/* ========================================================================== */
/* Test: HLS.js error recovery */
/* ========================================================================== */
describe('HLS.js: Error handling and recovery', () => {
it('should attempt media error recovery (not destroy instance)', () => {
const storageData = {};
const viewerId = 'test_media_err';
storageData[viewerId] = [{ url: 'x', src: 'x', hlsManifest: 'https://cdn.example.com/stream.m3u8', mediaType: 'hls' }];
const url = `https://example.com/viewer/viewer.html?id=${viewerId}&grid=1`;
const { win, hlsCalls } = createBrowserEnv({ storageData, url, hlsMode: 'mocked' });
win.eval(viewerSource);
return new Promise((resolve, reject) => {
setTimeout(() => {
try {
const inst = hlsCalls.instances[0];
if (!inst) throw new Error('No HLS instance');
const errorHandlers = inst.events['error'];
if (!errorHandlers) throw new Error('ERROR handler not registered');
// Simulate non-fatal media error (should recover, not destroy)
errorHandlers[0]('error', {
fatal: false,
type: 'mediaError',
details: 'fragParsingError',
});
if (inst.destroyed) {
throw new Error('HLS instance was destroyed on non-fatal media error — should have recovered');
}
resolve();
} catch (e) { reject(e); }
}, 500);
});
});
it('should destroy instance on fatal network error', () => {
const storageData = {};
const viewerId = 'test_net_err';
storageData[viewerId] = [{ url: 'x', src: 'x', hlsManifest: 'https://cdn.example.com/stream.m3u8', mediaType: 'hls' }];
const url = `https://example.com/viewer/viewer.html?id=${viewerId}&grid=1`;
const { win, hlsCalls } = createBrowserEnv({ storageData, url, hlsMode: 'mocked' });
win.eval(viewerSource);
return new Promise((resolve, reject) => {
setTimeout(() => {
try {
const inst = hlsCalls.instances[0];
const errorHandlers = inst.events['error'];
errorHandlers[0]('error', {
fatal: true,
type: 'networkError',
details: 'loadsource timeout',
});
if (!inst.destroyed) {
throw new Error('HLS instance should be destroyed on fatal network error');
}
const panelError = win.document.querySelector('.panel-error');
if (!panelError) {
throw new Error('Expected panel-error element for fatal network error');
}
resolve();
} catch (e) { reject(e); }
}, 500);
});
});
it('should track non-fatal fragment load failures (segmentsFailed++)', () => {
const storageData = {};
const viewerId = 'test_frag_fail';
storageData[viewerId] = [{ url: 'x', src: 'x', hlsManifest: 'https://cdn.example.com/stream.m3u8', mediaType: 'hls' }];
const url = `https://example.com/viewer/viewer.html?id=${viewerId}&grid=1`;
const { win, hlsCalls } = createBrowserEnv({ storageData, url, hlsMode: 'mocked' });
win.eval(viewerSource);
return new Promise((resolve, reject) => {
setTimeout(() => {
try {
const inst = hlsCalls.instances[0];
const errorHandlers = inst.events['error'];
const fragLoadedHandlers = inst.events['fragLoaded'];
// First load some segments so the overlay shows segment count
for (let i = 0; i < 5; i++) {
fragLoadedHandlers[0]('fragLoaded', { frag: { level: 0, sn: i } });
}
// Simulate non-fatal network errors (transient fragment failures)
errorHandlers[0]('error', { fatal: false, type: 'networkError', details: 'fragLoadError' });
errorHandlers[0]('error', { fatal: false, type: 'networkError', details: 'fragLoadError' });
const overlay = win.document.querySelector('.panel-overlay');
if (!overlay) throw new Error('No panel overlay');
// Should show segment count + failure count
if (!overlay.textContent.includes('5 seg')) {
throw new Error(`Expected '5 seg' in overlay after loading segments, got: ${overlay.textContent}`);
}
if (!overlay.textContent.includes('fail')) {
throw new Error(`Expected fragment failure tracking in overlay, got: ${overlay.textContent}`);
}
resolve();
} catch (e) { reject(e); }
}, 500);
});
});
});
/* ========================================================================== */
/* Test: Manifest URL edge cases (query strings, protocol-relative, etc.) */
/* ========================================================================== */
describe('HLS.js: Manifest URL edge cases', () => {
it('should intercept .m3u8 URLs with query string parameters', () => {
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', {
url: 'https://example.com/watch',
runScripts: 'dangerously',
});
const win = dom.window;
const intercepted = [];
win.postMessage = (msg) => {
if (msg.type === 'GOON_PLUGIN_HLS_INTERCEPT') intercepted.push(msg);
};
win.fetch = () => {};
win.eval(interceptorSource);
win.fetch('https://cdn.example.com/stream.m3u8?token=abc123&expires=999999');
if (intercepted.length !== 1) {
throw new Error(`Expected 1 intercept for .m3u8 with query string, got ${intercepted.length}`);
}
if (!intercepted[0].url.includes('token=abc123')) {
throw new Error(`Query string should be preserved: ${intercepted[0].url}`);
}
});
it('should intercept protocol-relative manifest URLs (//cdn.example.com)', () => {
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', {
url: 'https://example.com/watch',
runScripts: 'dangerously',
});
const win = dom.window;
const intercepted = [];
win.postMessage = (msg) => {
if (msg.type === 'GOON_PLUGIN_HLS_INTERCEPT') intercepted.push(msg);
};
win.fetch = () => {};
win.eval(interceptorSource);
win.fetch('//cdn.example.com/stream.m3u8');
if (intercepted.length !== 1) {
throw new Error(`Expected 1 intercept for protocol-relative URL, got ${intercepted.length}`);
}
if (!intercepted[0].url.startsWith('https://')) {
throw new Error(`Protocol-relative URL should be resolved to absolute: ${intercepted[0].url}`);
}
});
it('should intercept .hlsvod URLs (non-standard HLS extension)', () => {
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', {
url: 'https://example.com/watch',
runScripts: 'dangerously',
});
const win = dom.window;
const intercepted = [];
win.postMessage = (msg) => {
if (msg.type === 'GOON_PLUGIN_HLS_INTERCEPT') intercepted.push(msg);
};
win.fetch = () => {};
win.eval(interceptorSource);
win.fetch('https://cdn.example.com/stream.hlsvod');
if (intercepted.length !== 1) {
throw new Error(`Expected 1 intercept for .hlsvod URL, got ${intercepted.length}`);
}
});
it('should intercept /hls/ path URLs', () => {
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', {
url: 'https://example.com/watch',
runScripts: 'dangerously',
});
const win = dom.window;
const intercepted = [];
win.postMessage = (msg) => {
if (msg.type === 'GOON_PLUGIN_HLS_INTERCEPT') intercepted.push(msg);
};
win.fetch = () => {};
win.eval(interceptorSource);
win.fetch('https://cdn.example.com/hls/video/12345/master');
if (intercepted.length !== 1) {
throw new Error(`Expected 1 intercept for /hls/ path URL, got ${intercepted.length}`);
}
});
it('should use hlsManifest field even when src is not .m3u8', () => {
const storageData = {};
const viewerId = 'test_hls_field_priority';
storageData[viewerId] = [{
url: 'https://example.com/video/123',
src: 'https://example.com/video/123',
hlsManifest: 'https://cdn.example.com/stream.m3u8',
mediaType: 'hls',
}];
const url = `https://example.com/viewer/viewer.html?id=${viewerId}&grid=1`;
const { win, hlsCalls } = createBrowserEnv({ storageData, url, hlsMode: 'mocked' });
win.eval(viewerSource);
return new Promise((resolve, reject) => {
setTimeout(() => {
try {
const inst = hlsCalls.instances[0];
if (!inst) throw new Error('No HLS instance — hlsManifest field not used');
if (!inst.source || !inst.source.includes('stream.m3u8')) {
throw new Error(`Expected stream.m3u8 from hlsManifest, got: ${inst.source}`);
}
resolve();
} catch (e) { reject(e); }
}, 500);
});
});
});
/* ========================================================================== */
/* Test: Deep probe deduplication and priority */
/* ========================================================================== */
describe('HLS.js: Deep probe deduplication', () => {
const probeMatch = indexSource.match(/function m3u8DeepProbeFn\(\)[\s\S]*?^}/m);
if (!probeMatch) throw new Error('Could not find m3u8DeepProbeFn in index.js');
const probeFnSource = probeMatch[0];
function runProbeInWin(win) {
const result = win.eval(`(${probeFnSource})();`);
if (!Array.isArray(result)) throw new Error(`m3u8DeepProbeFn did not return an array`);
return result;
}
it('should deduplicate manifest found via multiple strategies', () => {
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', {
url: 'https://example.com/watch',
runScripts: 'dangerously',
});
const win = dom.window;
// Same URL via multiple detection strategies
win.hls = { url: 'https://cdn.example.com/stream.m3u8' };
win.__goon_plugin_data = {
manifests: ['https://cdn.example.com/stream.m3u8'],
sources: ['xhr'],
};
const script = win.document.createElement('script');
script.textContent = 'var m = "https://cdn.example.com/stream.m3u8";';
win.document.body.appendChild(script);
const results = runProbeInWin(win);
const duplicates = results.filter(r => r.url === 'https://cdn.example.com/stream.m3u8');
if (duplicates.length > 1) {
throw new Error(`Expected deduplication: manifest found ${duplicates.length} times across strategies`);
}
if (duplicates.length !== 1) {
throw new Error(`Manifest not found. Results: ${JSON.stringify(results)}`);
}
});
it('should deduplicate via interceptor strategy (seen Set prevents duplicates)', () => {
// The seen Set in m3u8DeepProbeFn ensures that once a URL is found by any
// earlier strategy, later strategies skip it. Strategy order: A(player globals),
// B(video elements), C(inline scripts), D(config globals), E(__goon_plugin_data).
// When __goon_plugin_data is the ONLY source, it should be the player label.
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', {
url: 'https://example.com/watch',
runScripts: 'dangerously',
});
const win = dom.window;
// Only interceptor-collected data (Strategy E)
win.__goon_plugin_data = {
manifests: ['https://cdn.example.com/stream.m3u8'],
sources: ['xhr'],
};
const results = runProbeInWin(win);
const found = results.find(r => r.url === 'https://cdn.example.com/stream.m3u8');
if (!found) throw new Error('Manifest not found');
if (found.player !== '__goon_plugin_data') {
throw new Error(`Expected player=__goon_plugin_data, got: ${found.player}`);
}
});
});
/* ========================================================================== */
/* Test: dedupManifests priority sorting */
/* ========================================================================== */
describe('HLS.js: Manifest priority sorting', () => {
it('should sort manifests by priority: interceptor > player > inline-script', () => {
// Extract dedupManifests from index.js
const dedupMatch = indexSource.match(/function dedupManifests\(allManifests\)[\s\S]*?^}/m);
if (!dedupMatch) throw new Error('Could not find dedupManifests in index.js');
// Check playerRank values enforce correct priority
const rankMatch = indexSource.match(/const playerRank = \{[\s\S]*?\}/);
if (!rankMatch) throw new Error('Could not find playerRank in index.js');
const rankText = rankMatch[0];
// __goon_plugin_data should have lowest rank number (highest priority)
if (!rankText.includes("'__goon_plugin_data': 0")) {
throw new Error('__goon_plugin_data should have rank 0 (highest priority)');
}
// inline-script should have highest rank number (lowest priority)
if (!rankText.includes("'inline-script': 99")) {
throw new Error('inline-script should have rank 99 (lowest priority)');
}
});
it('should filter out .ts segment URLs, keeping only manifests', () => {
const filterMatch = indexSource.match(/function filterManifests\(manifests\)[\s\S]*?^}/m);
if (!filterMatch) throw new Error('Could not find filterManifests in index.js');
const filterText = filterMatch[0];
// Should filter by .m3u8 / .hlsvod extensions
if (!filterText.includes('.m3u8') || !filterText.includes('.hlsvod')) {
throw new Error('filterManifests should check for .m3u8 and .hlsvod extensions');
}
// Should handle .ts segment URLs separately
if (!filterText.includes('.ts')) {
throw new Error('filterManifests should handle .ts segment URLs');
}
});
});
/* ========================================================================== */
/* Test: End-to-end interceptor -> deep probe pipeline */
/* ========================================================================== */
describe('HLS.js: End-to-end detection pipeline', () => {
it('should collect intercepted manifests and pass them to deep probe', () => {
// Pipeline verification:
// 1. interceptors.js -> window.__goon_plugin_data.manifests[]
if (!interceptorSource.includes('window.__goon_plugin_data.manifests')) {
throw new Error('interceptors.js does not write to __goon_plugin_data.manifests');
}
// 2. m3u8DeepProbeFn reads from __goon_plugin_data.manifests
if (!indexSource.includes('window.__goon_plugin_data.manifests')) {
throw new Error('m3u8DeepProbeFn does not read from __goon_plugin_data.manifests');
}
// 3. content.js collects from postMessage
if (!contentSource.includes('interceptedManifests')) {
throw new Error('content.js does not track intercepted manifests');
}
// 4. viewer.js uses hlsManifest from storage
if (!viewerSource.includes('hlsManifest')) {
throw new Error('viewer.js does not use hlsManifest field');
}
});
it('should handle the full hls.js lifecycle: create -> play -> error -> recover -> destroy', () => {
const lifecycleSteps = [
{ step: 'create HLS instance', pattern: /new window\.Hls\(HLS_CONFIG\)/ },
{ step: 'loadSource', pattern: /hls\.loadSource/ },
{ step: 'attachMedia', pattern: /hls\.attachMedia/ },
{ step: 'wireHlsEvents', pattern: /wireHlsEvents\(hls/ },
{ step: 'wireVideoEvents', pattern: /wireVideoEvents\(video\)/ },
{ step: 'startBufferMonitor', pattern: /startBufferMonitor\(video/ },
{ step: 'destroy on error', pattern: /hls\.destroy\(\)/ },
{ step: 'recoverMediaError', pattern: /hls\.recoverMediaError\(\)/ },
{ step: 'destroy on unload', pattern: /destroyHls/ },
];
for (const { step, pattern } of lifecycleSteps) {
if (!pattern.test(viewerSource)) {
throw new Error(`Missing lifecycle step: ${step}`);
}
}
});
});
/* ========================================================================== */
/* Test: Fragment tracking accuracy */
/* ========================================================================== */
describe('HLS.js: Fragment tracking accuracy', () => {
it('should correctly count segments loaded via FRAG_LOADED events', () => {
const storageData = {};
const viewerId = 'test_seg_count';
storageData[viewerId] = [{ url: 'x', src: 'x', hlsManifest: 'https://cdn.example.com/stream.m3u8', mediaType: 'hls' }];
const url = `https://example.com/viewer/viewer.html?id=${viewerId}&grid=1`;
const { win, hlsCalls } = createBrowserEnv({ storageData, url, hlsMode: 'mocked' });
win.eval(viewerSource);
return new Promise((resolve, reject) => {
setTimeout(() => {
try {
const inst = hlsCalls.instances[0];
const fragLoadedHandlers = inst.events['fragLoaded'];
for (let i = 0; i < 5; i++) {
fragLoadedHandlers[0]('fragLoaded', { frag: { level: 0, sn: i } });
}
const overlay = win.document.querySelector('.panel-overlay');
if (!overlay) throw new Error('No panel overlay');
if (!overlay.textContent.includes('5 seg')) {
throw new Error(`Expected '5 seg' in overlay, got: ${overlay.textContent}`);
}
resolve();
} catch (e) { reject(e); }
}, 500);
});
});
it('should reset segment count on BUFFER_RESET event', () => {
const storageData = {};
const viewerId = 'test_buf_reset';
storageData[viewerId] = [{ url: 'x', src: 'x', hlsManifest: 'https://cdn.example.com/stream.m3u8', mediaType: 'hls' }];
const url = `https://example.com/viewer/viewer.html?id=${viewerId}&grid=1`;
const { win, hlsCalls } = createBrowserEnv({ storageData, url, hlsMode: 'mocked' });
win.eval(viewerSource);
return new Promise((resolve, reject) => {
setTimeout(() => {
try {
const inst = hlsCalls.instances[0];
// Load some segments first
for (let i = 0; i < 3; i++) {
inst.events['fragLoaded'][0]('fragLoaded', { frag: { level: 0, sn: i } });
}
// Trigger BUFFER_RESET
inst.events['bufferReset'][0]('bufferReset');
const overlay = win.document.querySelector('.panel-overlay');
if (!overlay) throw new Error('No panel overlay');
// After buffer reset, segmentsLoaded should be 0
if (overlay.textContent.includes('3 seg')) {
throw new Error(`Expected segment count to reset after BUFFER_RESET, still shows '3 seg'`);
}
resolve();
} catch (e) { reject(e); }
}, 500);
});
});
it('should track buffer health updates via BUFFER_APPENDED', () => {
const storageData = {};
const viewerId = 'test_buf_append';
storageData[viewerId] = [{ url: 'x', src: 'x', hlsManifest: 'https://cdn.example.com/stream.m3u8', mediaType: 'hls' }];
const url = `https://example.com/viewer/viewer.html?id=${viewerId}&grid=1`;
const { win, hlsCalls } = createBrowserEnv({ storageData, url, hlsMode: 'mocked' });
win.eval(viewerSource);
return new Promise((resolve, reject) => {
setTimeout(() => {
try {
const inst = hlsCalls.instances[0];
inst.events['bufferAppended'][0]('bufferAppended');
const overlay = win.document.querySelector('.panel-overlay');
if (!overlay) throw new Error('No panel overlay');
if (!overlay.textContent.includes('buf')) {
throw new Error(`Expected buffer info in overlay, got: ${overlay.textContent}`);
}
resolve();
} catch (e) { reject(e); }
}, 500);
});
});
});
File diff suppressed because it is too large. Load diff
+2
View File
@@ -5,6 +5,8 @@ import './schema.test.mjs';
import './viewer_units.test.mjs'; import './viewer_units.test.mjs';
import './data_flow.test.mjs'; import './data_flow.test.mjs';
import './viewer_integration.test.mjs'; import './viewer_integration.test.mjs';
import './config_validation.test.mjs';
import './hls_qa.test.mjs';
import { run } from './runner.mjs'; import { run } from './runner.mjs';
const result = await run(); const result = await run();
+4 -1
View File
@@ -1,7 +1,10 @@
import { describe, it } from './runner.mjs'; import { describe, it } from './runner.mjs';
import Ajv from 'ajv'; import Ajv from 'ajv';
import addFormats from 'ajv-formats'; import addFormats from 'ajv-formats';
import schema from '../src/viewer/schema.json' with { type: 'json' }; import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const schema = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), '../src/viewer/schema.json'), 'utf8'));
const ajv = new Ajv({ strict: false }); const ajv = new Ajv({ strict: false });
addFormats(ajv); addFormats(ajv);
+2 -20
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. // 'native' mode sets HLS = undefined so viewer.js takes the Safari native path.
const hlsCalls = { instances: [], loaded: false }; const hlsCalls = { instances: [], loaded: false };
if (hlsMode === 'mocked') { if (hlsMode === 'mocked') {
win.HLS = class MockHLS { win.Hls = class MockHLS {
constructor(opts) { constructor(opts) {
hlsCalls.instances.push({ opts, destroyed: false, events: {}, source: null, media: null }); 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') { } else if (hlsMode === 'native') {
// Simulate Safari — window.HLS exists but isSupported() returns false. // Simulate Safari — window.HLS exists but isSupported() returns false.
// viewer.js then falls through to video.src = manifestUrl (native HLS). // viewer.js then falls through to video.src = manifestUrl (native HLS).
win.HLS = class MockHLS_Native { win.Hls = class MockHLS_Native {
static isSupported() { static isSupported() {
return false; // Safari has native HLS, Hls.js is not needed return false; // Safari has native HLS, Hls.js is not needed
} }
@@ -178,15 +178,6 @@ describe('Integration: viewer.js with HLS entries', () => {
return; return;
} }
// HLS detection in console logs
const hlsLogs = consoleLogs.filter(
(l) => l.includes('HLS detected') || l.includes('HLS')
);
if (hlsLogs.length === 0) {
reject(new Error(`No HLS detection in logs. Logs: ${consoleLogs.join(' | ')}`));
return;
}
// Verify Hls.js was actually instantiated for the HLS entry // Verify Hls.js was actually instantiated for the HLS entry
if (hlsCalls.instances.length < 1) { if (hlsCalls.instances.length < 1) {
reject(new Error('Expected at least 1 HLS instance, got 0')); reject(new Error('Expected at least 1 HLS instance, got 0'));
@@ -391,15 +382,6 @@ describe('Integration: viewer.js with HLS entries', () => {
return; return;
} }
// HLS detection in logs
const hlsLogs = consoleLogs.filter(
(l) => l.includes('HLS detected') || l.includes('HLS')
);
if (hlsLogs.length === 0) {
reject(new Error(`No HLS logs. All: ${consoleLogs.join(' | ')}`));
return;
}
// Only one HLS instance (for the HLS entry) // Only one HLS instance (for the HLS entry)
if (hlsCalls.instances.length !== 1) { if (hlsCalls.instances.length !== 1) {
reject(new Error(`Expected 1 HLS instance, got ${hlsCalls.instances.length}`)); reject(new Error(`Expected 1 HLS instance, got ${hlsCalls.instances.length}`));
+24
View File
@@ -0,0 +1,24 @@
// ESLint flat config — v9+
import globals from 'globals';
export default [
{
ignores: ['dist/', 'node_modules/', '__tests__/'],
},
{
files: ['**/*.js', '**/*.mjs'],
languageOptions: {
ecmaVersion: 2024,
sourceType: 'module',
globals: {
...globals.browser,
...globals.es2024,
chrome: 'readonly',
},
},
rules: {
'no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
'no-console': 'off',
},
},
];
+9 -1
View File
@@ -6,7 +6,9 @@
"permissions": [ "permissions": [
"storage", "storage",
"tabs", "tabs",
"scripting" "scripting",
"webRequest",
"declarativeNetRequest"
], ],
"host_permissions": [ "host_permissions": [
"<all_urls>" "<all_urls>"
@@ -31,6 +33,12 @@
"all_frames": true "all_frames": true
} }
], ],
"web_accessible_resources": [
{
"resources": ["interceptors.js", "hlds.js"],
"matches": ["<all_urls>"]
}
],
"icons": { "icons": {
"16": "icons/icon16.png", "16": "icons/icon16.png",
"48": "icons/icon48.png", "48": "icons/icon48.png",
-9
View File
@@ -7,9 +7,6 @@
"": { "": {
"name": "goon-plugin", "name": "goon-plugin",
"version": "0.1.0", "version": "0.1.0",
"dependencies": {
"hls.js": "^1.5.17"
},
"devDependencies": { "devDependencies": {
"ajv": "^8.17.1", "ajv": "^8.17.1",
"ajv-formats": "^3.0.1", "ajv-formats": "^3.0.1",
@@ -2784,12 +2781,6 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/hls.js": {
"version": "1.6.17",
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.17.tgz",
"integrity": "sha512-NUplVGVuc1hSPwdB/9/cbRkUmLrYi75/hqiXKdA+l300pJNxDu96R7jRb2imDzWJqIUF4I5ThmAdp9GvOCXsuQ==",
"license": "Apache-2.0"
},
"node_modules/html-encoding-sniffer": { "node_modules/html-encoding-sniffer": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz",
+1 -4
View File
@@ -6,16 +6,13 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build && node scripts/copy-hlds.mjs",
"lint": "eslint .", "lint": "eslint .",
"lint:fix": "eslint . --fix", "lint:fix": "eslint . --fix",
"test": "node __tests__/run.mjs", "test": "node __tests__/run.mjs",
"test:watch": "vitest", "test:watch": "vitest",
"check": "tsc --noEmit" "check": "tsc --noEmit"
}, },
"dependencies": {
"hls.js": "^1.5.17"
},
"devDependencies": { "devDependencies": {
"ajv": "^8.17.1", "ajv": "^8.17.1",
"ajv-formats": "^3.0.1", "ajv-formats": "^3.0.1",
+20
View File
@@ -0,0 +1,20 @@
// Copy the original ES5 hls.min.js source directly to dist/hlds.js.
// This avoids Vite/esbuild adding arrow functions (which Chrome flags as "ES module content"
// in extension URLs, preventing window.Hls from registering).
import { readFileSync, writeFileSync } from 'fs';
import { fileURLToPath } from 'url';
import { dirname, resolve } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const src = resolve(__dirname, '../src/inject/hls.min.js');
const dst = resolve(__dirname, '../dist/hlds.js');
let data = readFileSync(src, 'utf-8');
// Remove sourceMappingURL line (avoids Chrome trying to fetch it from an invalid chrome-extension:// URL)
data = data.replace(/[\r\n]+\/\/# sourceMappingURL=[^\s]*[\r\n]*/, '');
writeFileSync(dst, data);
console.log(`[copy-hlds] Copied hls.min.js -> dist/hlds.js (${data.length} bytes, pure ES5 source)`);
+444 -15
View File
@@ -4,29 +4,458 @@
* Uses Manifest V3 module-type service worker. * Uses Manifest V3 module-type service worker.
*/ */
// Fired when the extension is first installed / updated / Chrome starts. // === HLS manifest tracking via chrome.webRequest ===
chrome.runtime.onInstalled.addListener((details) => { // Intercepts network requests at the browser level — no world/timing issues.
if (details.reason === 'install') { // Catches .m3u8 manifest requests from ANY player (HLS.js, Video.js, custom).
console.log('[goon-plugin] Extension installed'); //
} else if (details.reason === 'update') { // IMPORTANT: MV3 service workers are ephemeral — they are killed when idle and
console.log(`[goon-plugin] Extension updated from ${details.previousVersion}`); // restarted on demand. In-memory state (Map, Set, etc.) is LOST on kill.
// Therefore, we persist captured manifests to chrome.storage.local so they
// survive service worker restarts.
function isHlsManifest(url) {
const u = url.toLowerCase();
// Exclude media segments
if (u.includes('.ts') || u.includes('.mp4') || u.includes('.webm') ||
u.includes('.aac') || u.includes('.m4s')) return false;
return u.includes('.m3u8') || u.includes('.hlsvod') ||
(u.includes('/hls/') && u.includes('.m3u')) ||
u.includes('/playlist.m3u') || u.includes('/manifest');
}
// === STRATEGY 8: Network-level fallback — log ALL phncdn.com and video-related requests ===
// Pornhub's es6player generates HLS manifests in-memory with zero .m3u8 network requests.
// However, the player still fetches video segments from phncdn.com. By logging all phncdn
// requests we can: (a) confirm HLS activity via segment patterns, (b) reconstruct manifest
// URLs from segment paths, (c) detect CDN response headers that reveal playlist endpoints.
const PHNCDN_HOSTS = ['phncdn.com', 'pornhubpremium.com', 'cdn.pornhub.com'];
const VIDEO_PATH_PATTERNS = ['/hls/', '/dash/', '/video/', '/segment', '/media/', '/stream'];
function isVideoRequest(url) {
const u = url.toLowerCase();
// Already a known video/manifest extension
if (u.includes('.m3u8') || u.includes('.mpd') || u.includes('.hlsvod') ||
u.includes('.ts') || u.includes('.m4s') || u.includes('.mp4')) return true;
// Path-based detection for in-memory playlists
return VIDEO_PATH_PATTERNS.some(p => u.includes(p));
}
// Persist all video requests per tab (survives SW restarts)
async function persistVideoRequest(tabId, url) {
const key = 'vr_requests_' + tabId;
try {
const stored = await chrome.storage.local.get(key);
const arr = stored[key] || [];
// Avoid duplicates — phncdn segments come in rapid succession
if (!arr.some(r => r === url)) {
arr.push(url);
// Keep last 200 requests per tab (enough to detect segment patterns)
if (arr.length > 200) arr.splice(0, arr.length - 200);
await chrome.storage.local.set({ [key]: arr });
} }
}); } catch(e) {
console.warn('[goon-bg] Failed to persist video request:', e && e.message ? e.message : e);
}
}
// --- phncdn token / URL helpers ------------------------------------------
//
// The phncdn auth token (h= / hash= / hdnea=) is base64 and its %-encoded
// escapes are significant: a `+` decoded to a space corrupts it, which is what
// produces the 410 Gone the live player avoids by sending `%2B`. So every
// helper below keeps the query string RAW — it is never passed through
// URLSearchParams (which decodes + -> space).
// Extract the phncdn videoId from a /hls/videos/ URL; '' if it isn't one.
// Path shape: /hls/videos/{year}/{subdir}/{videoId}/{variant}/...
function extractVideoId(url) {
if (!url || !url.includes('/hls/videos/')) return '';
try {
const path = new URL(url).pathname.split('/');
const hlsIdx = path.findIndex(p => p === 'hls');
if (hlsIdx === -1 || hlsIdx + 4 >= path.length) return '';
return path[hlsIdx + 4];
} catch {
return '';
}
}
// Raw query (everything after '?') with no decoding. '' when absent.
function extractRawQuery(url) {
const idx = url.indexOf('?');
return idx === -1 ? '' : url.slice(idx + 1);
}
// Best-effort token expiry (epoch seconds) from a RAW query; 0 = unknown.
// Used to rank tokens freshest-first so rescans pick the one most likely to
// still validate.
function tokenExpirySeconds(rawQuery) {
let secs = 0;
for (const key of ['validto', 'exp', 'e']) {
const m = new RegExp(key + '=(\\d+)').exec(rawQuery);
if (m) { secs = Math.max(secs, parseInt(m[1], 10)); break; }
}
return secs;
}
// Build a manifest URL for a variant directory from a raw phncdn URL,
// preserving the FULL variant path and the raw query (token encoding intact).
// /hls/videos/{year}/{subid}/{videoId}/{variant}/seg-1.ts ->
// /hls/videos/{year}/{subid}/{videoId}/{variant}/master.m3u8
// The variant directory (e.g. 720P_4000K_338337051.mp4) is MANDATORY — a master
// playlist lives inside it; dropping it (the old bug) yields a 410. Returns ''
// when the URL isn't a phncdn HLS URL with a complete variant path.
function buildManifestForVariant(rawUrl) {
if (!rawUrl || !rawUrl.includes('/hls/videos/')) return '';
try {
const path = rawUrl.split('?')[0];
const parts = path.split('/');
const hlsIdx = parts.findIndex(p => p === 'hls');
// Need hls, videos, year, subdir, videoId, variant (6 dirs).
if (hlsIdx === -1 || hlsIdx + 5 >= parts.length) return '';
const prefix = parts.slice(0, hlsIdx + 2).join('/'); // .../hls/videos
const year = parts[hlsIdx + 2];
const subid = parts[hlsIdx + 3];
const videoId = parts[hlsIdx + 4];
const variant = parts[hlsIdx + 5];
const rawQuery = extractRawQuery(rawUrl);
return `${prefix}/${year}/${subid}/${videoId}/${variant}/master.m3u8${rawQuery ? '?' + rawQuery : ''}`;
} catch {
return '';
}
}
// Reconstruct manifest URLs from segment URLs, keeping the raw query intact.
// The live tab keeps rotating short-lived tokens; each captured segment's query
// is the most recent token seen for that variant.
function reconstructManifestsFromSegments(segments) {
const seen = new Set();
const manifests = [];
for (const url of segments) {
// Only handle HLS segment URLs: /hls/videos/{..}/{variant}/seg-N.ts
if (!url.includes('/hls/videos/') || !/\/seg-\d+/.test(url)) continue;
const manifestUrl = buildManifestForVariant(url);
if (manifestUrl && !seen.has(manifestUrl)) {
seen.add(manifestUrl);
manifests.push(manifestUrl);
}
}
return manifests;
}
// Collect phncdn URLs across ALL tabs without a tabs.query match pattern (which
// throws if the pattern is malformed). We scan every persisted vr_requests_ /
// wr_manifests_ key so the rescan works even if the live source tab is closed.
async function collectAllPhncdnUrls() {
const all = await chrome.storage.local.get();
const out = [];
for (const key of Object.keys(all)) {
if ((key.startsWith('vr_requests_') || key.startsWith('wr_manifests_')) && Array.isArray(all[key])) {
out.push(...all[key]);
}
}
return out;
}
// For a videoId, return the freshest live phncdn URL (highest token expiry)
// across all persisted tabs, preferring captured .m3u8 manifest URLs. null if
// nothing usable is captured yet. Rescans use this to replace a stored URL that
// now 410s with the newest token the live tab captured.
//
// No tabs.query match pattern is used: '*pornhub.com*' is an INVALID pattern and
// made this throw ("Invalid url pattern"), which silently dropped every rescan.
// We scan persisted capture data instead, which is always available and
// sidesteps the match-Pattern API entirely.
async function freshLiveForVideo(videoId, preferManifest) {
let all;
try {
all = await collectAllPhncdnUrls();
} catch (e) {
console.warn('[goon-bg] collectAllPhncdnUrls failed:', e && e.message ? e.message : e);
all = [];
}
let bestManifest = null, bestManifestExp = 0;
let bestVariant = null, bestVariantExp = 0;
let bestTokenSource = null, bestTokenExp = 0;
for (const url of all) {
if (extractVideoId(url) !== videoId) continue;
const exp = tokenExpirySeconds(extractRawQuery(url));
if (url.includes('.m3u8')) {
if (exp > bestManifestExp) { bestManifestExp = exp; bestManifest = url; }
} else if (/\/seg-\d+/.test(url)) {
if (exp > bestVariantExp) { bestVariantExp = exp; bestVariant = url; }
} else {
if (exp > bestTokenExp) { bestTokenExp = exp; bestTokenSource = url; }
}
}
// Prefer a captured manifest; else reconstruct the variant manifest; else
// hand back a token source so the proxy can at least re-token a good path.
if (bestManifest) return bestManifest;
if (!preferManifest && bestVariant) return bestVariant;
if (!preferManifest && bestTokenSource) return bestTokenSource;
if (bestVariant) return bestVariant;
return bestManifest || bestTokenSource || null;
}
// Cache freshest tokens per videoId so we don't query storage on every proxy
// request. Keyed by videoId; values resolve once and are reused until a rescan
// (GOON_RESOLVE_MANIFEST) or a 410 forces an invalidation.
const tokenCache = new Map();
// Given a phncdn /hls/videos/ URL that arrives WITHOUT a query (hls.js drops the
// relative-URL token when building sub-playslists/segments), inject the freshest
// live token for that videoId. Returns the URL with a query appended, or the
// original URL unchanged when no token is available yet.
async function injectFreshToken(url) {
if (!url || !url.includes('/hls/videos/')) return url;
// Already carries a token — leave it alone.
if (url.includes('?')) return url;
const videoId = extractVideoId(url);
if (!videoId) return url;
if (tokenCache.has(videoId)) {
const token = tokenCache.get(videoId);
return token ? url + '?' + token : url;
}
const fresh = await freshLiveForVideo(videoId, false);
const token = fresh ? extractRawQuery(fresh) : '';
tokenCache.set(videoId, token);
console.log('[goon-bg] injectFreshToken for', videoId, videoId + '-> ' + (token ? token.substring(0, 40) + '…' : '(none captured)'));
return token ? url + '?' + token : url;
}
// Clear the per-video token cache. Called on rescan so the next proxy fetches
// re-query storage for a freshly-rotated token.
function invalidateTokenCache() { tokenCache.clear(); }
chrome.webRequest.onBeforeRequest.addListener(
async (details) => {
if (details.tabId < 0) return;
const u = details.url.toLowerCase();
const isPhncdn = PHNCDN_HOSTS.some(host => details.url.includes(host));
const isVideo = isVideoRequest(details.url);
// Log ALL phncdn.com requests regardless of path
if (isPhncdn) {
console.log('[goon-bg] webRequest: phncdn request in tab', details.tabId, ':', details.url.substring(0, 180));
await persistVideoRequest(details.tabId, details.url);
}
// Log video-like requests from any host
if (isVideo && !isPhncdn) {
console.log('[goon-bg] webRequest: video request in tab', details.tabId, ':', details.url.substring(0, 150));
}
// Also capture HLS manifest URLs (original logic)
if (!isHlsManifest(details.url)) return;
console.log('[goon-bg] webRequest: HLS manifest in tab', details.tabId, ':', details.url.substring(0, 120));
const key = 'wr_manifests_' + details.tabId;
try {
const stored = await chrome.storage.local.get(key);
const arr = stored[key] || [];
if (!arr.includes(details.url)) {
arr.push(details.url);
await chrome.storage.local.set({ [key]: arr });
}
} catch(e) {
console.warn('[goon-bg] Failed to persist webRequest manifest:', e && e.message ? e.message : e);
}
},
{ urls: ['<all_urls>'] }
);
// Clean up stale entries when tabs are closed
chrome.tabs.onRemoved.addListener(async (tabId) => {
const keys = ['wr_manifests_' + tabId, 'vr_requests_' + tabId];
try {
await chrome.storage.local.remove(keys);
} catch { /* ignore */ }
});
// === Force a pornhub Referer on every *.phncdn.com request ===
// The phncdn video CDN (hv-h.phncdn.com, pix-fl.phncdn.com, ev-h.phncdn.com,
// ... and the auth-token hosts) rejects any request whose Referer is not a
// pornhub-origin with HTTP 412 (see round-1 curl test: `Referer:
// https://www.pornhub.com/` -> 200, no referer -> 412). Origin is NOT the
// gate (curl with a pornhub Referer and no Origin still 200s), so we only
// rewrite Referer and never touch Origin.
//
// A normal fetch() CANNOT set the `Referer` header — it is a "forbidden
// header" in the Fetch API, so a service-worker fetch() gets
// `Referer: chrome-extension://<id>`, which the CDN rejects (412). The
// declarativeNetRequest API rewrites request headers at the network layer,
// below the Fetch API's forbidden-header restriction, and — per Chrome docs —
// these rules DO apply to fetch() calls made inside the service worker. So we
// force a pornhub referer on phncdn.com; the GOON_PROXY_FETCH handler in the
// SW then fetches the manifest + segments successfully.
const REFERER_RULE_ID = 9001;
const REFERER_RULE = {
id: REFERER_RULE_ID,
priority: 1,
action: {
type: 'modifyHeaders',
// `set` is allowed for any request header (only `append` is restricted to
// a fixed allowlist). `referer` is the header the CDN checks.
requestHeaders: [
{ header: 'referer', operation: 'set', value: 'https://www.pornhub.com/' },
],
},
condition: {
// phncdn.com (host + subdomains) ONLY. Do NOT include pornhub.com: the
// real page player's auth calls (api.pornhub.com) and its own segment
// fetches must keep their browser-supplied Origin/Referer or playback
// freezes. The proxy only ever fetches phncdn URLs.
requestDomains: ['phncdn.com'],
},
};
// Cached promise so concurrent proxy fetches share one rule install; a
// transient failure is cleared so a later fetch retries.
let refererRuleReady = null;
async function ensureRefererRule() {
if (refererRuleReady) return refererRuleReady;
refererRuleReady = (async () => {
const api = chrome.declarativeNetRequest;
if (!api || typeof api.updateSessionRules !== 'function') {
throw new Error('declarativeNetRequest.updateSessionRules unavailable');
}
// Idempotent: remove any stale copy of our rule, then add the fresh one.
await api.updateSessionRules({
removeRuleIds: [REFERER_RULE_ID],
addRules: [REFERER_RULE],
});
console.log('[goon-bg] DNR referer rule installed for *.phncdn.com');
})();
// If it failed, reset so the next call retries instead of caching the
// rejected promise forever.
refererRuleReady.catch(() => { refererRuleReady = null; });
return refererRuleReady;
}
// Install eagerly on SW start so even the first proxy fetch is covered.
ensureRefererRule();
// === Message handlers for popup/data consumers ===
// Listen for messages from popup or content scripts.
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
console.log('[goon-plugin] Received:', message);
switch (message.type) { switch (message.type) {
case 'PING': case 'PING':
sendResponse({ pong: true, timestamp: Date.now() }); sendResponse({ pong: true, timestamp: Date.now() });
break; return;
case 'GOON_GET_WEBREQUEST_MANIFESTS': {
(async () => {
const tabId = message.tabId;
const key = 'wr_manifests_' + tabId;
try {
const stored = await chrome.storage.local.get(key);
sendResponse({ manifests: stored[key] || [] });
} catch(e) {
sendResponse({ manifests: [] });
}
})();
return true;
}
case 'GOON_GET_PHNCDN_REQUESTS': {
// Strategy 8: Return all phncdn.com requests for a tab, including reconstructed manifests
(async () => {
const tabId = message.tabId;
const [segKey] = ['vr_requests_' + tabId];
try {
const stored = await chrome.storage.local.get(segKey);
const segments = stored[segKey] || [];
// Also reconstruct manifest URLs from segment patterns
const reconstructed = reconstructManifestsFromSegments(segments);
sendResponse({
segments: segments,
reconstructedManifests: reconstructed,
totalRequests: segments.length,
});
} catch(e) {
sendResponse({ segments: [], reconstructedManifests: [], totalRequests: 0 });
}
})();
return true;
}
case 'GOON_RESOLVE_MANIFEST': {
// Resolve the freshest live phncdn manifest for the video in a stored
// URL. The viewer calls this at startup and on a fatal network error:
// the live tab keeps rotating short-lived tokens, so the freshest
// captured manifest (highest expiry) replaces one that has 410'd.
// Clears the token cache first so subsequent proxy fetches re-query for
// the freshly-rotated token rather than the cached (now-dead) one.
async function resolveFreshManifest(message, sendResponse) {
invalidateTokenCache();
const url = message.url || '';
const videoId = extractVideoId(url);
if (!videoId) { sendResponse({ ok: false, error: 'no videoId' }); return; }
try {
const fresh = await freshLiveForVideo(videoId, true);
sendResponse({ ok: true, fresh, videoId, changed: !!(fresh && fresh !== url) });
} catch (e) {
console.warn('[goon-bg] resolveFreshManifest failed:', e && e.message ? e.message : e);
sendResponse({ ok: false, error: e && e.message ? e.message : String(e), videoId });
}
}
resolveFreshManifest(message, sendResponse);
return true;
}
case 'GOON_PROXY_FETCH': {
// Proxy a fetch request through the service worker (bypasses CORS).
// Returns base64-encoded response data. Used by the viewer when
// extension-origin requests to video CDNs are blocked by CORS.
//
// The phncdn CDN 412s requests without a pornhub Referer. A fetch()
// can't set Referer itself (forbidden header — Chrome replaces it with
// chrome-extension://...), so we install a declarativeNetRequest rule
// that forces `Referer: https://www.pornhub.com/` at the network layer
// for all *.phncdn.com requests (DNR applies to SW fetch() per Chrome
// docs). Await it first so the very next fetch is covered.
(async () => {
try {
await ensureRefererRule();
// hls.js resolves sub-playslists/segments relative to the master
// and can DROP the token query, handing us a token-less phncdn
// URL that 410s. injectFreshToken supplies the freshest live token
// for that videoId; the final `+` -> `%2B` pass keeps the
// (possibly injected or surviving) base64 token intact for the
// CDN's query parsing.
const resolved = await injectFreshToken(message.url || '');
const proxyUrl = resolved.replace(/\+/g, '%2B');
const resp = await fetch(proxyUrl);
if (!resp.ok) {
console.warn('[goon-bg] proxyFetch', resp.status, message.url.substring(0, 120));
sendResponse({ ok: false, status: resp.status, error: 'HTTP ' + resp.status });
return;
}
const buffer = await resp.arrayBuffer();
const bytes = new Uint8Array(buffer);
// Convert to base64 (btoa can only handle strings up to ~65k chars)
let binary = '';
const CHUNK_SIZE = 32768;
for (let i = 0; i < bytes.length; i += CHUNK_SIZE) {
const chunk = bytes.subarray(i, i + CHUNK_SIZE);
binary += String.fromCharCode.apply(null, Array.from(chunk));
}
const base64 = btoa(binary);
sendResponse({ ok: true, data: base64, mimeType: resp.headers.get('content-type') || '' });
} catch(e) {
console.warn('[goon-bg] proxyFetch failed:', e && e.message ? e.message : e);
sendResponse({ ok: false, error: e && e.message ? e.message : String(e) });
}
})();
return true;
}
default: default:
console.warn('[goon-plugin] Unknown message type:', message.type); console.warn('[goon-plugin] Unknown message type:', message.type);
sendResponse({ error: `Unknown type: ${message.type}` });
break;
} }
return true; // keep sendResponse channel open for async work
}); });
+67 -120
View File
@@ -24,128 +24,32 @@
/** /**
* Inject interceptors into the MAIN world at document_start. * 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.
*
* IMPORTANT: Remove the script element ONLY after the load event fires.
* Removing it synchronously can prevent the browser from starting the fetch,
* especially in Edge/Chrome with strict timing.
*
* Runs BEFORE any page JS (document_start), so we catch all HLS activity.
*/ */
function injectInterceptors() { 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'); const script = document.createElement('script');
script.textContent = interceptCode; script.src = chrome.runtime.getURL('interceptors.js');
script.setAttribute('type', 'text/javascript'); script.setAttribute('type', 'text/javascript');
(document.head || document.documentElement).appendChild(script); script.onload = function () {
// Remove the script element after execution but keep interceptors active // Script loaded and executed — safe to remove the element now
if (script.parentNode) {
script.parentNode.removeChild(script); script.parentNode.removeChild(script);
} }
};
(document.head || document.documentElement).appendChild(script);
// Keep the element in the DOM — onload will remove it after execution
}
// Inject immediately at document_start // Inject immediately at document_start
injectInterceptors(); injectInterceptors();
@@ -164,6 +68,7 @@
timestamp: event.data.timestamp timestamp: event.data.timestamp
}); });
console.log('[goon-plugin] Intercepted HLS manifest via', source, ':', url); console.log('[goon-plugin] Intercepted HLS manifest via', source, ':', url);
console.log('[goon-plugin] Total intercepted manifests so far:', interceptedManifests.length);
} }
} }
}); });
@@ -190,34 +95,76 @@
return url; return url;
} }
} }
} catch(e) {} } catch {}
} }
return null; return null;
} }
/** /**
* Get video data: intercepted manifests + DOM scan + poster. * Get video data: intercepted manifests + DOM scan + poster + Hls.cast probe.
* Called by the popup via chrome.tabs.sendMessage. * Called by the popup via chrome.tabs.sendMessage.
*/ */
function getVideoData() { function getVideoData() {
const poster = extractPoster(); const poster = extractPoster();
console.log('[goon-content] getVideoData called. Poster:', poster);
console.log('[goon-content] Intercepted manifests count:', interceptedManifests.length);
if (interceptedManifests.length > 0) {
console.log('[goon-content] Manifest details:', JSON.stringify(interceptedManifests.map(m => ({ url: m.url, source: m.source }))));
}
// DOM video scan (isolated world — can access DOM but not page JS) // DOM video scan (isolated world — can access DOM but not page JS)
const videoElements = []; const videoElements = [];
try { try {
const videos = document.querySelectorAll('video'); const videos = document.querySelectorAll('video');
console.log('[goon-content] Found', videos.length, '<video> elements in DOM');
for (const video of videos) { for (const video of videos) {
const style = window.getComputedStyle(video); const style = window.getComputedStyle(video);
if (style.display !== 'none' && video.clientWidth > 0 && video.clientHeight > 0) { const isVisible = style.display !== 'none' && video.clientWidth > 0 && video.clientHeight > 0;
console.log('[goon-content] Video:', {
src: (video.currentSrc || video.src || '').substring(0, 120),
visible: isVisible,
dimensions: video.clientWidth + 'x' + video.clientHeight,
networkState: video.networkState,
readyState: video.readyState,
});
if (isVisible) {
// Extract buffered ranges as serializable arrays
const bufferedRanges = [];
if (video.buffered && video.buffered.length > 0) {
for (let b = 0; b < video.buffered.length; b++) {
bufferedRanges.push({ start: video.buffered.start(b), end: video.buffered.end(b) });
}
}
videoElements.push({ videoElements.push({
src: video.currentSrc || video.src || '', src: video.currentSrc || video.src || '',
poster: video.poster || '', poster: video.poster || '',
width: video.clientWidth, width: video.clientWidth,
height: video.clientHeight, height: video.clientHeight,
duration: video.duration || 0,
buffered: bufferedRanges,
}); });
} }
} }
} catch(e) { /* ignore DOM access errors */ } } catch(e) { /* ignore DOM access errors */
console.warn('[goon-content] DOM scan error:', e && e.message ? e.message : e);
}
console.log('[goon-content] Visible video elements:', videoElements.length);
// Expose interceptor data globally so the popup's deep probe can read it.
window.__goon_plugin_data = window.__goon_plugin_data || {
manifests: [],
sources: [],
_contentType: 'content-script',
};
// Merge our intercepted manifests into the shared data store (the interceptors
// write their own URLs here; we add content-script-level XHR intercepts).
for (const m of interceptedManifests) {
if (!window.__goon_plugin_data.manifests.includes(m.url)) {
window.__goon_plugin_data.manifests.push(m.url);
window.__goon_plugin_data.sources.push(m.source);
}
}
return { return {
posterUrl: poster, posterUrl: poster,
+2
View File
@@ -0,0 +1,2 @@
!function t(e){var r,i;r=this,i=function(){"use strict";function r(t,e){var r=Object.keys(t);if(Object.getOwnPropertySymbols){var i=Object.getOwnPropertySymbols(t);e&&(i=i.filter((function(e){return Object.getOwnPropertyDescriptor(t,e).enumerable}))),r.push.apply(r,i)}return r}function i(t){for(var e=1;e<arguments.length;e++){var i=null!=arguments[e]?arguments[e]:{};e%2?r(Object(i),!0).forEach((function(e){var r,a,s;r=t,a=e,s=i[e],(a=n(a))in r?Object.defineProperty(r,a,{value:s,enumerable:!0,configurable:!0,writable:!0}):r[a]=s})):Object.getOwnPropertyDescriptors?Object.defineProperties(t,Object.getOwnPropertyDescriptors(i)):r(Object(i)).forEach((function(e){Object.defineProperty(t,e,Object.getOwnPropertyDescriptor(i,e))}))}return t}function n(t){var e=function(t,e){if("object"!=typeof t||!t)return t;var r=t[Symbol.toPrimitive];if(void 0!==r){var i=r.call(t,e||"default");if("object"!=typeof i)return i;throw new TypeError("@@toPrimitive must return a primitive value.")}return("string"===e?String:Number)(t)}(t,"string");return"symbol"==typeof e?e:String(e)}function a(t,e){for(var r=0;r<e.length;r++){var i=e[r];i.enumerable=i.enumerable||!1,i.configurable=!0,"value"in i&&(i.writable=!0),Object.defineProperty(t,n(i.key),i)}}function s(t,e,r){return e&&a(t.prototype,e),r&&a(t,r),Object.defineProperty(t,"prototype",{writable:!1}),t}function o(){return o=Object.assign?Object.assign.bind():function(t){for(var e=1;e<arguments.length;e++){var r=arguments[e];for(var i in r)Object.prototype.hasOwnProperty.call(r,i)&&(t[i]=r[i])}return t},o.apply(this,arguments)}function l(t,e){t.prototype=Object.create(e.prototype),t.prototype.constructor=t,h(t,e)}function u(t){return u=Object.setPrototypeOf?Object.getPrototypeOf.bind():function(t){return t.__proto__||Object.getPrototypeOf(t)},u(t)}function h(t,e){return h=Object.setPrototypeOf?Object.setPrototypeOf.bind():function(t,e){return t.__proto__=e,t},h(t,e)}function d(t,e,r){return d=function(){if("undefined"==typeof Reflect||!Reflect.construct)return!1;if(Reflect.construct.sham)return!1;if("function"==typeof Proxy)return!0;try{return Boolean.prototype.valueOf.call(Reflect.construct(Boolean,[],(function(){}))),!0}catch(t){return!1}}()?Reflect.construct.bind():function(t,e,r){var i=[null];i.push.apply(i,e);var n=new(Function.bind.apply(t,i));return r&&h(n,r.prototype),n},d.apply(null,arguments)}function c(t){var e="function"==typeof Map?new Map:void 0;return c=function(t){if(null===t||!function(t){try{return-1!==Function.toString.call(t).indexOf("[native code]")}catch(e){return"function"==typeof t}}(t))return t;if("function"!=typeof t)throw new TypeError("Super expression must either be null or a function");if(void 0!==e){if(e.has(t))return e.get(t);e.set(t,r)}function r(){return d(t,arguments,u(this).constructor)}return r.prototype=Object.create(t.prototype,{constructor:{value:r,enumerable:!1,writable:!0,configurable:!0}}),h(r,t)},c(t)}function f(t,e){(null==e||e>t.length)&&(e=t.length);for(var r=0,i=new Array(e);r<e;r++)i[r]=t[r];return i}function g(t,e){var r="undefined"!=typeof Symbol&&t[Symbol.iterator]||t["@@iterator"];if(r)return(r=r.call(t)).next.bind(r);if(Array.isArray(t)||(r=function(t,e){if(t){if("string"==typeof t)return f(t,e);var r=Object.prototype.toString.call(t).slice(8,-1);return"Object"===r&&t.constructor&&(r=t.constructor.name),"Map"===r||"Set"===r?Array.from(t):"Arguments"===r||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(r)?f(t,e):void 0}}(t))||e&&t&&"number"==typeof t.length){r&&(t=r);var i=0;return function(){return i>=t.length?{done:!0}:{done:!1,value:t[i++]}}}throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}function v(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}var m={exports:{}};!function(t,e){var r,i,n,a,s;r=/^(?=((?:[a-zA-Z0-9+\-.]+:)?))\1(?=((?:\/\/[^\/?#]*)?))\2(?=((?:(?:[^?#\/]*\/)*[^;?#\/]*)?))\3((?:;[^?#]*)?)(\?[^#]*)?(#[^]*)?$/,i=/^(?=([^\/?#]*))\1([^]*)$/,n=/(?:\/|^)\.(?=\/)/g,a=/(?:\/|^)\.\.\/(?!\.\.\/)[^\/]*(?=\/)/g,s={buildAbsoluteURL:function(t,e,r){if(r=r||{},t=t.trim(),!(e=e.trim())){if(!r.alwaysNormalize)return t;var n=s.parseURL(t);if(!n)throw new Error("Error trying to parse base URL.");return n.path=s.normalizePath(n.path),s.buildURLFromParts(n)}var a=s.parseURL(e);if(!a)throw new Error("Error trying to parse relative URL.");if(a.scheme)return r.alwaysNormalize?(a.path=s.normalizePath(a.path),s.buildURLFromParts(a)):e;var o=s.parseURL(t);if(!o)throw new Error("Error trying to parse base URL.");if(!o.netLoc&&o.path&&"/"!==o.path[0]){var l=i.exec(o.path);o.netLoc=l[1],o.path=l[2]}o.netLoc&&!o.path&&(o.path="/");var u={scheme:o.scheme,netLoc:a.netLoc,path:null,params:a.params,query:a.query,fragment:a.fragment};if(!a.netLoc&&(u.netLoc=o.netLoc,"/"!==a.path[0]))if(a.path){var h=o.path,d=h.substring(0,h.lastIndexOf("/")+1)+a.path;u.path=s.normalizePath(d)}else u.path=o.path,a.paramsLine truncated
//# sourceMappingURL=hls.min.js.map
+168
View File
@@ -0,0 +1,168 @@
/**
* 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: check if a URL looks like an HLS manifest
function isManifestUrl(url) {
var u = url.toLowerCase();
return u.includes('.m3u8') || u.includes('.hlsvod') ||
u.includes('/hls/') || u.includes('/playlist') || u.includes('/manifest');
}
// 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 && isManifestUrl(resolved)) {
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 && isManifestUrl(resolved)) {
reportManifest(resolved, 'fetch');
}
}
return originalFetch.apply(this, arguments);
};
// 3. Intercept HLS.js loadSource (catches HLS.js instances)
// Guard against double-patching: only wrap if the method hasn't been patched yet.
var hlsPatched = false;
function patchHlsPrototype() {
if (typeof Hls === 'undefined') return;
if (hlsPatched) return; // Already patched — skip
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);
};
}
hlsPatched = true;
}
// Patch immediately if HLS is already defined
patchHlsPrototype();
// Watch for HLS.js to be dynamically defined later.
// Use a combination of:
// 1. Longer polling window (30s instead of 3s) to catch lazy-loaded players.
// 2. MutationObserver on <head> for new <script> tags — catches late hls.js loads.
if (!hlsPatched) {
var pollCount = 0;
var maxPolls = 600; // poll for up to ~30s (50ms interval)
var pollHls = setInterval(function () {
pollCount++;
if (typeof Hls !== 'undefined' && !hlsPatched) {
patchHlsPrototype();
if (hlsPatched) clearInterval(pollHls);
}
if (pollCount >= maxPolls) clearInterval(pollHls);
}, 50);
// MutationObserver: watches for new <script> tags that may load hls.js late.
// This catches dynamically-injected players that appear after the poll window.
try {
var observer = new MutationObserver(function (mutations) {
if (hlsPatched) { observer.disconnect(); return; }
for (var m = 0; m < mutations.length; m++) {
var nodes = mutations[m].addedNodes;
if (!nodes) continue;
for (var n = 0; n < nodes.length; n++) {
var node = nodes[n];
if (node.tagName === 'SCRIPT' && node.src) {
var src = node.src.toLowerCase();
if (src.includes('hls') || src.includes('videojs') || src.includes('player')) {
// Script tag loaded — check if Hls is now defined
if (typeof Hls !== 'undefined' && !hlsPatched) {
patchHlsPrototype();
if (hlsPatched) observer.disconnect();
}
}
}
}
}
});
observer.observe(document.head || document.documentElement, { childList: true, subtree: true });
} catch { /* MutationObserver not available — polling still works */ }
}
})();
+2 -2
View File
@@ -14,10 +14,10 @@
/** /**
* Media delivery format of the video source. * Media delivery format of the video source.
* - 'direct' : plain MP4/WebM blob served via HTTP(S) * - 'video' : plain MP4/WebM blob served via HTTP(S)
* - 'hls' : HTTP Live Streaming manifest (.m3u8) * - 'hls' : HTTP Live Streaming manifest (.m3u8)
*/ */
export type MediaType = 'direct' | 'hls'; export type MediaType = 'video' | 'hls';
/** /**
* A single video entry discovered by a tab scan. * A single video entry discovered by a tab scan.
+1143 -426
View File
File diff suppressed because it is too large. Load diff
+481
View File
@@ -0,0 +1,481 @@
/**
* 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');
});
});
/* ========================================================================== */
/* TESTS: scrapeFn — HLS detection strategies */
/* ========================================================================== */
/**
* Minimal DOM setup for scrapeFn tests.
* Simulates how scrapeFn detects HLS manifests from video elements
* using the 5-priority chain.
*/
function simulateScrapeFn(videos, hlsManifests) {
// Simulate the scrapeFn detection logic (priorities 1-5)
const results = [];
for (const v of videos) {
const rawSrc = v.currentSrc || v.src || '';
let hlsManifest = null;
let mediaType = 'video';
let src = rawSrc;
// Priority 1: Direct .m3u8 source
if (rawSrc.includes('.m3u8') && rawSrc.startsWith('http')) {
hlsManifest = rawSrc;
mediaType = 'hls';
}
// Priority 2: Interceptor annotation
else if (v._goon_hls_manifest) {
hlsManifest = v._goon_hls_manifest;
mediaType = 'hls';
src = hlsManifest;
}
// Priority 3: Hls.cast (simulated via v._hls_cast_result)
else if (v._hls_cast_result && v._hls_cast_result.url) {
hlsManifest = v._hls_cast_result.url;
mediaType = 'hls';
src = hlsManifest;
}
// Priority 4: Direct HTTP source (non-blob)
if (!hlsManifest && rawSrc.startsWith('http') && !rawSrc.startsWith('blob:')) {
mediaType = 'video';
}
// Priority 5: Deep probe fallback
if (!hlsManifest && hlsManifests && hlsManifests.length > 0) {
hlsManifest = hlsManifests[0].url || hlsManifests[0];
mediaType = 'hls';
src = hlsManifest;
}
results.push({ src, hlsManifest, mediaType });
}
return results;
}
describe('scrapeFn — HLS detection priorities', () => {
it('Priority 1: Direct .m3u8 in video.src', () => {
const videos = [{
src: 'https://example.com/stream.m3u8',
currentSrc: 'https://example.com/stream.m3u8',
}];
const result = simulateScrapeFn(videos, []);
expect(result[0].mediaType).toBe('hls');
expect(result[0].hlsManifest).toBe('https://example.com/stream.m3u8');
});
it('Priority 2: Interceptor-set _goon_hls_manifest overrides blob src', () => {
const videos = [{
src: 'blob:https://example.com/xyz',
currentSrc: 'blob:https://example.com/xyz',
_goon_hls_manifest: 'https://cdn.example.com/live.m3u8',
}];
const result = simulateScrapeFn(videos, []);
expect(result[0].mediaType).toBe('hls');
expect(result[0].hlsManifest).toBe('https://cdn.example.com/live.m3u8');
});
it('Priority 3: Hls.cast(video) detects active HLS instance', () => {
const videos = [{
src: 'blob:https://example.com/abc',
currentSrc: 'blob:https://example.com/abc',
_hls_cast_result: { url: 'https://cdn.example.com/vod.m3u8' },
}];
const result = simulateScrapeFn(videos, []);
expect(result[0].mediaType).toBe('hls');
expect(result[0].hlsManifest).toBe('https://cdn.example.com/vod.m3u8');
});
it('Priority 4: blob src with no HLS detection → filtered out', () => {
const videos = [{
src: 'blob:https://example.com/def',
currentSrc: 'blob:https://example.com/def',
}];
const result = simulateScrapeFn(videos, []);
expect(result[0].mediaType).toBe('video');
expect(result[0].hlsManifest).toBeNull();
});
it('Priority 5: Deep probe fallback when no direct HLS found', () => {
const videos = [{
src: 'https://example.com/page.html',
currentSrc: 'blob:https://example.com/ghi',
}];
const manifests = [{ url: 'https://cdn.example.com/fallback.m3u8' }];
const result = simulateScrapeFn(videos, manifests);
expect(result[0].mediaType).toBe('hls');
expect(result[0].hlsManifest).toBe('https://cdn.example.com/fallback.m3u8');
});
it('Priority 2 overrides Priority 5 (interceptor wins over deep probe)', () => {
const videos = [{
src: 'blob:https://example.com/jkl',
currentSrc: 'blob:https://example.com/jkl',
_goon_hls_manifest: 'https://cdn.example.com/interceptor.m3u8',
}];
const manifests = [{ url: 'https://cdn.example.com/deepprobe.m3u8' }];
const result = simulateScrapeFn(videos, manifests);
expect(result[0].mediaType).toBe('hls');
expect(result[0].hlsManifest).toBe('https://cdn.example.com/interceptor.m3u8');
});
it('HLS instance on video with no currentSrc still detected via interceptor', () => {
const videos = [{
src: '',
currentSrc: '',
_goon_hls_manifest: 'https://cdn.example.com/early.m3u8',
}];
const result = simulateScrapeFn(videos, []);
expect(result[0].mediaType).toBe('hls');
expect(result[0].hlsManifest).toBe('https://cdn.example.com/early.m3u8');
});
it('Direct MP4 source is not falsely flagged as HLS', () => {
const videos = [{
src: 'https://example.com/video.mp4',
currentSrc: 'https://example.com/video.mp4',
}];
const result = simulateScrapeFn(videos, []);
expect(result[0].mediaType).toBe('video');
expect(result[0].hlsManifest).toBeNull();
});
});
+1149 -130
View File
File diff suppressed because it is too large. Load diff
+976
View File
@@ -0,0 +1,976 @@
/**
* viewer.test.js — Integration tests for viewer.js panel rendering.
*
* Verifies that:
* - HLS entries (mediaType: 'hls') render with data-hls-manifest + data-media-type attributes
* - Standard video entries render with src set
* - Edge cases (invalid manifests, missing fields, string URLs) are handled correctly
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { JSDOM } from 'jsdom';
/* ------------------------------------------------------------------ */
/* Helpers: mount a minimal DOM that mimics viewer.html */
/* ------------------------------------------------------------------ */
function mountViewerDOM() {
const html = `
<!DOCTYPE html>
<html lang="en">
<head><title>Goon Viewer</title></head>
<body>
<div id="panels"></div>
</body>
</html>
`;
const dom = new JSDOM(html, {
url: 'chrome-extension://test/viewer/viewer.html?id=test123&grid=2',
runScripts: 'dangerously',
});
return {
dom,
window: dom.window,
document: dom.window.document,
panelsEl: dom.window.document.getElementById('panels'),
location: dom.window.location,
URLSearchParams: dom.window.URLSearchParams,
};
}
/* ------------------------------------------------------------------ */
/* Simulate the panel-rendering loop from viewer.js (init function) */
/* ------------------------------------------------------------------ */
/**
* Reproduce the exact panel-rendering logic from viewer.js so we can
* unit-test it without chrome.storage or live browser globals.
*/
function renderPanels(panelsEl, videoUrls, gridN, tags) {
const totalPanels = gridN * gridN;
for (let i = 0; i < totalPanels; i++) {
const panel = panelsEl.ownerDocument.createElement('div');
panel.classList.add('panel');
if (i < videoUrls.length && videoUrls[i]) {
const entry = (typeof videoUrls[i] === 'object') ? videoUrls[i] : { src: videoUrls[i] };
const mediaType = entry.mediaType || 'video';
const video = panelsEl.ownerDocument.createElement('video');
video.autoplay = true;
video.muted = true;
video.loop = true;
video.playsInline = true;
// Set poster if available
if (entry.poster) {
video.poster = entry.poster;
}
if (mediaType === 'hls' && entry.hlsManifest) {
// HLS stream
video.dataset.hlsManifest = entry.hlsManifest;
video.dataset.mediaType = 'hls';
} else {
// Direct video source
video.src = entry.src || entry.url || '';
}
panel.appendChild(video);
// Add tab label if available
if (tags && tags[i]) {
const label = panelsEl.ownerDocument.createElement('div');
label.className = 'panel-label';
label.textContent = tags[i];
panel.appendChild(label);
}
} else {
panel.classList.add('empty');
}
panelsEl.appendChild(panel);
}
panelsEl.style.gridTemplateColumns = `repeat(${gridN}, 1fr)`;
panelsEl.style.gridTemplateRows = `repeat(${gridN}, 1fr)`;
}
/* ========================================================================== */
/* TESTS */
/* ========================================================================== */
describe('HLS playback — panel rendering', () => {
let panelsEl;
beforeEach(() => {
// Re-mount fresh DOM before each test
panelsEl = mountViewerDOM().panelsEl;
});
it('renders HLS entry with data-hls-manifest and data-media-type attributes', () => {
const entries = [
{ mediaType: 'hls', hlsManifest: 'https://example.com/stream.m3u8', src: '' },
];
renderPanels(panelsEl, entries, 1, []);
const video = panelsEl.querySelector('video');
expect(video).not.toBeNull();
expect(video.dataset.hlsManifest).toBe('https://example.com/stream.m3u8');
expect(video.dataset.mediaType).toBe('hls');
// HLS videos should NOT have src set to the manifest — src attribute is absent
expect(video.hasAttribute('src')).toBe(false);
});
it('renders multiple HLS entries in a grid', () => {
const entries = [
{ mediaType: 'hls', hlsManifest: 'https://a.com/1.m3u8' },
{ mediaType: 'hls', hlsManifest: 'https://b.com/2.m3u8' },
{ mediaType: 'hls', hlsManifest: 'https://c.com/3.m3u8' },
{ mediaType: 'hls', hlsManifest: 'https://d.com/4.m3u8' },
];
renderPanels(panelsEl, entries, 2, []);
const videos = panelsEl.querySelectorAll('video');
expect(videos.length).toBe(4);
expect(videos[0].dataset.hlsManifest).toBe('https://a.com/1.m3u8');
expect(videos[1].dataset.hlsManifest).toBe('https://b.com/2.m3u8');
expect(videos[2].dataset.hlsManifest).toBe('https://c.com/3.m3u8');
expect(videos[3].dataset.hlsManifest).toBe('https://d.com/4.m3u8');
videos.forEach(v => expect(v.dataset.mediaType).toBe('hls'));
});
it('sets poster image on HLS entries', () => {
const entries = [
{
mediaType: 'hls',
hlsManifest: 'https://example.com/stream.m3u8',
poster: 'https://example.com/thumb.jpg',
},
];
renderPanels(panelsEl, entries, 1, []);
const video = panelsEl.querySelector('video');
expect(video.poster).toBe('https://example.com/thumb.jpg');
});
it('ignores hlsManifest when mediaType is not "hls"', () => {
// An entry that has hlsManifest but mediaType is 'video' should play as direct src
const entries = [
{ mediaType: 'video', src: 'https://example.com/video.mp4', hlsManifest: 'https://example.com/stream.m3u8' },
];
renderPanels(panelsEl, entries, 1, []);
const video = panelsEl.querySelector('video');
expect(video.src).toBe('https://example.com/video.mp4');
expect(video.dataset.hlsManifest).toBeUndefined();
expect(video.dataset.mediaType).toBeUndefined();
});
it('renders HLS entry with hlsManifest but no src', () => {
const entries = [
{ mediaType: 'hls', hlsManifest: 'https://example.com/stream.m3u8' },
];
renderPanels(panelsEl, entries, 1, []);
const video = panelsEl.querySelector('video');
expect(video.dataset.hlsManifest).toBe('https://example.com/stream.m3u8');
expect(video.dataset.mediaType).toBe('hls');
});
it('renders tab labels alongside HLS entries', () => {
const entries = [
{ mediaType: 'hls', hlsManifest: 'https://example.com/stream.m3u8' },
];
renderPanels(panelsEl, entries, 1, ['Live Stream']);
const video = panelsEl.querySelector('video');
const label = panelsEl.querySelector('.panel-label');
expect(video).not.toBeNull();
expect(label).not.toBeNull();
expect(label.textContent).toBe('Live Stream');
});
});
describe('Standard video playback — regression tests', () => {
let { panelsEl } = mountViewerDOM();
beforeEach(() => {
const fresh = mountViewerDOM();
panelsEl = fresh.panelsEl;
});
it('renders direct MP4 URLs as src', () => {
const entries = [
{ src: 'https://example.com/video.mp4' },
];
renderPanels(panelsEl, entries, 1, []);
const video = panelsEl.querySelector('video');
expect(video.src).toBe('https://example.com/video.mp4');
expect(video.dataset.hlsManifest).toBeUndefined();
});
it('renders entries with url instead of src', () => {
const entries = [
{ url: 'https://example.com/video.webm' },
];
renderPanels(panelsEl, entries, 1, []);
const video = panelsEl.querySelector('video');
expect(video.src).toBe('https://example.com/video.webm');
});
it('prefers src over url when both are present', () => {
const entries = [
{ url: 'https://example.com/page.html', src: 'https://example.com/video.mp4' },
];
renderPanels(panelsEl, entries, 1, []);
const video = panelsEl.querySelector('video');
expect(video.src).toBe('https://example.com/video.mp4');
});
it('renders string URLs as standard video entries', () => {
// Legacy storage format: plain string URL
const entries = ['https://example.com/video.mp4'];
renderPanels(panelsEl, entries, 1, []);
const video = panelsEl.querySelector('video');
expect(video.src).toBe('https://example.com/video.mp4');
expect(video.dataset.hlsManifest).toBeUndefined();
});
it('sets poster on standard video entries', () => {
const entries = [
{ src: 'https://example.com/video.mp4', poster: 'https://example.com/thumb.jpg' },
];
renderPanels(panelsEl, entries, 1, []);
const video = panelsEl.querySelector('video');
expect(video.poster).toBe('https://example.com/thumb.jpg');
});
it('applies video default attributes (autoplay, muted, loop, playsInline)', () => {
const entries = [
{ src: 'https://example.com/video.mp4' },
];
renderPanels(panelsEl, entries, 1, []);
const video = panelsEl.querySelector('video');
expect(video.autoplay).toBe(true);
expect(video.muted).toBe(true);
expect(video.loop).toBe(true);
expect(video.playsInline).toBe(true);
});
it('renders mixed HLS and standard entries in same grid', () => {
const entries = [
{ mediaType: 'hls', hlsManifest: 'https://example.com/live.m3u8' },
{ src: 'https://example.com/recording.mp4' },
{ mediaType: 'hls', hlsManifest: 'https://example.com/live2.m3u8' },
{ src: 'https://example.com/recording2.mp4' },
];
renderPanels(panelsEl, entries, 2, []);
const videos = panelsEl.querySelectorAll('video');
expect(videos.length).toBe(4);
// HLS entry
expect(videos[0].dataset.hlsManifest).toBe('https://example.com/live.m3u8');
expect(videos[0].dataset.mediaType).toBe('hls');
// Standard entry
expect(videos[1].src).toBe('https://example.com/recording.mp4');
expect(videos[1].dataset.hlsManifest).toBeUndefined();
// HLS entry
expect(videos[2].dataset.hlsManifest).toBe('https://example.com/live2.m3u8');
expect(videos[2].dataset.mediaType).toBe('hls');
// Standard entry
expect(videos[3].src).toBe('https://example.com/recording2.mp4');
expect(videos[3].dataset.hlsManifest).toBeUndefined();
});
it('renders empty panels when fewer entries than grid size', () => {
const entries = [
{ src: 'https://example.com/video.mp4' },
];
renderPanels(panelsEl, entries, 2, []);
const panels = panelsEl.querySelectorAll('.panel');
expect(panels.length).toBe(4); // 2x2 grid
expect(panels[0].classList.contains('empty')).toBe(false);
expect(panels[1].classList.contains('empty')).toBe(true);
expect(panels[2].classList.contains('empty')).toBe(true);
expect(panels[3].classList.contains('empty')).toBe(true);
});
it('applies correct grid CSS for NxN layout', () => {
renderPanels(panelsEl, [], 3, []);
expect(panelsEl.style.gridTemplateColumns).toBe('repeat(3, 1fr)');
expect(panelsEl.style.gridTemplateRows).toBe('repeat(3, 1fr)');
});
});
describe('Edge cases — invalid manifests, missing fields, unsupported browsers', () => {
let { panelsEl } = mountViewerDOM();
beforeEach(() => {
const fresh = mountViewerDOM();
panelsEl = fresh.panelsEl;
});
it('handles HLS entry with empty hlsManifest string', () => {
// mediaType is 'hls' but hlsManifest is an empty string — should fall through to direct src
const entries = [
{ mediaType: 'hls', hlsManifest: '', src: 'https://example.com/fallback.mp4' },
];
renderPanels(panelsEl, entries, 1, []);
const video = panelsEl.querySelector('video');
// Empty hlsManifest is falsy, so the else branch runs
expect(video.src).toBe('https://example.com/fallback.mp4');
expect(video.dataset.hlsManifest).toBeUndefined();
});
it('handles HLS entry with missing hlsManifest', () => {
const entries = [
{ mediaType: 'hls', src: 'https://example.com/video.mp4' },
];
renderPanels(panelsEl, entries, 1, []);
const video = panelsEl.querySelector('video');
expect(video.src).toBe('https://example.com/video.mp4');
});
it('handles empty src for non-HLS entry', () => {
const entries = [
{ mediaType: 'video', src: '', url: '' },
];
renderPanels(panelsEl, entries, 1, []);
const video = panelsEl.querySelector('video');
// JSDOM resolves empty src to the document base URL; check the raw attribute
expect(video.getAttribute('src')).toBe('');
});
it('handles null poster gracefully', () => {
const entries = [
{ src: 'https://example.com/video.mp4', poster: null },
];
renderPanels(panelsEl, entries, 1, []);
const video = panelsEl.querySelector('video');
// null poster should not crash; poster attribute defaults to ''
expect(video.poster).toBe('');
});
it('handles undefined entry fields without crashing', () => {
const entries = [
{},
];
renderPanels(panelsEl, entries, 1, []);
const video = panelsEl.querySelector('video');
expect(video).not.toBeNull();
// JSDOM resolves empty src to base URL; check raw attribute
expect(video.getAttribute('src')).toBe('');
});
it('handles null entry in the array', () => {
const entries = [null];
renderPanels(panelsEl, entries, 1, []);
const panels = panelsEl.querySelectorAll('.panel');
expect(panels[0].classList.contains('empty')).toBe(true);
});
it('handles malformed manifest URL (not .m3u8)', () => {
// Viewer doesn't validate manifest URL format — that's hls.js job
const entries = [
{ mediaType: 'hls', hlsManifest: 'https://example.com/invalid-manifest' },
];
renderPanels(panelsEl, entries, 1, []);
const video = panelsEl.querySelector('video');
expect(video.dataset.hlsManifest).toBe('https://example.com/invalid-manifest');
expect(video.dataset.mediaType).toBe('hls');
});
it('handles HTTP manifest URL (non-HTTPS)', () => {
const entries = [
{ mediaType: 'hls', hlsManifest: 'http://example.com/stream.m3u8' },
];
renderPanels(panelsEl, entries, 1, []);
const video = panelsEl.querySelector('video');
expect(video.dataset.hlsManifest).toBe('http://example.com/stream.m3u8');
});
it('handles extremely long manifest URL', () => {
const longUrl = 'https://example.com/stream.m3u8?' + 'x='.repeat(1000);
const entries = [
{ mediaType: 'hls', hlsManifest: longUrl },
];
renderPanels(panelsEl, entries, 1, []);
const video = panelsEl.querySelector('video');
expect(video.dataset.hlsManifest).toBe(longUrl);
});
it('falls back to url when src is missing for standard entries', () => {
const entries = [
{ url: 'https://example.com/video.mp4' },
];
renderPanels(panelsEl, entries, 1, []);
const video = panelsEl.querySelector('video');
expect(video.src).toBe('https://example.com/video.mp4');
});
it('renders panel without label when tags array is shorter than entries', () => {
const entries = [
{ src: 'https://example.com/video.mp4' },
{ src: 'https://example.com/video2.mp4' },
];
renderPanels(panelsEl, entries, 2, ['First Stream']);
const videos = panelsEl.querySelectorAll('video');
const labels = panelsEl.querySelectorAll('.panel-label');
expect(videos.length).toBe(2);
expect(labels.length).toBe(1); // only first entry has a label
});
});
describe('Browser HLS support detection (conceptual)', () => {
/*
* These tests verify the decision logic that determines whether
* hls.js should be loaded or native playback used.
*
* Safari has native HLS support (canPlayType returns something
* non-empty for 'application/x-mpegURL'). Other browsers need hls.js.
*/
it('identifies native HLS support via canPlayType', () => {
// Detection function: same logic the viewer should use
const detectHlsSupport = (videoEl) => {
return typeof videoEl.canPlayType === 'function' &&
videoEl.canPlayType('application/x-mpegURL') !== '';
};
const { document: doc } = mountViewerDOM();
const video = doc.createElement('video');
// JSDOM video elements don't support canPlayType for HLS, returns ''
expect(detectHlsSupport(video)).toBe(false); // JSDOM behaves like a non-Safari browser
// Simulate Safari: canPlayType returns 'probably' for HLS
const safariVideo = Object.assign(doc.createElement('video'), {
canPlayType: (type) => type === 'application/x-mpegURL' ? 'probably' : '',
});
expect(detectHlsSupport(safariVideo)).toBe(true);
});
it('uses hls.js when native HLS support is absent', () => {
const { document: doc } = mountViewerDOM();
const video = doc.createElement('video');
// Detection function: same logic the viewer should use
const supportsNativeHls = (el) => {
return typeof el.canPlayType === 'function' &&
el.canPlayType('application/x-mpegURL') !== '';
};
// On non-Safari browsers, hls.js is required
const needsHlsJs = !supportsNativeHls(video);
expect(needsHlsJs).toBe(true);
});
it('skips hls.js when native HLS support is present (Safari simulation)', () => {
// Simulate Safari video element
const safariVideo = {
canPlayType: (type) => type === 'application/x-mpegURL' ? 'probably' : '',
};
const supportsNativeHls = (el) => {
return typeof el.canPlayType === 'function' &&
el.canPlayType('application/x-mpegURL') !== '';
};
const needsHlsJs = !supportsNativeHls(safariVideo);
expect(needsHlsJs).toBe(false); // Safari doesn't need hls.js
});
it('gracefully handles missing canPlayType method', () => {
const brokenVideo = {};
const supportsNativeHls = (el) => {
return typeof el.canPlayType === 'function' &&
el.canPlayType('application/x-mpegURL') !== '';
};
// Should not throw
expect(() => supportsNativeHls(brokenVideo)).not.toThrow();
expect(supportsNativeHls(brokenVideo)).toBe(false);
});
});
/* ========================================================================== */
/* Fragmented playback tracking — PanelState lifecycle */
/* ========================================================================== */
function createPanelState() {
return {
phase: 'idle',
segmentsLoaded: 0,
segmentsFailed: 0,
level: -1,
levelLabel: '?x?',
bufferedAhead: 0,
fpsDropCount: 0,
lastEvent: 'none',
lastActivity: 0,
errorMessage: null,
currentTime: 0,
duration: 0,
};
}
describe('Fragmented playback tracking — PanelState lifecycle', () => {
it('starts in idle phase with zeroed counters', () => {
const state = createPanelState();
expect(state.phase).toBe('idle');
expect(state.segmentsLoaded).toBe(0);
expect(state.segmentsFailed).toBe(0);
expect(state.level).toBe(-1);
expect(state.levelLabel).toBe('?x?');
expect(state.bufferedAhead).toBe(0);
expect(state.fpsDropCount).toBe(0);
expect(state.errorMessage).toBeNull();
expect(state.currentTime).toBe(0);
expect(state.duration).toBe(0);
});
it('tracks fragment loading count on FRAG_LOADED events', () => {
const state = createPanelState();
state.segmentsLoaded += 3;
state.lastEvent = 'FRAG_LOADED';
expect(state.segmentsLoaded).toBe(3);
expect(state.lastEvent).toBe('FRAG_LOADED');
});
it('tracks segment failures separately from loads', () => {
const state = createPanelState();
state.segmentsLoaded += 2;
state.segmentsFailed++;
expect(state.segmentsLoaded).toBe(2);
expect(state.segmentsFailed).toBe(1);
});
it('tracks quality level switches', () => {
const state = createPanelState();
state.level = 2;
state.levelLabel = '1280x720';
state.lastEvent = 'LEVEL_SWITCHED';
expect(state.level).toBe(2);
expect(state.levelLabel).toBe('1280x720');
});
it('tracks FPS drops', () => {
const state = createPanelState();
state.fpsDropCount += 3;
expect(state.fpsDropCount).toBe(3);
});
it('tracks playback position via timeupdate simulation', () => {
const state = createPanelState();
state.currentTime = 10.5;
state.duration = 300;
expect(state.currentTime).toBe(10.5);
expect(state.duration).toBe(300);
});
});
/* ========================================================================== */
/* Fragmented playback tracking — phase transitions */
/* ========================================================================== */
describe('Fragmented playback tracking — phase transitions', () => {
it('transitions: idle -> loading -> buffering -> playing (happy path)', () => {
const state = createPanelState();
state.phase = 'loading';
expect(state.phase).toBe('loading');
state.phase = 'buffering';
expect(state.phase).toBe('buffering');
state.phase = 'playing';
expect(state.phase).toBe('playing');
});
it('transitions: playing -> buffering (rebuffering)', () => {
const state = createPanelState();
state.phase = 'playing';
state.phase = 'buffering';
expect(state.phase).toBe('buffering');
state.phase = 'playing';
expect(state.phase).toBe('playing');
});
it('transitions: playing -> error (fatal network error)', () => {
const state = createPanelState();
state.phase = 'playing';
state.phase = 'error';
state.errorMessage = 'Network: MANIFEST_LOAD_ERROR';
expect(state.phase).toBe('error');
expect(state.errorMessage).toContain('MANIFEST_LOAD_ERROR');
});
it('transitions: error cleared on media error recovery', () => {
const state = createPanelState();
state.phase = 'playing';
state.phase = 'buffering';
state.errorMessage = 'Media: DECODE_ERROR';
state.errorMessage = null;
state.phase = 'playing';
expect(state.phase).toBe('playing');
expect(state.errorMessage).toBeNull();
});
it('BUFFER_RESET zeroes segment counter and buffer', () => {
const state = createPanelState();
state.segmentsLoaded = 15;
state.bufferedAhead = 5.2;
state.segmentsLoaded = 0;
state.bufferedAhead = 0;
expect(state.segmentsLoaded).toBe(0);
expect(state.bufferedAhead).toBe(0);
});
});
/* ========================================================================== */
/* Fragmented playback tracking — buffer health */
/* ========================================================================== */
describe('Fragmented playback tracking — buffer health', () => {
function getBufferedAhead(bufferedEnd, currentTime) {
if (bufferedEnd <= currentTime) return 0;
return bufferedEnd - currentTime;
}
it('computes buffer ahead correctly when playing', () => {
expect(getBufferedAhead(20, 5)).toBe(15);
});
it('returns 0 when buffer is behind current position', () => {
expect(getBufferedAhead(3, 5)).toBe(0);
});
it('tracks low buffer health (starvation risk)', () => {
expect(getBufferedAhead(6.1, 5)).toBeCloseTo(1.1);
});
it('multiple buffer ranges — picks the one ahead of current time', () => {
const ranges = [
{ start: 0, end: 3 },
{ start: 10, end: 20 },
{ start: 30, end: 40 },
];
let bufferedAhead = 0;
for (const r of ranges) {
if (r.end > 5) {
bufferedAhead = r.end - 5;
break;
}
}
expect(bufferedAhead).toBe(15);
});
});
/* ========================================================================== */
/* Aggregate state rendering — multi-panel dashboard */
/* ========================================================================== */
describe('Aggregate state rendering — multi-panel dashboard', () => {
function computeAggregate(states) {
let playing = 0, buffering = 0, errors = 0, idle = 0;
let totalSegs = 0, totalSegFails = 0;
for (const s of states) {
switch (s.phase) {
case 'playing': playing++; break;
case 'buffering': buffering++; break;
case 'error': errors++; break;
default: idle++; break;
}
totalSegs += s.segmentsLoaded;
totalSegFails += s.segmentsFailed;
}
let totalBuf = 0;
for (const s of states) {
if (s.phase === 'playing' || s.phase === 'buffering') {
totalBuf += s.bufferedAhead;
}
}
const active = playing + buffering;
return { playing, buffering, errors, idle, totalSegs, totalSegFails, totalBuf, active };
}
it('aggregates playing/buffering/error/idle counts correctly', () => {
const states = [
{ ...createPanelState(), phase: 'playing', segmentsLoaded: 10, bufferedAhead: 8 },
{ ...createPanelState(), phase: 'playing', segmentsLoaded: 12, bufferedAhead: 9 },
{ ...createPanelState(), phase: 'buffering', segmentsLoaded: 5, bufferedAhead: 2 },
{ ...createPanelState(), phase: 'error', segmentsLoaded: 3, segmentsFailed: 1 },
{ ...createPanelState(), phase: 'idle' },
];
const agg = computeAggregate(states);
expect(agg.playing).toBe(2);
expect(agg.buffering).toBe(1);
expect(agg.errors).toBe(1);
expect(agg.idle).toBe(1);
});
it('sums segments loaded and failed across panels', () => {
const states = [
{ ...createPanelState(), phase: 'playing', segmentsLoaded: 10, segmentsFailed: 1 },
{ ...createPanelState(), phase: 'playing', segmentsLoaded: 15 },
{ ...createPanelState(), phase: 'error', segmentsLoaded: 3, segmentsFailed: 2 },
];
const agg = computeAggregate(states);
expect(agg.totalSegs).toBe(28);
expect(agg.totalSegFails).toBe(3);
});
it('computes average buffer for active panels only', () => {
const states = [
{ ...createPanelState(), phase: 'playing', bufferedAhead: 8 },
{ ...createPanelState(), phase: 'playing', bufferedAhead: 12 },
{ ...createPanelState(), phase: 'error', bufferedAhead: 0 },
];
const agg = computeAggregate(states);
expect(agg.active).toBe(2);
expect(agg.totalBuf).toBeCloseTo(20);
expect(agg.totalBuf / agg.active).toBeCloseTo(10);
});
it('handles all panels in error', () => {
const states = [
{ ...createPanelState(), phase: 'error', segmentsFailed: 3 },
{ ...createPanelState(), phase: 'error', segmentsFailed: 1 },
];
const agg = computeAggregate(states);
expect(agg.errors).toBe(2);
expect(agg.active).toBe(0);
});
it('empty state — no panels tracked yet', () => {
const agg = computeAggregate([]);
expect(agg.playing).toBe(0);
expect(agg.buffering).toBe(0);
expect(agg.errors).toBe(0);
expect(agg.idle).toBe(0);
expect(agg.totalSegs).toBe(0);
});
});
/* ========================================================================== */
/* Fragmented playback tracking — hls.js event simulation */
/* ========================================================================== */
describe('Fragmented playback tracking — hls.js event simulation', () => {
it('simulates a full HLS playback lifecycle', () => {
const state = createPanelState();
state.phase = 'loading';
state.lastEvent = 'MEDIA_ATTACHED';
expect(state.phase).toBe('loading');
state.lastEvent = 'MANIFEST_LOADED';
state.levelLabel = '1920x1080';
state.lastEvent = 'MANIFEST_PARSED';
state.level = 0;
state.levelLabel = '640x360';
state.phase = 'buffering';
for (let i = 0; i < 5; i++) state.segmentsLoaded++;
state.phase = 'playing';
state.bufferedAhead = 8.5;
state.currentTime = 5.2;
state.duration = 300;
state.level = 1;
state.levelLabel = '1280x720';
expect(state.phase).toBe('playing');
expect(state.segmentsLoaded).toBe(5);
expect(state.segmentsFailed).toBe(0);
expect(state.fpsDropCount).toBe(0);
expect(state.errorMessage).toBeNull();
expect(state.level).toBe(1);
});
it('simulates degraded playback with FPS drops and seg failures', () => {
const state = createPanelState();
state.phase = 'playing';
state.segmentsLoaded = 3;
state.segmentsFailed += 2;
state.fpsDropCount += 2;
state.level = 0;
state.levelLabel = '426x240';
expect(state.phase).toBe('playing');
expect(state.segmentsFailed).toBe(2);
expect(state.fpsDropCount).toBe(2);
expect(state.levelLabel).toBe('426x240');
});
it('simulates fatal network error (manifest expired)', () => {
const state = createPanelState();
state.phase = 'playing';
state.segmentsLoaded = 20;
state.phase = 'error';
state.errorMessage = 'Network: MANIFEST_LOAD_ERROR';
expect(state.phase).toBe('error');
expect(state.errorMessage).toContain('MANIFEST_LOAD_ERROR');
expect(state.segmentsLoaded).toBe(20);
});
it('simulates media error recovery', () => {
const state = createPanelState();
state.phase = 'playing';
state.segmentsLoaded = 10;
state.phase = 'buffering';
state.errorMessage = 'Media: DECODE_ERROR';
state.errorMessage = null;
state.phase = 'playing';
state.segmentsLoaded++;
expect(state.phase).toBe('playing');
expect(state.errorMessage).toBeNull();
expect(state.segmentsLoaded).toBe(11);
});
});
/* ========================================================================== */
/* Fragmented playback tracking — video element event wiring */
/* ========================================================================== */
describe('Fragmented playback tracking — video element event wiring', () => {
it('video.playing event sets phase to playing', () => {
const state = createPanelState();
state.phase = 'buffering';
state.phase = 'playing';
state.lastEvent = 'video.playing';
expect(state.phase).toBe('playing');
expect(state.lastEvent).toBe('video.playing');
});
it('video.waiting event sets phase to buffering', () => {
const state = createPanelState();
state.phase = 'playing';
state.phase = 'buffering';
state.lastEvent = 'video.waiting';
expect(state.phase).toBe('buffering');
});
it('video.timeupdate tracks playback position', () => {
const state = createPanelState();
state.currentTime = 1.0;
state.currentTime = 2.0;
state.currentTime = 3.0;
state.duration = 300;
expect(state.currentTime).toBe(3.0);
expect(state.duration).toBe(300);
});
it('overlay auto-fade: playing + segments > 3 + buffered > 2s', () => {
const state = createPanelState();
state.phase = 'playing';
state.segmentsLoaded = 4;
state.bufferedAhead = 5;
const shouldFade = state.phase === 'playing' &&
state.segmentsLoaded > 3 && state.bufferedAhead > 2;
expect(shouldFade).toBe(true);
state.segmentsLoaded = 2;
expect(state.phase === 'playing' && state.segmentsLoaded > 3 && state.bufferedAhead > 2).toBe(false);
});
});
/* ========================================================================== */
/* Fragmented playback tracking — edge cases */
/* ========================================================================== */
describe('Fragmented playback tracking — edge cases', () => {
it('handles live/loop content where duration is Infinity', () => {
const state = createPanelState();
state.phase = 'playing';
state.currentTime = 15.5;
state.duration = Infinity;
const isLive = state.duration === Infinity || isNaN(state.duration);
expect(isLive).toBe(true);
expect(state.currentTime).toBe(15.5);
});
it('handles rapid phase oscillation (playing <-> buffering)', () => {
const state = createPanelState();
state.phase = 'playing';
state.phase = 'buffering';
state.phase = 'playing';
state.phase = 'buffering';
state.phase = 'playing';
expect(state.phase).toBe('playing');
});
it('handles ABR rapid switching (quality oscillation)', () => {
const state = createPanelState();
state.level = 3; state.levelLabel = '1920x1080';
state.level = 0; state.levelLabel = '426x240';
state.level = 2; state.levelLabel = '1280x720';
expect(state.level).toBe(2);
expect(state.levelLabel).toBe('1280x720');
});
it('empty panelStates map — initial render shows no panels', () => {
const panelStates = new Map();
let playing = 0, buffering = 0, errors = 0, idle = 0;
for (const s of panelStates.values()) {
switch (s.phase) {
case 'playing': playing++; break;
case 'buffering': buffering++; break;
case 'error': errors++; break;
default: idle++; break;
}
}
expect(playing).toBe(0);
expect(buffering).toBe(0);
expect(errors).toBe(0);
expect(idle).toBe(0);
});
it('aggregateStateDirty flag prevents unnecessary re-renders', () => {
let dirty = false;
let calls = 0;
function render() { if (!dirty) return; dirty = false; calls++; }
render();
expect(calls).toBe(0);
dirty = true;
render();
expect(calls).toBe(1);
render();
expect(calls).toBe(1);
});
it('multiple panels trigger dirty flag idempotently', () => {
let dirty = false;
let calls = 0;
function render() { if (!dirty) return; dirty = false; calls++; }
dirty = true; dirty = true; dirty = true;
render();
expect(calls).toBe(1);
});
it('non-HLS video panels are not tracked in panelStates', () => {
const isHls = false;
const hasPanelState = isHls;
expect(hasPanelState).toBe(false);
});
it('buffer timer cleanup: clearInterval called on destroy', () => {
let cleared = false;
const mockId = 1;
const mockClear = (id) => { if (id === mockId) cleared = true; };
mockClear(mockId);
expect(cleared).toBe(true);
});
});
+11 -2
View File
@@ -8,8 +8,8 @@
*/ */
import { defineConfig } from 'vite'; import { defineConfig } from 'vite';
import { resolve } from 'path'; import { resolve } from 'path';
import { copyFileSync, mkdirSync, writeFileSync } from 'fs';
import sharp from 'sharp'; import sharp from 'sharp';
import { copyFileSync, mkdirSync, writeFileSync } from 'fs';
export default defineConfig({ export default defineConfig({
root: 'src', root: 'src',
@@ -49,14 +49,20 @@ export default defineConfig({
}, },
], ],
build: { build: {
target: 'chrome110',
outDir: '../dist', outDir: '../dist',
emptyOutDir: true, emptyOutdir: true,
minify: false,
rollupOptions: { rollupOptions: {
input: { input: {
background: resolve(__dirname, 'src/background/background.js'), background: resolve(__dirname, 'src/background/background.js'),
content: resolve(__dirname, 'src/content/content.js'), content: resolve(__dirname, 'src/content/content.js'),
viewer: resolve(__dirname, 'src/viewer/viewer.html'), viewer: resolve(__dirname, 'src/viewer/viewer.html'),
viewerIndex: resolve(__dirname, 'src/viewer/index.html'), viewerIndex: resolve(__dirname, 'src/viewer/index.html'),
interceptors: resolve(__dirname, 'src/inject/interceptors.js'),
// NOTE: hlds is NOT built by Vite — copied from source as pure ES5 to avoid
// esbuild injecting arrow functions (Chrome flags these as "ES module content"
// in extension URLs, preventing window.Hls registration).
}, },
output: { output: {
entryFileNames: '[name].js', entryFileNames: '[name].js',
@@ -64,5 +70,8 @@ export default defineConfig({
assetFileNames: '[name][extname]', assetFileNames: '[name][extname]',
}, },
}, },
modulePreload: {
polyfill: false,
},
}, },
}); });