Compare commits

...
45 Commits
Author SHA1 Message Date
kfj001 fbc836d9c4 Merge pull request 'ci: make release step idempotent + fix Gitea asset upload' (#9) from feature/gitea-release-workflow into master
CI / test (push) Successful in 5m43s
Release / release (push) Successful in 13s
Reviewed-on: http://192.168.1.74:30008/kfj001/goon_plugin/pulls/9
Reviewed-by: robot <robot@westerntechnologies.duckdns.org>
2026-08-31 10:27:12 -07:00
kfj001 de9d537a75 ci: make release step idempotent + fix Gitea asset upload
CI / test (pull_request) Successful in 5m37s
- resolve-or-create the release by tag to avoid the 409 'Release is has no Tag'
  when a release already exists for the tag (e.g. one made manually in the UI)
- use POST /releases/{id}/assets with multipart field 'attachment'
  (was the obsolete /_attachments route with field 'file')
2026-08-31 00:33:53 -07:00
kfj001 23a3f00df9 Merge pull request 'ci: add tag-based Gitea release workflow + bump to 0.1.1' (#8) from feature/gitea-release-workflow into master
CI / test (push) Successful in 5m32s
Release / release (push) Failing after 13s
Reviewed-on: http://192.168.1.74:30008/kfj001/goon_plugin/pulls/8
2026-08-30 23:57:59 -07:00
kfj001 0a3225035c ci: add tag-based Gitea release workflow + bump to 0.1.1
CI / test (pull_request) Successful in 5m48s
2026-08-30 23:31:12 -07:00
kfj001 0848cd51be Merge pull request 'Add user instructions to goon plugin UI' (#7) from feature/goon-instructions into master
CI / test (push) Successful in 5m39s
Reviewed-on: http://192.168.1.74:30008/kfj001/goon_plugin/pulls/7
Reviewed-by: kfj001 <kfj001@gmail.com>
2026-08-30 21:27:52 -07:00
kfj001 41723227c0 Slight copy update
CI / test (pull_request) Successful in 5m32s
2026-08-31 04:22:12 +00:00
robot 5da2003cd0 docs: draft PR description
CI / test (pull_request) Successful in 5m31s
2026-08-31 03:27:06 +00:00
robot 97abaf42d9 feat: add user instructions to viewer popup 2026-08-31 03:26:50 +00:00
kfj001 c366f0b8c4 Merge pull request 'Rebrand: rename 'goon' -> 'vidplex' + docs' (#6) from rebrand/vidplex-rename-kj into master
CI / test (push) Successful in 5m39s
Reviewed-on: http://192.168.1.74:30008/kfj001/goon_plugin/pulls/6
Reviewed-by: kfj001 <kfj001@gmail.com>
2026-08-30 19:19:26 -07:00
robot c826669c7d rename log prefixes: '[goon' -> '[vidplex' in runtime logs
CI / test (pull_request) Successful in 5m34s
2026-08-31 01:55:27 +00:00
kfj001 64d792632d Merge pull request 'refactor: decouple from site-specific CDN references' (#5) from feature/no_mention_of_pornhub into master
CI / test (push) Successful in 5m34s
Reviewed-on: http://192.168.1.74:30008/kfj001/goon_plugin/pulls/5
Reviewed-by: robot <robot@westerntechnologies.duckdns.org>
2026-08-30 17:07:51 -07:00
kfj001 3e02a6ae74 F1 BLOCKING — walk() cycle detection (src/viewer/index.js:161)
CI / test (pull_request) Successful in 5m41s
Added a per-tree WeakSet visited-guard so the probe terminates. The depth cap alone didn't stop the infinite recursion: window/_parent/_top/frames/_globalObject/_globalProxy are mutually-reachable circular Proxies, so Strategy H's window scan re-walked the same identities along exponentially many paths. The set is declared inside walk (not the outer function) so the separate player trees stay independent. This is what was hanging npm test (exit 124); it now completes in ~15s.
F2 SHOULD-FIX — doc/behavior mismatch (src/background/background.js:299-322)
Reworded the referer-capture comment block. It claimed the referer was read "via onBeforeSendHeaders, which sees the request headers," but the code uses onBeforeRequest + chrome.tabs.get (deriving the referer from the source tab's URL). Now states the actual mechanism and documents why onBeforeSendHeaders+extraHeaders was avoided (extraInfoSpec is rejected by some Chromium builds and fails the whole SW).
F3 SHOULD-FIX — leaked settings file
git rm --cached .claude/settings.local.json and added it to .gitignore. It carried machine-specific absolute paths (/Users/kyle/.claude/...).
F4 FYI — package-lock churn (package-lock.json)
Removed the spurious root "dependencies": { @rollup/rollup-darwin-arm64 } and restored the dev: true / optional: true flags on that platform package so it matches the rest of the @rollup/rollup-* siblings.
F5 FYI — real provider hostnames in test fixtures
Swapped phncdn.com→neutral example.net (registrable-domain sibling test) and thisvid.com→example.com in data_flow/hls_qa/simple_video/referer_proxy test files, rewording comments that named the real sites. src/ and __tests__/ now have zero phncdn/thisvid/pornhub references.
2026-08-30 21:56:05 +00:00
kfj001 ff463c107a fixes broken tests
CI / test (pull_request) Successful in 5m32s
2026-08-30 06:34:44 +00:00
kfj001 bd7d5bfcd0 removes unneeded dependency
CI / test (pull_request) Failing after 5m34s
2026-08-30 05:48:15 +00:00
kfj001 4357ce4094 Fixes issue with pornhub. Removes references.
CI / test (pull_request) Failing after 20s
2026-08-30 04:48:23 +00:00
kfj001 7b43d260a2 Additional fixes
CI / test (pull_request) Successful in 5m42s
2026-08-28 18:03:14 +00:00
kfj001 d3c8c85c58 removes thisvid reference from comment
CI / test (pull_request) Successful in 5m42s
2026-08-28 08:51:49 +00:00
kfj001 62d1244558 additional files 2026-08-28 08:51:27 +00:00
kfj001andClaude 0030d031f1 fix(scan): recover simple <video> detection site-agnostically
The no-mention-of-pornhub branch stopped finding plain <video> elements on
pages like thisvid.com. Two site-agnostic causes, both fixed without any
site-specific references:

1. Predicate over-rejection: isValidManifestUrl (and the nested isManifestUrl
   in m3u8DeepProbeFn) ran the placeholder/`%`/trailing-slash rejections
   BEFORE the concrete media signals. thisvid serves its direct MP4 WITH a
   trailing slash (.../14644093.mp4/?rnd=...), so `path.endsWith('/')`
   rejected a real, fetchable source. Concrete media signals (.mp4/.webm/.ogv/
   .mpd/.m4s/.m3u8/.hlsvod//hls/) now win outright, so a coincidental '%' or
   trailing slash never sinks a real source — while genuine route templates
   (/playlists/, /playlist/%1%/, /videos/{id}/) are still rejected.

2. Runtime cause on ad-iframe-heavy pages: scrapeTab ran only a MAIN-world
   scrape. A MAIN-world + allFrames injection can reject on pages with
   sandboxed/opaque-origin ad iframes, returning 0 videos even when a <video>
   is visible. scrapeTab now falls back to an isolated-world scrape (no `world`
   key — always permitted) when the MAIN-world run finds nothing. The per-frame
   reduction was extracted into a shared reduceScrapeFrames helper.

Adds __tests__/simple_video.test.mjs: a thisvid isValidManifestUrl battery
(trailing-slash MP4 accepted, templates still rejected, %2B/%2F tokens
preserved) plus a structural guard that scrapeTab keeps both a MAIN-world
primary and an isolated-world fallback.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-28 01:17:35 -07:00
kfj001 7139c5ea6f fix(scan): run deep probe/scraper in MAIN world; reject route-template "manifests"
CI / test (pull_request) Successful in 5m48s
The video-detection pipeline was finding nothing on pages that have a bare
<video> element. Two root causes:

1. world: 'MAIN' placement. The deep probe + scraper are injected with
   chrome.scripting.executeScript. Their code (and its own comments) requires
   the MAIN world to see page globals — window.Hls, per-<video> Hls instances,
   video._goon_hls_manifest, and window.__goon_plugin_data (written by the
   main-world interceptors). The calls were missing it, so every main-world
   strategy (A/A2/E/H) found 0. It was also briefly placed inside `target`,
   which chrome.scripting rejects ("Unexpected property: 'world'"); `world` is
   a sibling of `target` on ScriptInjection. Restore it as a sibling of target.

2. fake "manifest" classification. Strategy H was reporting 10 nav links /
   server-side route TEMPLATES as manifests — e.g. /playlists/,
   /playlists/liked/, /playlist/%1%/video/%2%/, /members/%ID%/playlists/ —
   because the old /playlist|/manifest substring check matches nav menus and
   leaves %1%/%ID%/%DIR% template placeholders intact. Those then became the
   stored "video" entries, drowning out the real element.

   isManifestUrl and isValidManifestUrl now reject:
     - dynamic route templates (%NAME% or {name} tokens), using a negative
       lookahead so genuine %-encoded tokens like %2B/%2F are preserved
       (the invariant the codebase explicitly protects),
     - plural collection paths (/playlists, /manifests),
     - trailing-slash directory listings.

   The 'World: ISOLATED' probe log was also misleading (typeof chrome is not a
   world discriminator — the probe provably runs in MAIN); replaced it.

Adds a regression test (hls_qa) reproducing thisvid's pageContext.langUrls
scenario: all 10 templates are rejected while a real .m3u8 carrying a %2B token
is still detected. 90/90 tests pass; 0 lint errors; build OK.
2026-08-25 20:27:14 -07:00
kfj001 e3b904783f fix(proxy): stop per-tab referer wipe and concurrent DNR rule race
CI / test (pull_request) Successful in 28s
Address robot's review on PR #5:

- [Blocking] 'goon_referers' is a host-keyed GLOBAL store and must outlive
  its capture tab; it was wrongly added to the per-tab onRemoved cleanup, so
  closing any tab wiped referers captured by still-open source tabs (-> 412).
  It is no longer removed on tab close. Stale referers are now pruned
  opportunistically in persistReferer via a per-host timestamp + TTL.

- [Should-fix] the DNR referer rule used a single shared REFERER_RULE_ID with
  a non-atomic check+update, so concurrent proxy fetches for two hosts could
  interleave and leave the wrong host's referer live. Each host now gets its
  own stable rule id (refererRuleIdFor) and the install+record is serialized
  per host via an async lock (refererRuleLocks).

- [Coverage] add __tests__/referer_proxy.test.mjs exercising the new
  referer-capture / proxy-host / onRemoved paths: closing a tab preserves
  goon_referers, two hosts proxied concurrently keep distinct per-host rules,
  and same-host proxy is idempotent.
2026-08-25 15:35:24 -07:00
kfj001 66c621825f refactor: decouple from site-specific CDN references
CI / test (pull_request) Successful in 37s
The extension had hard-coupled itself to one provider's CDN throughout
background.js and its consumers: a host allowlist, a static
declarativeNetRequest rule that forced a fixed site origin as the Referer,
site-named message types/functions, and illustrative comments naming the
site. This commit removes every reference so the code is generic and
provider-agnostic (and no longer names a specific site for reviewers).

What changed:

  - background.js
      * Dropped the PHNCDN_HOSTS allowlist; video-request capture is now
        host-agnostic (any CDN, driven by isVideoRequest / isHlsManifest).
      * Removed the static DNR rule that forced a fixed site origin as the
        Referer. Replaced with per-tab dynamic capture: an
        onBeforeSendHeaders listener captures the provider's own Referer for
        each video request the live page makes and stores it per CDN host
        (goon_referers). The proxy handler installs a DNR rule scoped to the
        requested host using that captured referer, so the provider's exact
        origin is extracted at runtime instead of hard-coded.
      * Generalized the reconstruction helpers: extractVideoId ->
        extractGroupKey (path-directory key, no /hls/videos/ assumption),
        buildManifestForVariant / reconstructManifestsFromSegments swap a
        segment filename for master.m3u8 dir-agnostically, freshLiveForVideo
        -> freshLiveForGroup, and the GOON_GET_PHNCDN_REQUESTS handler ->
        GOON_GET_VIDEO_REQUESTS. Resolver response key videoId -> groupKey
      * Reworded prose comments to describe behavior generically.

  - manifest.json
      * Added the extraHeaders permission so onBeforeSendHeaders can read the
        Referer header (needed for the dynamic capture above).

  - viewer.js / index.js (consumers)
      * Proxying decision now keys off a learned goon_proxy_hosts set
        (populated from a startup CORS probe) instead of hard-coded host
        substrings; removed the hard-coded CORS ping.
      * filterManifests / isValidManifestUrl generalized (kept the
        .m3u8 / .hlsvod / .ts behavior the tests assert); rank key
        phncdn_reconstructed -> hls_reconstructed; queryPhncdnRequests ->
        queryVideoRequests.

  - content.js, interceptors.js, and two test-prose strings: the illustrative
    "sites like <site>" comments generalized.

Verification: 83/83 tests pass, 0 lint errors. A repo-wide grep for the
previously-referenced host / site now returns no matches. The pre-existing
tsc errors in vite.config.ts are unrelated (present on the prior commit).

Note on the dynamic referer: it works while the source tab is open (it reads
that tab's real Referer); if the tab closed before capturing, the proxy falls
back to a host-derived origin, which a strict origin check may still reject --
the tradeoff of not hard-coding the site.
2026-08-25 02:26:16 -07:00
kfj001 dc2c8092f0 Merge pull request 'Feature/rebrand' (#4) from feature/rebrand into master
CI / test (push) Successful in 27s
Reviewed-on: http://192.168.1.74:30008/kfj001/goon_plugin/pulls/4
Reviewed-by: robot <robot@westerntechnologies.duckdns.org>
2026-08-24 18:42:42 -07:00
kfj001 cb607306bc Reverts plugin changes unrelated to rebranding efforts.
CI / test (pull_request) Successful in 26s
2026-08-24 23:58:57 +00:00
kfj001 7d56e836c9 minor changes to reccomended plugins
CI / test (pull_request) Successful in 25s
2026-08-24 22:53:13 +00:00
Your Name 602d6b774b more changes 2026-08-24 22:43:05 +00:00
Your Name a54d094bef work complete from phase 1 rebrand 2026-08-24 22:42:29 +00:00
kfj001 9420bce9c1 Merge pull request 'fix(tests): stop hls_qa deep-probe suite hanging under jsdom (test-only)' (#3) from fix/jsdom-cycle-hang-hls-qa into master
CI / test (push) Successful in 36s
Reviewed-on: http://192.168.1.74:30008/kfj001/goon_plugin/pulls/3
Reviewed-by: kfj001 <kfj001@gmail.com>
2026-08-24 10:28:08 -07:00
robot fd8c36d72e fix(tests): stop hls_qa deep-probe suite hanging under jsdom
CI / test (pull_request) Successful in 1m56s
CI times out on the 7 "HLS QA: Deep probe strategies" tests. Root cause
is a jsdom-only artifact, not a production bug: jsdom exposes the
self-referential cyclic globals (_globalObject/_globalProxy/_top/_parent/
window/self/top/parent/frames) as ENUMERABLE own-props on the window.
Strategy H in m3u8DeepProbeFn() runs Object.keys(window) and a no-
visited-set walk() over each, which explodes to O(204^5) ≈ 3.5e11 ops and
never terminates within the depth-5 cap, so the whole suite hangs forever.

A real Chrome MAIN-world window does NOT expose those keys as enumerable
own-props, so this is purely a test-environment mismatch. The fix is
test-only: sanitizeJsdomWinForProbe() marks the jsdom cyclic globals
non-enumerable in runProbeInWin() before eval, mirroring a real page.
No production/underlying source (src/viewer/index.js) is modified.

Local (Node 22.22.3 / Linux == CI ubuntu-latest + Node 22):
before: npm test hangs indefinitely (no Results line; CI job killed)
after:  npm run build && npm test -> 83/83 passed in ~12.5s
2026-08-24 05:10:56 +00:00
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
42 changed files with 8294 additions and 777 deletions

No files matched your search

-8
View File
@@ -1,8 +0,0 @@
{
"permissions": {
"allow": [
"Bash(npm run *)",
"Bash(node --check /home/kyle/Documents/goon_plugin/src/viewer/index.js)"
]
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"name": "Goon Plugin Dev", "name": "VidPlex Dev",
"image": "mcr.microsoft.com/devcontainers/javascript-node:22", "image": "mcr.microsoft.com/devcontainers/javascript-node:22",
"features": { "features": {
"ghcr.io/devcontainers/features/sshd:1": {} "ghcr.io/devcontainers/features/sshd:1": {}
+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
+81
View File
@@ -0,0 +1,81 @@
name: Release
on:
push:
tags:
- 'v*'
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
- name: Package dist.zip
shell: bash
run: |
cd dist
zip -r "../vidplex-${{ github.ref_name }}.zip" .
- name: Create Gitea release
shell: bash
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
GITEA_API: ${{ secrets.GITEA_API || 'http://192.168.1.74:30008/api/v1' }}
REPO: ${{ github.repository }}
TAG: ${{ github.ref_name }}
run: |
if [ -z "$GITEA_TOKEN" ]; then
echo "::warning::GITEA_TOKEN secret is not set; skipping release creation. Add it in Gitea repo Settings > Secrets (repo scope)."
exit 0
fi
json_id() { node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>{const j=JSON.parse(d||"{}");if(j.id==null){console.error("no release id:",JSON.stringify(j));process.exit(1)}console.log(j.id)})'; }
release_id=$(
curl -s -o /tmp/rel.json -w "%{http_code}" \
-H "Authorization: token $GITEA_TOKEN" \
"$GITEA_API/repos/$REPO/releases/tags/$TAG"
)
if [ "$release_id" = "200" ]; then
echo "Release for $TAG already exists; reusing it."
release_id=$(json_id < /tmp/rel.json)
else
echo "Creating release for $TAG."
release_id=$(
curl -s -o /tmp/rel.json -w "%{http_code}" -X POST \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"tag_name\": \"$TAG\", \"name\": \"$TAG\", \"body\": \"Automated release build for $TAG\"}" \
"$GITEA_API/repos/$REPO/releases"
)
case "$release_id" in
201|200) release_id=$(json_id < /tmp/rel.json) ;;
*) echo "::error::release create returned HTTP $release_id: $(cat /tmp/rel.json)"; exit 1 ;;
esac
fi
echo "Uploading asset to release $release_id."
resp=$(curl -s -o /tmp/att.json -w "%{http_code}" -X POST \
-H "Authorization: token $GITEA_TOKEN" \
-F "attachment=@vidplex-${TAG}.zip" \
"$GITEA_API/repos/$REPO/releases/$release_id/assets")
if [ "$resp" != "200" ] && [ "$resp" != "201" ]; then
echo "::error::asset upload returned HTTP $resp: $(cat /tmp/att.json)"
exit 1
fi
cat /tmp/att.json
echo
+1
View File
@@ -3,3 +3,4 @@ dist/
.vscode-insiders/ .vscode-insiders/
/icons /icons
/icons/* /icons/*
.claude/settings.local.json
+2 -2
View File
@@ -1,4 +1,4 @@
# Goon Plugin # VidPlex
A Chrome extension (Manifest V3) that finds videos on the current page and displays them in a 2x2 grid. A Chrome extension (Manifest V3) that finds videos on the current page and displays them in a 2x2 grid.
@@ -55,4 +55,4 @@ src/
## VS Code workspace ## VS Code workspace
Open `goon_plugin.code-workspace` to get the project with recommended extensions pre-installed. Open `vidplex.code-workspace` to get the project with recommended extensions pre-installed.
+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). ' +
'Some sites 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").'
);
}
});
});
+35 -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);
@@ -182,4 +185,35 @@ describe('Data flow: popup -> storage -> viewer', () => {
throw new Error('Entry with invalid mediaType should be rejected'); throw new Error('Entry with invalid mediaType should be rejected');
} }
}); });
it('an HLS entry carrying a scan-time referer passes schema + round-trips', () => {
// The referer is bound to the entry at scan time so it survives the
// watch-later flow (the source tab may be long closed). Because the schema
// sets additionalProperties:false, a `referer` the store carries must be an
// explicit property or storage-layer validation would reject the entry.
const scrapedEntry = {
url: 'https://pageA.example.com/watch',
src: 'https://cdn.example.com/hls/live/master.m3u8',
hlsManifest: 'https://cdn.example.com/hls/live/master.m3u8',
mediaType: 'hls',
poster: 'https://pageA.example.com/thumb.jpg',
tab: 'Referer-protected stream',
referer: 'https://pageA.example.com/watch',
};
const valid = validate(scrapedEntry);
if (!valid) {
throw new Error(`Entry with referer fails schema: ${JSON.stringify(validate.errors)}`);
}
// The referer must survive the storage round-trip intact so the viewer can
// send it to the proxy when the user opens a previously-scanned entry.
const roundTripped = JSON.parse(JSON.stringify(scrapedEntry));
if (roundTripped.referer !== 'https://pageA.example.com/watch') {
throw new Error('referer lost during round-trip');
}
if (!validate(roundTripped)) {
throw new Error('Round-tripped entry with referer fails schema');
}
});
}); });
+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
+204
View File
@@ -0,0 +1,204 @@
import { describe, it } from './runner.mjs';
import { JSDOM } from 'jsdom';
const POPUP_HTML = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>VidPlex</title>
</head>
<body>
<div id="app">
<h1>VidPlex</h1>
<section class="instructions-panel" role="region" aria-labelledby="instructions-title">
<div class="instructions-header">
<div>
<p class="instructions-kicker">Quick start</p>
<h2 id="instructions-title">How to use VidPlex</h2>
</div>
<button
id="instructions-toggle"
type="button"
class="instructions-toggle"
aria-expanded="true"
aria-controls="instructions-body"
>
Hide instructions
</button>
</div>
<div id="instructions-body" class="instructions-body">
<ol class="instructions-list">
<li>
<strong>Scan videos</strong>
<span>Click <strong>Find Videos</strong> to search the current browser window for videos.</span>
</li>
<li>
<strong>Review the list</strong>
<span>Check the popup list; each found video URL is clickable.</span>
</li>
<li>
<strong>Open the viewer</strong>
<span>Click <strong>Open Viewer</strong> to open the 2x2 grid player in a new window.</span>
</li>
<li>
<strong>Reset and rescan</strong>
<span>Click <strong>Clear</strong> to erase results and scan again.</span>
</li>
</ol>
<p class="instructions-note">Warning: Browser pages, extension pages, and restricted frames can hide videos, so results may be incomplete.</p>
<p class="instructions-learn-more">Learn more tooltip: Read the VidPlex README and audit notes.</p>
</div>
</section>
<button id="find-btn">Find Videos (all tabs)</button>
<div id="video-list" class="video-list" style="display:none;"></div>
<div id="status" class="status" style="min-height:16px;"></div>
<div id="btn-row" class="btn-row" style="display:none;">
<button id="clear-btn" class="btn-secondary">Clear</button>
<button id="open-btn">Open Viewer</button>
</div>
</div>
</body>
</html>`;
function installPopupDom() {
const dom = new JSDOM(POPUP_HTML, { url: 'chrome-extension://test/src/viewer/index.html' });
const previous = {
window: globalThis.window,
document: globalThis.document,
navigator: globalThis.navigator,
chrome: globalThis.chrome,
HTMLElement: globalThis.HTMLElement,
Node: globalThis.Node,
};
globalThis.window = dom.window;
globalThis.document = dom.window.document;
Object.defineProperty(globalThis, 'navigator', {
value: dom.window.navigator,
configurable: true,
writable: true,
});
Object.defineProperty(globalThis, 'HTMLElement', {
value: dom.window.HTMLElement,
configurable: true,
writable: true,
});
Object.defineProperty(globalThis, 'Node', {
value: dom.window.Node,
configurable: true,
writable: true,
});
globalThis.chrome = {
storage: {
local: {
get: async () => ({}),
set: async () => undefined,
remove: async () => undefined,
},
},
tabs: { query: async () => [] },
scripting: { executeScript: async () => [] },
runtime: { getURL: (path) => `chrome-extension://test/${path}` },
windows: { create: async () => ({ id: 1 }) },
};
return {
dom,
cleanup() {
dom.window.close();
globalThis.window = previous.window;
globalThis.document = previous.document;
Object.defineProperty(globalThis, 'navigator', {
value: previous.navigator,
configurable: true,
writable: true,
});
globalThis.chrome = previous.chrome;
Object.defineProperty(globalThis, 'HTMLElement', {
value: previous.HTMLElement,
configurable: true,
writable: true,
});
Object.defineProperty(globalThis, 'Node', {
value: previous.Node,
configurable: true,
writable: true,
});
},
};
}
async function mountPopup() {
const { dom, cleanup } = installPopupDom();
const moduleUrl = new URL('../src/viewer/index.js?test=' + Date.now(), import.meta.url);
await import(moduleUrl.href);
return { dom, cleanup };
}
describe('Viewer popup instructions', () => {
it('renders the drafted instructions copy on mount with accessible labeling', async () => {
const { dom, cleanup } = await mountPopup();
try {
const doc = dom.window.document;
const region = doc.querySelector('.instructions-panel');
const title = doc.getElementById('instructions-title');
const toggle = doc.getElementById('instructions-toggle');
const body = doc.getElementById('instructions-body');
if (!region) throw new Error('Instructions region missing');
if (region.getAttribute('role') !== 'region') throw new Error('Instructions region missing role="region"');
if (region.getAttribute('aria-labelledby') !== 'instructions-title') throw new Error('Instructions region missing aria-labelledby');
if (!title || title.textContent !== 'How to use VidPlex') throw new Error('Instructions title not rendered');
if (!toggle) throw new Error('Instructions toggle missing');
if (toggle.getAttribute('aria-controls') !== 'instructions-body') throw new Error('Toggle missing aria-controls');
if (toggle.getAttribute('aria-expanded') !== 'true') throw new Error('Toggle should start expanded');
if (!body || body.hidden) throw new Error('Instructions body should be visible on mount');
const expectedTexts = [
'Scan videos',
'Click Find Videos to search the current browser window for videos.',
'Review the list',
'Check the popup list; each found video URL is clickable.',
'Open the viewer',
'Click Open Viewer to open the 2x2 grid player in a new window.',
'Reset and rescan',
'Click Clear to erase results and scan again.',
'Warning: Browser pages, extension pages, and restricted frames can hide videos, so results may be incomplete.',
'Learn more tooltip: Read the VidPlex README and audit notes.',
];
for (const text of expectedTexts) {
if (!doc.body.textContent.includes(text)) {
throw new Error(`Missing instructions copy: ${text}`);
}
}
} finally {
cleanup();
}
});
it('toggles the instructions panel open and closed', async () => {
const { dom, cleanup } = await mountPopup();
try {
const doc = dom.window.document;
const toggle = doc.getElementById('instructions-toggle');
const body = doc.getElementById('instructions-body');
if (!toggle || !body) throw new Error('Instructions controls missing');
toggle.click();
if (toggle.getAttribute('aria-expanded') !== 'false') throw new Error('Toggle should collapse the instructions');
if (!body.hidden) throw new Error('Instructions body should be hidden after collapse');
if (toggle.textContent !== 'Show instructions') throw new Error('Collapsed label should switch to Show instructions');
toggle.click();
if (toggle.getAttribute('aria-expanded') !== 'true') throw new Error('Toggle should expand the instructions');
if (body.hidden) throw new Error('Instructions body should be visible after expanding');
if (toggle.textContent !== 'Hide instructions') throw new Error('Expanded label should switch back to Hide instructions');
} finally {
cleanup();
}
});
});
+364
View File
@@ -0,0 +1,364 @@
/**
* referer_proxy.test.mjs — coverage for the background worker's per-CDN-host
* referer capture + DNR proxy-host learning, added in PR #5.
*
* The background worker is not itself a module, so the test transpiles it into
* one (wrapping + top-level-statement `async function` transpile) under a mocked
* `chrome` + `fetch`/`btoa` environment, then exercises the captured listeners
* and the message handler directly.
*
* Covers the three gaps robot flagged:
* (a) closing a tab must NOT wipe the host-keyed `goon_referers` global store,
* (b) two hosts proxied concurrently must each keep their OWN referer (no race
* that leaves host B's referer live for host A -> 412),
* (c) the new referer-capture / proxy-host-learning / onRemoved paths run.
*/
import { describe, it } from './runner.mjs';
import { readFileSync, writeFileSync } from 'node:fs';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { dirname, join } from 'node:path';
import { tmpdir } from 'node:os';
const __dirname = dirname(fileURLToPath(import.meta.url));
const backgroundSource = readFileSync(join(__dirname, '../src/background/background.js'), 'utf8');
// Transpile the top-level-statement worker into an ES module. Wrapping in an
// async IIFE makes its top-level `async function` declarations + `await`-using
// nested functions legal inside a module.
function buildModule(code) {
return `(async () => {\n${code}\n})();\n`;
}
// A fresh mock of the chrome APIs the worker touches. `onBeforeRequest` and
// `tabs.onRemoved` capture their listener, and `tabs.get` serves the source
// tab's URL (the captured referer); `declarativeNetRequest.updateSessionRules`
// records the live rules.
function makeChromeEnv() {
const storage = {};
const handlers = { onMessage: [] };
const tabUrls = {};
let sessionRules = {};
const webRequest = {
onBeforeRequest: { addListener: (cb) => { handlers.onBeforeRequest = cb; } },
};
const chrome = {
storage: {
local: {
get: async (keys) => {
const out = {};
const arr = Array.isArray(keys) ? keys : (keys === undefined ? Object.keys(storage) : [keys]);
for (const k of arr) if (k in storage) out[k] = storage[k];
return out;
},
set: async (data) => {
for (const [k, v] of Object.entries(data)) storage[k] = v;
},
remove: async (keys) => {
const arr = Array.isArray(keys) ? keys : [keys];
for (const k of arr) delete storage[k];
},
},
},
webRequest,
tabs: {
get: async (tabId) => tabUrls[tabId] ? { tabId, url: tabUrls[tabId] } : undefined,
onRemoved: { addListener: (cb) => { handlers.onRemoved = cb; } },
},
runtime: { onMessage: { addListener: (cb) => { handlers.onMessage.push(cb); } } },
declarativeNetRequest: {
updateSessionRules: async ({ removeRuleIds = [], addRules = [] } = {}) => {
const rm = new Set(removeRuleIds);
sessionRules = Object.fromEntries(
Object.entries(sessionRules).filter(([id]) => !rm.has(id))
);
for (const r of addRules) sessionRules[r.id] = r;
},
},
};
return { chrome, handlers, storage, tabUrls,
setTabUrl: (tabId, url) => { tabUrls[tabId] = url; },
getSessionRules: () => sessionRules };
}
// Load the worker into a fresh environment. `data:` URLs are cached by Node, so
// the module body (listener registration) would only run once; instead we write
// a uniquely-named temp file per load so each call registers into its own env.
let loadCounter = 0;
async function loadBackground(env) {
global.chrome = env.chrome;
const file = join(tmpdir(), `goon_bg_${process.pid}_${loadCounter++}.mjs`);
writeFileSync(file, buildModule(backgroundSource));
const module = await import(pathToFileURL(file).href);
await module.default;
return module;
}
// Drive a referer capture the way the worker now does it: the worker reads the
// source tab's URL (via chrome.tabs.get) as the provider referer, so we set the
// tab's URL and fire an onBeforeRequest for a video URL on that tab.
async function capture(env, videoUrl, referer) {
env.setTabUrl(11, referer);
await env.handlers.onBeforeRequest({ tabId: 11, url: videoUrl });
}
describe('PR#5: per-host referer capture + proxy race', () => {
// (a) Blocking fix: a host-keyed `goon_referers` entry must outlive the close
// of ANY tab — including a different tab.
it('closing a tab does not wipe goon_referers captured for another host', async () => {
const env = makeChromeEnv();
global.chrome = env.chrome;
await loadBackground(env);
// A live source tab captures the provider referer for its CDN host.
await capture(env, 'https://cdnA.example.com/hls/live/seg-1.ts', 'https://pageA.example.com/watch');
const rA = env.storage['goon_referers']?.['example.com'];
if (!rA) throw new Error('referer was not captured for cdnA');
if (rA.referer !== 'https://pageA.example.com/watch') {
throw new Error('captured referer wrong: ' + JSON.stringify(rA));
}
// A DIFFERENT tab closes. The per-tab cleanup must NOT remove goon_referers.
await env.handlers.onRemoved(22);
const after = env.storage['goon_referers'];
if (!after || !after['example.com']) {
throw new Error('closing tab 22 wiped goon_referers — host A referer lost');
}
if (after['example.com'].referer !== 'https://pageA.example.com/watch') {
throw new Error('closing tab 22 corrupted the host A referer: ' + JSON.stringify(after));
}
});
it('closing the capturing tab itself preserves the referer for later proxy use', async () => {
const env = makeChromeEnv();
global.chrome = env.chrome;
await loadBackground(env);
await capture(env, 'https://cdnB.example.com/video/x/master.m3u8', 'https://pageB.example.com/v');
// The very tab that captured the referer now closes.
await env.handlers.onRemoved(11);
const after = env.storage['goon_referers']?.['example.com'];
if (!after) throw new Error('goon_referers for the capturing host was wiped on its own tab close');
if (after.referer !== 'https://pageB.example.com/v') {
throw new Error('capturing-tab close corrupted referer: ' + JSON.stringify(after));
}
});
// (b) Should-fix + coverage: two hosts proxied concurrently must each keep
// their own referer. With a shared rule id + non-atomic install this regressed;
// per-host rule ids + the per-host lock keep them distinct.
it('concurrent proxy fetches for two distinct registrable domains keep distinct referer rules', async () => {
const env = makeChromeEnv();
let fetchCalls = 0;
global.chrome = env.chrome;
global.fetch = async (url) => {
fetchCalls++;
return {
ok: true,
status: 200,
headers: { get: () => 'application/vnd.apple.mpegurl' },
arrayBuffer: async () => new Uint8Array([0]).buffer,
};
};
await loadBackground(env);
// Two GOON_PROXY_FETCH messages for different CDN hosts, fired together.
const handler = env.handlers.onMessage[env.handlers.onMessage.length - 1];
let respA, respB;
handler({ type: 'GOON_PROXY_FETCH', url: 'https://cdnA.example.com/hls/live/seg-1.ts', referer: 'https://pageA.example.com/watch' }, {}, (r) => { respA = r; });
handler({ type: 'GOON_PROXY_FETCH', url: 'https://cdnB.other.com/hls/live/seg-1.ts', referer: 'https://pageB.other.com/watch' }, {}, (r) => { respB = r; });
await new Promise(r => setTimeout(r, 30)); // let async sends + installs flush
if (!respA || !respA.ok) throw new Error('proxy A failed: ' + JSON.stringify(respA));
if (!respB || !respB.ok) throw new Error('proxy B failed: ' + JSON.stringify(respB));
if (fetchCalls < 2) throw new Error('expected both proxy fetches to run, got ' + fetchCalls);
// Each host must have its OWN installed DNR rule with its OWN referer —
// host A must NOT be served host B's referer (the 412 regression).
const rules = env.getSessionRules();
const ruleA = Object.values(rules).find(r => r.condition?.requestDomains?.includes('example.com'));
const ruleB = Object.values(rules).find(r => r.condition?.requestDomains?.includes('other.com'));
if (!ruleA) throw new Error('no DNR rule installed for cdna; rules=' + JSON.stringify(rules));
if (!ruleB) throw new Error('no DNR rule installed for cdnb; rules=' + JSON.stringify(rules));
if (ruleA.id === ruleB.id) {
throw new Error('cdnA and cdnB shared a DNR rule id -> concurrent race possible');
}
const refA = ruleA.action.requestHeaders?.find(h => h.header.toLowerCase() === 'referer')?.value;
const refB = ruleB.action.requestHeaders?.find(h => h.header.toLowerCase() === 'referer')?.value;
if (refA !== 'https://pageA.example.com/watch') {
throw new Error('cdnA served wrong referer: ' + refA);
}
if (refB !== 'https://pageB.other.com/watch') {
throw new Error('cdnB served wrong referer: ' + refB);
}
if (refA === refB) throw new Error('both hosts got the same referer — cross-host bleed');
});
it('repeated proxy for the same host is idempotent (one rule, one install)', async () => {
const env = makeChromeEnv();
let installs = 0;
env.chrome.declarativeNetRequest.updateSessionRules = async ({ addRules = [] } = {}) => {
for (const r of addRules) installs++;
};
global.chrome = env.chrome;
global.fetch = async () => ({
ok: true, status: 200,
headers: { get: () => 'application/vnd.apple.mpegurl' },
arrayBuffer: async () => new Uint8Array([0]).buffer,
});
await loadBackground(env);
const handler = env.handlers.onMessage[env.handlers.onMessage.length - 1];
const run = async (n) => {
handler(
{ type: 'GOON_PROXY_FETCH', url: 'https://cdnA.example.com/hls/live/seg-1.ts', referer: 'https://pageA.example.com/watch' },
{},
() => {}
);
await new Promise(r => setTimeout(r, 5));
};
await run(1);
const installsAfterFirst = installs;
await run(2);
await run(3);
// Same host + same referer should not re-add the rule on repeat fetches.
if (installs !== installsAfterFirst) {
throw new Error(`expected idempotent install, but re-added rule ${installs - installsAfterFirst} extra time(s)`);
}
});
// (c) Domain generalization — the core 412 fix. A provider referer captured for
// ONE edge subdomain must cover a SIBLING subdomain: the DNR rule is scoped to
// the registrable domain and the store is domain-keyed, so a proxy for ev-h
// needs no per-subdomain capture.
it('a proxy for a sibling subdomain reuses the captured referer via the registrable domain', async () => {
const env = makeChromeEnv();
global.chrome = env.chrome;
global.fetch = async () => ({
ok: true,
status: 200,
headers: { get: () => 'application/vnd.apple.mpegurl' },
arrayBuffer: async () => new Uint8Array([0]).buffer,
});
await loadBackground(env);
// Capture the provider referer for hv-h.example.net (one edge subdomain).
await capture(env, 'https://hv-h.example.net/hls/live/seg-1.ts', 'https://provider.example.com/watch');
// Proxy a SIBLING subdomain ev-h.example.net with NO referer in the message:
// the SW must resolve it from the domain-keyed store.
const handler = env.handlers.onMessage[env.handlers.onMessage.length - 1];
let resp;
handler({ type: 'GOON_PROXY_FETCH', url: 'https://ev-h.example.net/hls/live/seg-1.ts' }, {}, (r) => { resp = r; });
await new Promise(r => setTimeout(r, 20));
if (!resp || !resp.ok) throw new Error('sibling proxy failed: ' + JSON.stringify(resp));
const rules = env.getSessionRules();
const rule = Object.values(rules).find(r => r.condition?.requestDomains?.includes('example.net'));
if (!rule) throw new Error('no domain-scoped DNR rule for example.net; rules=' + JSON.stringify(rules));
const ref = rule.action.requestHeaders?.find(h => h.header.toLowerCase() === 'referer')?.value;
if (ref !== 'https://provider.example.com/watch') {
throw new Error('sibling edge served wrong referer: ' + ref);
}
// The rule is scoped to the registrable domain, NOT the exact subdomain.
if (rule.condition.requestDomains?.includes('ev-h.example.net')) {
throw new Error('rule scoped to exact subdomain instead of registrable domain');
}
});
// (d) With no captured referer and none in the message, install NO rule: the
// self-origin fallback (guaranteed-wrong for hotlink protection) is dropped.
it('no captured referer and no message referer installs no DNR rule', async () => {
const env = makeChromeEnv();
let fetchCalls = 0;
global.chrome = env.chrome;
global.fetch = async () => {
fetchCalls++;
return {
ok: true,
status: 200,
headers: { get: () => 'application/vnd.apple.mpegurl' },
arrayBuffer: async () => new Uint8Array([0]).buffer,
};
};
await loadBackground(env);
const handler = env.handlers.onMessage[env.handlers.onMessage.length - 1];
let resp;
handler({ type: 'GOON_PROXY_FETCH', url: 'https://cdnA.example.com/hls/live/seg-1.ts' }, {}, (r) => { resp = r; });
await new Promise(r => setTimeout(r, 20));
if (!resp || !resp.ok) throw new Error('proxy fetch failed: ' + JSON.stringify(resp));
if (fetchCalls < 1) throw new Error('proxy fetch did not run');
const rules = env.getSessionRules();
if (Object.keys(rules).length !== 0) {
throw new Error('installed a rule with no referer (self-origin fallback not dropped): ' + JSON.stringify(rules));
}
});
// (e) GOON_GET_REFERERS (used by scan-time capture) resolves each manifest host
// via the domain-keyed store: a sibling subdomain gets the provider referer,
// and an uncaptured host is omitted.
it('GOON_GET_REFERERS resolves manifest hosts through the domain-keyed store', async () => {
const env = makeChromeEnv();
global.chrome = env.chrome;
await loadBackground(env);
// Capture for hv-h.example.net only.
await capture(env, 'https://hv-h.example.net/hls/live/seg-1.ts', 'https://provider.example.com/watch');
const handler = env.handlers.onMessage[env.handlers.onMessage.length - 1];
let resp;
handler(
{ type: 'GOON_GET_REFERERS', hosts: ['ev-h.example.net', 'hv-h.example.net', 'cdnA.example.com'] },
{},
(r) => { resp = r; });
await new Promise(r => setTimeout(r, 15));
if (!resp || !resp.ok) throw new Error('GOON_GET_REFERERS failed: ' + JSON.stringify(resp));
const referers = resp.referers || {};
if (referers['ev-h.example.net'] !== 'https://provider.example.com/watch') {
throw new Error('sibling subdomain did not resolve the captured referer: ' + JSON.stringify(referers));
}
if (referers['hv-h.example.net'] !== 'https://provider.example.com/watch') {
throw new Error('captured subdomain did not resolve its own referer: ' + JSON.stringify(referers));
}
if ('cdnA.example.com' in referers) {
throw new Error('uncaptured host should be omitted from referers: ' + JSON.stringify(referers));
}
});
// Regression for the live 412 bug: the worker captures the provider referer
// from the source tab's URL via chrome.tabs.get inside onBeforeRequest. The
// old path read request headers via onBeforeSendHeaders + extraInfoSpec, which
// is rejected by some Chromium builds and fails the whole service worker, so it
// was removed; this asserts the new tab-URL capture is wired up and actually
// stores a referer.
it('the onBeforeRequest capture reads the referer from the source tab URL', async () => {
const env = makeChromeEnv();
global.chrome = env.chrome;
await loadBackground(env);
const cap = env.handlers.onBeforeRequest;
if (!cap) throw new Error('onBeforeRequest listener was not registered');
// A video request for cdnA from a tab whose URL IS the provider referer.
env.setTabUrl(11, 'https://pageA.example.com/watch');
await cap({ tabId: 11, url: 'https://cdnA.example.com/hls/live/seg-1.ts' });
const rA = env.storage['goon_referers']?.['example.com'];
if (!rA) throw new Error('referer was not captured from the source tab URL');
if (rA.referer !== 'https://pageA.example.com/watch') {
throw new Error('captured referer wrong: ' + JSON.stringify(rA));
}
});
});
+5
View File
@@ -3,8 +3,13 @@
*/ */
import './schema.test.mjs'; import './schema.test.mjs';
import './viewer_units.test.mjs'; import './viewer_units.test.mjs';
import './instructions_ui.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 './simple_video.test.mjs';
import './referer_proxy.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);
+131
View File
@@ -0,0 +1,131 @@
// Regression tests: simple <video> / direct-media detection.
//
// The feature branch broke detection of pages that expose a plain <video>
// (e.g. a site that serves a direct MP4 with a TRAILING SLASH and a
// ?rnd= query: .../14644093/14644093.mp4/?rnd=...). Two site-agnostic fixes
// are guarded here:
// 1) The URL predicate (isValidManifestUrl) must let concrete media signals
// (.mp4/.m3u8/.webm/... and /hls/) WIN over the placeholder/`%`/trailing-
// slash rejections, so a real direct-media URL is not mistaken for a
// route template.
// 2) scrapeTab must fall back to an ISOLATED-world scrape when the MAIN-world
// scrape yields nothing — the runtime cause of "detects nothing" on such pages
// is a MAIN-world + allFrames rejection on its ad-iframe-heavy page.
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 indexSource = fs.readFileSync(
path.join(__dirname, '../src/viewer/index.js'),
'utf-8'
);
// Extract a `function <name>(...) {…}` by balanced-brace scan. Robust to the
// file's quirky/mixed indentation (a column-0 `}` heuristic would mis-truncate
// nested or oddly-indented bodies).
function extractFn(src, name) {
const re = new RegExp('function ' + name + '\\s*\\(');
const m = re.exec(src);
if (!m) throw new Error('not found: ' + name);
const start = m.index;
let i = src.indexOf('{', start), depth = 0;
for (; i < src.length; i++) {
const c = src[i];
if (c === '{') depth++;
else if (c === '}') { depth--; if (depth === 0) return src.slice(start, i + 1); }
}
throw new Error('unbalanced: ' + name);
}
// The direct-media URL, shaped exactly like the rejected case (a trailing slash +
// ?rnd= query). This is the concrete case the predicate used to reject.
const DIRECT_MP4 =
'https://example.com/get_file/5/6cae22f8b3ba9d74f10cc2f8d060af738c8d30ddf5/14644000/14644093/14644093.mp4/?rnd=1787811205406';
describe('HLS QA: simple <video> direct-media predicate', () => {
// Extract and evaluate the REAL isValidManifestUrl from index.js in a browser
// window so its `new URL(...)` / string behavior matches production.
const isValidSrc = extractFn(indexSource, 'isValidManifestUrl');
function assertValid(url, expectValid, label) {
const dom = new JSDOM(
'<!DOCTYPE html><html><body></body></html>',
{ url: 'https://example.com/videos/x/', runScripts: 'dangerously' }
);
const got = dom.window.eval('(' + isValidSrc + ')(' + JSON.stringify(url) + ')');
if (got !== expectValid) {
throw new Error(
`${label || 'isValidManifestUrl'}(${url}) = ${got}, expected ${expectValid}`
);
}
}
it('accepts a real direct-MP4 URL WITH a trailing slash + ?rnd=', () => {
// Before the fix, `path.endsWith('/')` rejected this real media source.
assertValid(DIRECT_MP4, true, 'trailing-slash MP4');
});
it('accepts a direct .mp4 with no trailing slash', () => {
assertValid('https://cdn.example.com/vids/abc123.mp4', true, 'plain MP4');
});
it('accepts real .m3u8 / .webm / .mpd / .m4s / /hls/ media signals', () => {
assertValid('https://cdn.example.com/live/master.m3u8', true, 'master.m3u8');
assertValid('https://cdn.example.com/seg/webm/v.webm', true, 'webm');
assertValid('https://cdn.example.com/seg/dash/master.mpd', true, 'mpd');
assertValid('https://cdn.example.com/seg/1-0.m4s', true, 'm4s');
assertValid('https://cdn.example.com/hls/abc/playlist.m3u8', true, '/hls/');
});
it('does not over-reject a real .m3u8 carrying a valid %2B/%2F token', () => {
// %2B/%2F are valid percent-encoded hex pairs — a genuine auth token, not a
// route-template placeholder, so it must survive.
assertValid('https://cdn.example.com/live/master.m3u8?h=a%2Bb%2Fc&e=1234', true, '%2B token');
});
it('still rejects route-template / nav URLs (no over-acceptance)', () => {
// The fix must not trade one regression for the other: the route templates
// a provider's pageContext.langUrls leaks must still be rejected.
assertValid('https://example.com/playlists/', false, '/playlists/');
assertValid('https://example.com/playlists/liked/', false, '/playlists/liked/');
assertValid('https://example.com/playlist/%1%/video/%2%/', false, '/playlist/%1%/');
assertValid('https://example.com/members/%ID%/playlists/', false, '/members/%ID%/playlists/');
assertValid('https://example.com/videos/%DIR%/', false, '/videos/%DIR%/');
});
});
describe('HLS QA: scrapeTab isolated-world fallback', () => {
// Extract the whole scrapeTab function (balanced braces, indentation-agnostic).
const scrapeSrc = extractFn(indexSource, 'scrapeTab');
it('runs an isolated-world scrape attempt (no `world` key) as a fallback', () => {
const calls = scrapeSrc.match(/executeScript\(\s*\{[\s\S]*?\}\s*\)/g) || [];
if (calls.length === 0) {
throw new Error('scrapeTab has no executeScript call');
}
// At least one executeScript must omit `world` so it runs in the always-
// permitted isolated world (recovers simple videos a MAIN-world injection
// rejects on sandboxed/opaque-origin ad iframes).
const hasIsolated = calls.some((c) => !/\bworld\s*:/.test(c));
if (!hasIsolated) {
throw new Error(
'scrapeTab has no isolated-world (no `world:`) executeScript fallback — ' +
'a MAIN-world+allFrames rejection on an ad-iframe-heavy page leaves 0 videos'
);
}
});
it('primary scrape still uses world: \'MAIN\' (reads page globals)', () => {
const calls = scrapeSrc.match(/executeScript\(\s*\{[\s\S]*?\}\s*\)/g) || [];
const hasMain = calls.some((c) => /world\s*:\s*['"]MAIN['"]/.test(c));
if (!hasMain) {
throw new Error(
'scrapeTab primary attempt must use world: "MAIN" to read page globals ' +
'(interceptor data, Hls instances, custom <video> props)'
);
}
});
});
+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}`));
+25
View File
@@ -0,0 +1,25 @@
# Instructions UI audit
## Scope
This audit covers the VidPlex popup instructions banner added in `src/viewer/index.html`, `src/viewer/index.css`, and `src/viewer/index.js`.
## What the UI now explains
- How to scan the current page for videos
- How to review clickable results
- How to open the 2x2 viewer
- How to clear and rescan
- A caveat that browser pages, extension pages, and restricted frames may hide videos
## Accessibility checks
- The instructions section uses `role="region"` and an accessible label
- The collapse toggle is wired with `aria-expanded` and `aria-controls`
- The body is hidden with the `hidden` attribute when collapsed
## UX notes
- The panel is visible on mount so first-time users see the steps immediately
- The toggle label changes between “Hide instructions” and “Show instructions”
- The copy intentionally stays short so the popup remains readable in a narrow extension window
## Validation
- Covered by `__tests__/instructions_ui.test.mjs`
- Verified alongside the existing build/test flow in the parent implementation task
+17
View File
@@ -0,0 +1,17 @@
# Instructions UI copy
## Banner copy
Quick start
How to use VidPlex
## Steps
1. Scan videos — Click Find Videos to search the current browser window for videos.
2. Review the list — Check the popup list; each found video URL is clickable.
3. Open the viewer — Click Open Viewer to open the 2x2 grid player in a new window.
4. Reset and rescan — Click Clear to erase results and scan again.
## Warning copy
Warning: Browser pages, extension pages, and restricted frames can hide videos, so results may be incomplete.
## Learn-more copy
Learn more tooltip: Read the VidPlex README and audit notes.
+50
View File
@@ -0,0 +1,50 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="720" viewBox="0 0 1280 720" role="img" aria-label="VidPlex popup instructions preview">
<defs>
<style>
.bg { fill: #ffffff; }
.panel { fill: #f8fafc; stroke: #e2e8f0; stroke-width: 1; }
.text { fill: #1a1a2e; font-family: -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif; }
.muted { fill: #64748b; }
.accent { fill: #4361ee; }
.warn { fill: #7c3aed; }
.small { font-size: 12px; }
.tiny { font-size: 11px; letter-spacing: .08em; font-weight: 700; }
.title { font-size: 18px; font-weight: 600; }
.h2 { font-size: 14px; font-weight: 600; }
.step-title { font-size: 12px; font-weight: 600; }
.body { font-size: 12px; fill: #475569; }
.button-text { font-size: 12px; fill: #4361ee; }
.primary-text { fill: #ffffff; font-size: 14px; }
</style>
</defs>
<rect class="bg" x="0" y="0" width="1280" height="720"/>
<text class="text title" x="18" y="30">VidPlex</text>
<rect class="panel" x="17" y="50" width="1264" height="276" rx="10"/>
<text class="text muted tiny" x="30" y="72">QUICK START</text>
<text class="text h2" x="30" y="90">How to use VidPlex</text>
<rect x="1150" y="62" width="104" height="28" rx="8" fill="#ffffff" stroke="#e2e8f0"/>
<text class="button-text" x="1165" y="80">Hide instructions</text>
<g class="text">
<text class="step-title" x="48" y="126">Scan videos</text>
<text class="body" x="48" y="144">Click Find Videos to search the current browser window for videos.</text>
<text class="step-title" x="48" y="176">Review the list</text>
<text class="body" x="48" y="194">Check the popup list; each found video URL is clickable.</text>
<text class="step-title" x="48" y="226">Open the viewer</text>
<text class="body" x="48" y="244">Click Open Viewer to open the 2x2 grid player in a new window.</text>
<text class="step-title" x="48" y="276">Reset and rescan</text>
<text class="body" x="48" y="294">Click Clear to erase results and scan again.</text>
<text class="warn small" x="30" y="319">Warning: Browser pages, extension pages, and restricted frames can hide videos, so results may be incomplete.</text>
<text class="body" x="30" y="348">Learn more tooltip: Read the VidPlex README and audit notes.</text>
</g>
<rect x="17" y="339" width="1264" height="31" rx="8" fill="#4361ee"/>
<text class="primary-text" x="561" y="359">Find Videos (all tabs)</text>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

+26
View File
@@ -0,0 +1,26 @@
# Instructions UI design
## Placement
The instructions banner sits above the primary action button so new users can read the steps before interacting with the popup.
## Structure
- Short kicker: “Quick start”
- Heading: “How to use VidPlex”
- Expand/collapse toggle for users who want a smaller popup
- Ordered list of the core tasks
- One warning line for edge cases
- One short learn-more line that points to the README and audit notes
## Copy strategy
The microcopy avoids jargon and keeps each step tied to a visible control:
- Find Videos
- Open Viewer
- Clear
## Accessibility strategy
- A labelled region exposes the whole panel to assistive tech
- The toggle reflects the current visibility state
- The collapsed state removes the body from the layout entirely
## Notes
This design intentionally favors clarity over density because extension popups are small and users typically need the instructions only once.
+42
View File
@@ -0,0 +1,42 @@
# Add user instructions to goon plugin UI
Branch: `feature/goon-instructions`
## Summary
Adds an always-visible, collapsible quick-start panel to the VidPlex popup so users can understand the scan / review / open / clear flow without reading the README first.
## Links
- Audit notes: [docs/instructions-audit.md](docs/instructions-audit.md)
- Design notes: [docs/instructions-design.md](docs/instructions-design.md)
- Copy deck: [docs/instructions-copy.md](docs/instructions-copy.md)
- Preview render: [docs/instructions-demo.svg](docs/instructions-demo.svg)
## Testing
- `npm run build`
- `npm test`
Manual verification:
1. Open the extension popup.
2. Confirm the instructions banner renders expanded on mount.
3. Click the toggle and confirm the panel collapses / expands and the label changes.
4. Confirm the main Scan Videos button still works normally.
## Changed files
- `src/viewer/index.html`
- `src/viewer/index.css`
- `src/viewer/index.js`
- `__tests__/instructions_ui.test.mjs`
- `__tests__/run.mjs`
- `docs/instructions-audit.md`
- `docs/instructions-design.md`
- `docs/instructions-copy.md`
- `docs/instructions-demo.svg`
## Acceptance criteria
- A PR exists against `master`.
- The PR description includes summary, links to the design/copy docs, a visual preview, testing steps, changed files, and acceptance criteria.
- Reviewers are requested before handoff.
## Open questions / risks
- The repo still has some pre-existing lint warnings outside this change.
- The preview asset is an SVG render of the UI rather than a raster screenshot.
+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',
},
},
];
+16 -8
View File
@@ -1,13 +1,15 @@
{ {
"manifest_version": 3, "manifest_version": 3,
"name": "Goon Plugin", "name": "VidPlex",
"version": "0.1.0", "version": "0.1.1",
"description": "A Chrome extension", "description": "Find videos on any page and view them in a grid.",
"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",
+2 -11
View File
@@ -1,15 +1,12 @@
{ {
"name": "goon-plugin", "name": "vidplex",
"version": "0.1.0", "version": "0.1.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "goon-plugin", "name": "vidplex",
"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",
+6 -7
View File
@@ -1,21 +1,18 @@
{ {
"name": "goon-plugin", "name": "vidplex",
"version": "0.1.0", "version": "0.1.1",
"private": true, "private": true,
"description": "Chrome extension scaffold", "description": "Chrome extension: find videos on a page and view them in a grid.",
"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",
@@ -26,5 +23,7 @@
"typescript": "^5.7.0", "typescript": "^5.7.0",
"vite": "^6.0.0", "vite": "^6.0.0",
"vitest": "^3.0.0" "vitest": "^3.0.0"
},
"dependencies": {
} }
} }
+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)`);
+604 -16
View File
@@ -4,29 +4,617 @@
* 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 video requests across any CDN ===
// Some players generate HLS manifests in-memory and make zero .m3u8 network
// requests, only fetching video segments from a CDN. By logging every video-like
// request (any host) 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. No site-specific host list.
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 — video 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('[vidplex-bg] Failed to persist video request:', e && e.message ? e.message : e);
} }
}); }
// --- Token / URL helpers ------------------------------------------
//
// A media CDN's 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).
// Group key for a video's URL: the path directory holding its variant/segment
// (the last non-file path segment of a CDN URL). Used to match the freshest
// live URL by video, host-independent. '' when the URL is malformed.
function extractGroupKey(url) {
if (!url) return '';
try {
const path = new URL(url).pathname.split('/');
// Drop the final (file/segment or manifest) segment; the remaining
// directory joins into a key that is stable across a video's variants.
path.pop();
return path.join('/');
} 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 the parent manifest URL for a segment URL, keeping the FULL path to
// the variant directory and the RAW query (token encoding intact). Swaps the
// segment filename for a manifest filename in the same directory:
// {host}/path/to/variant/seg-1.ts -> {host}/path/to/variant/master.m3u8
// The variant directory is MANDATORY — a master playlist lives inside it;
// dropping it yields a 404/410. Returns '' for non-segment URLs.
function buildManifestForVariant(url) {
if (!url || !/\/seg-\d+/i.test(url)) return '';
try {
const [pathPart, queryPart] = url.split('?');
const idx = pathPart.lastIndexOf('/');
if (idx === -1) return '';
const dir = pathPart.slice(0, idx); // variant directory
const rawQuery = queryPart ? '?' + queryPart : '';
return `${dir}/master.m3u8${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 video/segment URLs (e.g. .../seg-N.ts).
const manifestUrl = buildManifestForVariant(url);
if (manifestUrl && !seen.has(manifestUrl)) {
seen.add(manifestUrl);
manifests.push(manifestUrl);
}
}
return manifests;
}
// Collect captured video 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 collectAllVideoUrls() {
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 group key, return the freshest live captured 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: match patterns are brittle (a bad
// pattern throws "Invalid url pattern" and silently drops every rescan).
// We scan persisted capture data instead, which is always available and
// sidesteps the match-pattern API entirely.
async function freshLiveForGroup(groupKey, preferManifest) {
let all;
try {
all = await collectAllVideoUrls();
} catch (e) {
console.warn('[vidplex-bg] collectAllVideoUrls 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 (extractGroupKey(url) !== groupKey) continue;
const exp = tokenExpirySeconds(extractRawQuery(url));
if (url.includes('.m3u8')) {
if (exp > bestManifestExp) { bestManifestExp = exp; bestManifest = url; }
} else if (/\/seg-\d+/i.test(url)) {
if (exp > bestVariantExp) { bestVariantExp = exp; bestVariant = url; }
} else {
if (exp > bestTokenExp) { bestTokenExp = exp; bestTokenSource = url; }
}
}
// Prefer a captured manifest; else the best segment (reconstructable to a
// manifest); else a token source so the proxy can 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 group key so we don't query storage on every proxy
// request. Keyed by group key; values resolve once and are reused until a rescan
// (GOON_RESOLVE_MANIFEST) or a 410 forces an invalidation.
const tokenCache = new Map();
// Given a CDN 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 group. Returns the URL with a query appended, or the original URL
// unchanged when no token is available yet.
async function injectFreshToken(url) {
if (!url) return url;
// Already carries a token — leave it alone.
if (url.includes('?')) return url;
try {
const groupKey = extractGroupKey(url);
if (!groupKey) return url;
if (tokenCache.has(groupKey)) {
const token = tokenCache.get(groupKey);
return token ? url + '?' + token : url;
}
const fresh = await freshLiveForGroup(groupKey, false);
const token = fresh ? extractRawQuery(fresh) : '';
tokenCache.set(groupKey, token);
console.log('[vidplex-bg] injectFreshToken for', groupKey, (token ? token.substring(0, 40) + '…' : '(none captured)'));
return token ? url + '?' + token : url;
} catch {
return 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(); }
// CDN registrable domains whose referer we have already captured this SW
// lifetime, so we do not re-query the tab for every segment of a stream.
// Lost if the SW is killed; persistReferer's storage dedupe covers a fresh SW.
const refererCapturedDomains = new Set();
chrome.webRequest.onBeforeRequest.addListener(
async (details) => {
if (details.tabId < 0) return;
// Log video-like requests from any host (CDN-agnostic).
if (isVideoRequest(details.url)) {
console.log('[vidplex-bg] webRequest: video request in tab', details.tabId, ':', details.url.substring(0, 180));
await persistVideoRequest(details.tabId, details.url);
// Capture the provider referer for this CDN host from the source tab's
// URL: the browser sends the referring page's URL as the Referer for a
// cross-origin video request, and that IS the tab's URL. extraInfoSpec
// (reading the actual request headers) is rejected by some Chromium
// builds and fails the whole service worker, so we derive the referer
// from the tab instead. The `tabs` + host_permissions already allow it.
const host = hostOf(details.url);
const domain = host ? registrableDomain(host) : '';
if (domain && !refererCapturedDomains.has(domain)) {
try {
const tab = await chrome.tabs.get(details.tabId);
const ref = tab && tab.url;
if (ref) {
await persistReferer(host, ref);
refererCapturedDomains.add(domain);
console.log('[vidplex-bg] captured referer for', domain, '->', ref);
}
} catch {
// Tab may have closed mid-stream; a later request from a live tab
// re-captures. Best-effort.
}
}
}
// Also capture HLS manifest URLs (original logic)
if (!isHlsManifest(details.url)) return;
console.log('[vidplex-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('[vidplex-bg] Failed to persist webRequest manifest:', e && e.message ? e.message : e);
}
},
{ urls: ['<all_urls>'] }
);
// Clean up stale PER-TAB entries when tabs are closed. NOTE: 'goon_referers' is
// a domain-keyed GLOBAL store — it must outlive its capture tab so a proxy fetch
// for that host still finds its referer after the source tab is closed. It is
// therefore NOT removed here (only the per-tab vr_requests_/wr_manifests_ keys
// are). Stale referers are pruned opportunistically in persistReferer.
chrome.tabs.onRemoved.addListener(async (tabId) => {
const keys = ['wr_manifests_' + tabId, 'vr_requests_' + tabId];
try {
await chrome.storage.local.remove(keys);
} catch { /* ignore */ }
});
// === Capture the provider-origin Referer per CDN host (CDN-agnostic) ===
// Some media CDNs reject requests whose `Referer` is not the provider page's
// origin (HTTP 412). A fetch() CANNOT set `Referer` — it is a "forbidden
// header" in the Fetch API, so a service-worker fetch() would send
// `Referer: chrome-extension://<id>`, which the CDN rejects. The
// declarativeNetRequest API rewrites request headers at the network layer, below
// the Fetch API's restriction, and its rules apply to fetch() calls made inside
// the SW.
//
// We cannot hardcode a target referer: the proxy is invoked from the viewer
// (a different origin than the source page), and a DNR rule cannot read a live
// page's origin. So we derive the provider referer from the source tab's own URL.
// For a cross-origin video request the browser sends the referring page's URL as
// the Referer, and that page IS the tab currently loading the video — so the tab
// URL is the referer we need. We read it via chrome.webRequest.onBeforeRequest
// (no header access — it cannot read the request headers) plus chrome.tabs.get,
// which returns the source tab's URL, and persist that per CDN host. The
// GOON_PROXY_FETCH handler then installs a DNR rule that re-inserts that
// captured referer for just that host, so the proxy's fetch() is accepted.
// This requires no knowledge of any particular site. Deriving the referer from
// the tab URL (rather than reading the real request header via
// onBeforeSendHeaders + extraHeaders) is intentional: extraInfoSpec header
// parsing is rejected by some Chromium builds and fails the whole service worker,
// and the tabs + host permissions already permit chrome.tabs.get.
//
// Each CDN host gets its OWN declarativeNetRequest rule id so that concurrent
// proxy fetches for different hosts can't clobber each other's rule (a single
// shared id + a remove-then-add lets two hosts interleave and leave the wrong
// host's referer live). The id is derived from the host via a monotonic counter
// keyed by host, so it is stable for a given host across installs.
let nextRefererRuleId = 9001;
const refererRuleIds = new Map();
function refererRuleIdFor(host) {
const existing = refererRuleIds.get(host);
if (existing !== undefined) return existing;
const id = nextRefererRuleId++;
refererRuleIds.set(host, id);
return id;
}
// host of a URL (CDN host, lowercased); '' when the URL is malformed.
function hostOf(url) {
if (!url) return '';
try { return new URL(url).host.toLowerCase(); } catch { return ''; }
}
// Registrable domain of a CDN host — the label a single provider's referer
// applies to. A referer-protecting CDN serves manifests and segments from many
// edge subdomains that share ONE
// provider referer, so we key the referer store, the DNR rule, and proxy-host
// matching on this domain rather than the exact subhost.
//
// Heuristic: the last two DNS labels. Correct for the overwhelmingly common
// single-label public suffixes (.com, .net, .org, .io, ...). Known caveat:
// multi-label public suffixes such as .co.uk / .com.au collapse to the public
// suffix itself (foo.co.uk -> co.uk); that is an exotic edge case and no worse
// than the exact-host behavior it replaces. A single-level host (no dot, or a
// bare TLD) is returned unchanged.
function registrableDomain(host) {
if (!host) return '';
const parts = host.split('.');
if (parts.length < 2) return host;
return parts.slice(-2).join('.');
}
// How long a captured referer is trusted before it is pruned on the next
// persist. Kept long enough for the scan-now / watch-later flow (a user scans a
// page, then opens the viewer much later) without tying cleanup to tab close (a
// still-open source tab's referer must survive other tabs closing).
const REFERER_TTL_MS = 7 * 24 * 60 * 60 * 1000;
// Persist the captured referer per CDN registrable domain so the proxy can
// re-use it even after the capture tab closes. Keyed by the host's registrable
// domain so a single provider referer covers every edge subdomain (a CDN serves
// manifests/segments from many subhosts that share one referer), and so a proxy
// for any sibling subhost can look it up without knowing the source tab. Each
// entry is stamped with a capture time; entries older than the TTL are pruned
// opportunistically here (never on tab close).
async function persistReferer(host, referer) {
const domain = registrableDomain(host);
if (!domain || !referer) return;
try {
const stored = await chrome.storage.local.get('goon_referers');
const map = stored['goon_referers'] || {};
const now = Date.now();
// Opportunistic per-domain prune: drop entries older than the TTL.
for (const [h, entry] of Object.entries(map)) {
if (h !== domain && (!entry || !entry.at || now - entry.at > REFERER_TTL_MS)) {
delete map[h];
}
}
const current = map[domain];
if (current && current.referer === referer) return;
map[domain] = { referer, at: now };
await chrome.storage.local.set({ goon_referers: map });
} catch (e) {
console.warn('[vidplex-bg] persistReferer failed:', e && e.message ? e.message : e);
}
}
// Look up the referer captured for a host's registrable domain. '' when none
// has been captured (or the stored entry is malformed).
async function getReferer(host) {
if (!host) return '';
try {
const stored = await chrome.storage.local.get('goon_referers');
const entry = (stored['goon_referers'] || {})[registrableDomain(host)];
return entry && entry.referer ? entry.referer : '';
} catch {
return '';
}
}
// Install (idempotently) a DNR rule that sets the captured referer for a given
// CDN host. The value comes from capture (or a fallback origin), never a
// hardcoded site. Scoped to the requested host so only proxy fetches for that
// host get the rewritten header.
//
// Each host gets its own rule id (refererRuleIdFor) so that concurrent proxy
// fetches for DIFFERENT hosts never interleave: host A's rule and host B's rule
// are distinct DNR ids and one install can't remove the other. Within a single
// host, a per-host async lock (refererRuleLocks) serializes the idempotency
// check + updateSessionRules so the install-and-record pair is atomic — no two
// installs for the same host can interleave their check/update.
const refererRuleInstalled = new Map();
const refererRuleLocks = new Map();
async function ensureRefererRule(host, referer) {
const domain = registrableDomain(host);
if (!domain || !referer) return;
const api = chrome.declarativeNetRequest;
if (!api || typeof api.updateSessionRules !== 'function') {
console.warn('[vidplex-bg] declarativeNetRequest unavailable; skipping referer rule');
return;
}
// Serialize per host so the idempotency check + updateSessionRules is atomic.
const run = (async () => {
if (refererRuleInstalled.has(domain) && refererRuleInstalled.get(domain) === referer) {
return;
}
const ruleId = refererRuleIdFor(domain);
try {
// Remove only THIS host's own rule id, then add it back scoped to the host.
await api.updateSessionRules({
removeRuleIds: [ruleId],
addRules: [{
id: ruleId,
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: referer },
],
},
condition: {
// Host + subdomains ONLY, so only proxy fetches for this host get the
// rewritten referer; the source page's own requests keep theirs.
requestDomains: [domain],
},
}],
});
refererRuleInstalled.set(domain, referer);
console.log('[vidplex-bg] DNR referer rule installed for', domain, '(' + ruleId + ')');
} catch (e) {
console.warn('[vidplex-bg] ensureRefererRule failed:', e && e.message ? e.message : e);
}
})().finally(() => { refererRuleLocks.set(domain, null); });
refererRuleLocks.set(domain, run);
return run;
}
// === 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_VIDEO_REQUESTS': {
// Strategy 8: Return all video 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 manifest for the video encoded 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 groupKey = extractGroupKey(url);
if (!groupKey) { sendResponse({ ok: false, error: 'no group key' }); return; }
try {
const fresh = await freshLiveForGroup(groupKey, true);
sendResponse({ ok: true, fresh, groupKey, changed: !!(fresh && fresh !== url) });
} catch (e) {
console.warn('[vidplex-bg] resolveFreshManifest failed:', e && e.message ? e.message : e);
sendResponse({ ok: false, error: e && e.message ? e.message : String(e), groupKey });
}
}
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.
//
// A fetch() can't set the Referer itself (forbidden header — Chrome
// replaces it with chrome-extension://...), so a declarativeNetRequest
// rule installs the captured provider-origin referer at the network
// layer (DNR applies to SW fetch() per Chrome docs). The rule is
// installed on demand for the requested host only.
(async () => {
try {
const proxyHost = hostOf(message.url);
if (proxyHost) {
// Prefer the provider's real referer captured from the live
// tab (domain-aware), or the entry's scan-time referer the viewer sent.
// With neither, install no rule: a self-origin referer is
// guaranteed-wrong for hotlink protection, so we never fall
// back to one (it would just 412).
const captured = await getReferer(proxyHost);
const referer = captured || message.referer || '';
if (referer) await ensureRefererRule(proxyHost, referer);
}
// hls.js resolves sub-playslists/segments relative to the master
// and can DROP the token query, handing us a token-less
// URL that 410s. injectFreshToken supplies the freshest live
// token for that video; 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('[vidplex-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('[vidplex-bg] proxyFetch failed:', e && e.message ? e.message : e);
sendResponse({ ok: false, error: e && e.message ? e.message : String(e) });
}
})();
return true;
}
case 'GOON_GET_REFERERS': {
// Resolve the provider referer captured for each manifest host
// (domain-aware: one provider referer covers every edge subdomain).
// Used at scan time to bind the referer to a stored video entry so it
// survives the watch-later flow.
async function getReferers(message, sendResponse) {
const hosts = Array.isArray(message.hosts) ? message.hosts : [];
const referers = {};
for (const host of hosts) {
const r = await getReferer(host);
if (r) referers[host] = r;
}
sendResponse({ ok: true, referers });
}
getReferers(message, sendResponse);
return true;
}
default: default:
console.warn('[goon-plugin] Unknown message type:', message.type); console.warn('[vidplex-plugin] Unknown message type:', message.type);
sendResponse({ error: `Unknown type: ${message.type}` });
break;
} }
return true; // keep sendResponse channel open for async work
}); });
+69 -122
View File
@@ -24,127 +24,31 @@
/** /**
* 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. Some sites 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');
script.onload = function () {
// Script loaded and executed — safe to remove the element now
if (script.parentNode) {
script.parentNode.removeChild(script);
}
};
(document.head || document.documentElement).appendChild(script); (document.head || document.documentElement).appendChild(script);
// Remove the script element after execution but keep interceptors active // Keep the element in the DOM — onload will remove it after execution
script.parentNode.removeChild(script);
} }
// Inject immediately at document_start // Inject immediately at document_start
@@ -163,7 +67,8 @@
source: source, source: source,
timestamp: event.data.timestamp timestamp: event.data.timestamp
}); });
console.log('[goon-plugin] Intercepted HLS manifest via', source, ':', url); console.log('[vidplex-plugin] Intercepted HLS manifest via', source, ':', url);
console.log('[vidplex-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('[vidplex-content] getVideoData called. Poster:', poster);
console.log('[vidplex-content] Intercepted manifests count:', interceptedManifests.length);
if (interceptedManifests.length > 0) {
console.log('[vidplex-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('[vidplex-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('[vidplex-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('[vidplex-content] DOM scan error:', e && e.message ? e.message : e);
}
console.log('[vidplex-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,
@@ -236,5 +183,5 @@
return false; return false;
}); });
console.log('[goon-plugin] Content script loaded. Interceptors injected at document_start.'); console.log('[vidplex-plugin] Content script loaded. Interceptors injected at document_start.');
})(); })();
+1 -1
View File
@@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
<rect width="128" height="128" rx="24" fill="#4361ee"/> <rect width="128" height="128" rx="24" fill="#4361ee"/>
<text x="64" y="84" text-anchor="middle" dominant-baseline="middle" font-size="64" font-weight="bold" font-family="sans-serif" fill="white">G</text> <text x="64" y="84" text-anchor="middle" dominant-baseline="middle" font-size="64" font-weight="bold" font-family="sans-serif" fill="white">V</text>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 279 B

After

Width:  |  Height:  |  Size: 279 B

+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
+169
View File
@@ -0,0 +1,169 @@
/**
* 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 that restrict
* script-src.
*
* 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.
+84
View File
@@ -32,6 +32,90 @@ h1 {
font-weight: 600; font-weight: 600;
} }
.instructions-panel {
display: flex;
flex-direction: column;
gap: 10px;
padding: 12px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: #f8fafc;
}
.instructions-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.instructions-kicker {
margin-bottom: 2px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #64748b;
}
.instructions-panel h2 {
font-size: 14px;
line-height: 1.3;
font-weight: 600;
}
.instructions-toggle {
flex: 0 0 auto;
padding: 6px 10px;
font-size: 12px;
background: transparent;
color: var(--accent);
border: 1px solid var(--border);
}
.instructions-toggle:hover {
background: #eef2ff;
}
.instructions-body {
display: flex;
flex-direction: column;
gap: 8px;
}
.instructions-body[hidden] {
display: none;
}
.instructions-list {
display: flex;
flex-direction: column;
gap: 8px;
padding-left: 18px;
}
.instructions-list li {
display: flex;
flex-direction: column;
gap: 2px;
}
.instructions-list strong {
font-size: 12px;
}
.instructions-list span,
.instructions-note,
.instructions-learn-more {
font-size: 12px;
line-height: 1.45;
color: #475569;
}
.instructions-note {
color: #7c3aed;
}
button { button {
padding: 8px 16px; padding: 8px 16px;
background: var(--accent); background: var(--accent);
+44 -2
View File
@@ -3,12 +3,54 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Goon Plugin</title> <title>VidPlex</title>
<link rel="stylesheet" href="index.css"> <link rel="stylesheet" href="index.css">
</head> </head>
<body> <body>
<div id="app"> <div id="app">
<h1>Goon Plugin</h1> <h1>VidPlex</h1>
<section class="instructions-panel" role="region" aria-labelledby="instructions-title">
<div class="instructions-header">
<div>
<p class="instructions-kicker">Quick start</p>
<h2 id="instructions-title">How to use VidPlex</h2>
</div>
<button
id="instructions-toggle"
type="button"
class="instructions-toggle"
aria-expanded="true"
aria-controls="instructions-body"
>
Hide instructions
</button>
</div>
<div id="instructions-body" class="instructions-body">
<ol class="instructions-list">
<li>
<strong>Start your videos</strong>
<span>Open the videos you want to play, one per tab, and begin their playback.</span>
</li>
<li>
<strong>Scan videos</strong>
<span>Click <strong>Find Videos</strong> to search the current browser window's tabs for playing videos.</span>
</li>
<li>
<strong>Review the list</strong>
<span>Check to make sure the right number of videos was found.</span>
</li>
<li>
<strong>Open the viewer</strong>
<span>Click <strong>Open Viewer</strong> to open the grid player in a new window.</span>
</li>
<li>
<strong>Reset and rescan</strong>
<span>Click <strong>Clear</strong> to erase results and scan again.</span>
</li>
</ol>
<p class="instructions-note">Warning: Browser pages, extension pages, and restricted frames can hide videos, so results may be incomplete.</p>
</div>
</section>
<button id="find-btn">Find Videos (all tabs)</button> <button id="find-btn">Find Videos (all tabs)</button>
<div id="video-list" class="video-list" style="display:none;"></div> <div id="video-list" class="video-list" style="display:none;"></div>
<div id="status" class="status" style="min-height:16px;"></div> <div id="status" class="status" style="min-height:16px;"></div>
+1229 -438
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();
});
});
+5
View File
@@ -33,6 +33,11 @@
"type": "string", "type": "string",
"enum": ["direct", "hls"], "enum": ["direct", "hls"],
"description": "Media delivery format. Defaults to 'direct' when omitted. Set to 'hls' for HTTP Live Streaming." "description": "Media delivery format. Defaults to 'direct' when omitted. Set to 'hls' for HTTP Live Streaming."
},
"referer": {
"type": ["string", "null"],
"format": "uri",
"description": "Provider Referer captured at scan time for the manifest's CDN; the proxy sends it so a referer-protecting CDN accepts sibling edge subdomains. Optional."
} }
}, },
"additionalProperties": false, "additionalProperties": false,
+1 -1
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Goon Viewer</title> <title>VidPlex Viewer</title>
<link rel="stylesheet" href="viewer.css"> <link rel="stylesheet" href="viewer.css">
</head> </head>
<body> <body>
+1212 -134
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);
});
});
File renamed without changes.
+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,
},
}, },
}); });