diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 9e18383..b411677 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -12,6 +12,8 @@ jobs: run: find . -type f -name '*.js' -not -path './.git/*' -print0 | xargs -0 -n1 node --check - name: firefox manifest in sync run: python3 build-aux/gen-firefox-manifest.py --check + - name: Install shadow-root test dependency + run: npm install --no-save --package-lock=false --ignore-scripts jsdom@30.1.3 - name: tests # Explicit globs: `node --test test/` fails on Node 22. run: node --test test/*.test.js diff --git a/background.js b/background.js index 1640858..bd35546 100644 --- a/background.js +++ b/background.js @@ -155,6 +155,8 @@ if (typeof module !== "undefined" && module.exports) { MAX_GRAB_URL_BYTES, MAX_HANDOFF_BODY_BYTES, MAX_VIDEO_ITEMS, + MAX_VIDEO_FRAMES, + MAX_VIDEO_TITLE_LENGTH, manifestMenuRows, directMenuRow, manifestKind, diff --git a/test/video-shadow.test.js b/test/video-shadow.test.js index dda6126..adfee8b 100644 --- a/test/video-shadow.test.js +++ b/test/video-shadow.test.js @@ -1,20 +1,14 @@ // Shadow-root video detection tests (jsdom). -// Run with: node --test test/video-shadow.test.js -// Requires the jsdom package (npm install jsdom); skipped if unavailable. +// CI installs a pinned jsdom version before running this suite. const { test, describe } = require("node:test"); const assert = require("node:assert/strict"); const path = require("node:path"); const CONTENT_PATH = path.join(__dirname, "..", "content.js"); -let JSDOM; -try { - ({ JSDOM } = require("jsdom")); -} catch { - JSDOM = null; -} +const { JSDOM } = require("jsdom"); -describe("shadow-root video detection (jsdom)", { skip: !JSDOM }, () => { +describe("shadow-root video detection (jsdom)", () => { function loadContent(dom) { const messages = []; @@ -27,23 +21,32 @@ describe("shadow-root video detection (jsdom)", { skip: !JSDOM }, () => { }; // content.js runs its init at load time against globals. const g = dom.window; - // Fake user activation: the play handler latches muted videos only - // when the user recently interacted. - g.navigator = { userActivation: { isActive: true } }; + // jsdom does not implement user activation; expose the property consumed + // by content.js on the window's Navigator instance. + Object.defineProperty(g.navigator, "userActivation", { + configurable: true, + value: { isActive: true }, + }); // Fresh module per test: the init block wires listeners on load. delete require.cache[require.resolve(CONTENT_PATH)]; // Keep globals set for the test duration (reports are async). - // Caller must restore via the returned cleanup(). + // Node 22 also exposes a read-only global navigator, so save its property + // descriptor and replace it explicitly instead of assigning to the getter. + const prevNavigator = Object.getOwnPropertyDescriptor(globalThis, "navigator"); const prev = { chrome: globalThis.chrome, document: globalThis.document, location: globalThis.location, - navigator: globalThis.navigator, }; globalThis.chrome = chrome; globalThis.document = g.document; globalThis.location = g.location; - globalThis.navigator = g.navigator; + Object.defineProperty(globalThis, "navigator", { + configurable: true, + enumerable: prevNavigator ? prevNavigator.enumerable : true, + writable: true, + value: g.navigator, + }); require(CONTENT_PATH); const cleanup = () => { // Install a dummy: pending debounce timers from this instance may @@ -53,7 +56,11 @@ describe("shadow-root video detection (jsdom)", { skip: !JSDOM }, () => { }; globalThis.document = prev.document; globalThis.location = prev.location; - globalThis.navigator = prev.navigator; + if (prevNavigator) { + Object.defineProperty(globalThis, "navigator", prevNavigator); + } else { + delete globalThis.navigator; + } }; return { messages, document: g.document, window: g, messageListener, cleanup }; } diff --git a/test/video.test.js b/test/video.test.js index a5f7aad..bf165fc 100644 --- a/test/video.test.js +++ b/test/video.test.js @@ -721,6 +721,83 @@ describe("detection wiring", () => { assert.equal(saved.frames[0][1].length, 10, "all active videos survive worker persistence"); }); + test("restoring oversized legacy video state caps frames and entries", async () => { + const chrome = makeChrome(); + const tabId = 122; + chrome._session.set("videoFinds:" + tabId, { + frames: Array.from({ length: 128 }, (_, frameId) => [ + frameId, + Array.from({ length: 8 }, (_, index) => ({ + src: `https://legacy.example.com/${frameId}-${index}.mp4`, + blob: false, + })), + ]), + sniffed: [], + kinds: [], + title: "legacy".repeat(500), + }); + const bg = loadBackground(chrome); + await chrome._listeners["runtime.onMessage"]( + { + type: "grab-videos-detected", + videos: [{ src: "https://fresh.example.com/top.mp4", blob: false }], + title: "fresh title", + }, + { tab: { id: tabId, url: "https://example.com/" }, frameId: 0 } + ); + + const findings = bg._videoFinds.get(tabId); + const total = [...findings.frames.values()].reduce((sum, videos) => sum + videos.length, 0); + assert.ok(findings.frames.size <= bg.MAX_VIDEO_FRAMES, "restored frames stay bounded"); + assert.ok(total <= bg.MAX_VIDEO_ITEMS, "restored entries stay bounded"); + assert.ok(findings.frames.has(0), "the top frame is retained"); + assert.ok(findings.frames.get(0).some((video) => video.src === "https://fresh.example.com/top.mp4")); + assert.ok(findings.title.length <= bg.MAX_VIDEO_TITLE_LENGTH, "restored title stays bounded"); + }); + + test("video report intake caps retained frames, entries, and titles", async () => { + const chrome = makeChrome(); + const bg = loadBackground(chrome); + const L = chrome._listeners; + const tabId = 121; + await L["tabs.onActivated"]({ tabId }); + + await L["runtime.onMessage"]( + { + type: "grab-videos-detected", + videos: Array.from({ length: bg.MAX_VIDEO_ITEMS * 4 }, (_, i) => ({ + src: `https://cdn.example.com/v/${i}.mp4`, + blob: false, + })), + title: "x".repeat(bg.MAX_VIDEO_TITLE_LENGTH * 4), + }, + { tab: { id: tabId, url: "https://example.com/" }, frameId: 0 } + ); + + for (let frameId = 1; frameId < bg.MAX_VIDEO_FRAMES + 10; frameId++) { + await L["runtime.onMessage"]( + { + type: "grab-videos-detected", + videos: [{ src: `https://cdn.example.com/frame-${frameId}.mp4`, blob: false }], + }, + { tab: { id: tabId, url: "https://example.com/" }, frameId } + ); + } + + const findings = bg._videoFinds.get(tabId); + const total = [...findings.frames.values()].reduce((sum, videos) => sum + videos.length, 0); + assert.ok(findings.frames.size <= bg.MAX_VIDEO_FRAMES, "frame IDs stay bounded"); + assert.ok(total <= bg.MAX_VIDEO_ITEMS, "DOM video entries stay bounded per tab"); + assert.ok(findings.title.length <= bg.MAX_VIDEO_TITLE_LENGTH, "page titles stay bounded"); + + const saved = chrome._session.get("videoFinds:" + tabId); + assert.ok(saved.frames.length <= bg.MAX_VIDEO_FRAMES, "persisted frame IDs stay bounded"); + assert.ok( + saved.frames.reduce((sum, pair) => sum + pair[1].length, 0) <= bg.MAX_VIDEO_ITEMS, + "persisted DOM entries stay bounded" + ); + }); + test("a blob: video is paired with the tab's sniffed manifest", async () => { const chrome = makeChrome(); loadBackground(chrome); diff --git a/video-menu.js b/video-menu.js index dcb002c..622ffbc 100644 --- a/video-menu.js +++ b/video-menu.js @@ -318,6 +318,8 @@ function mergeVideoFinds(domVideos, sniffedUrls, kinds) { // sniffed: [url] }. Reports are tracked per frame because the content script // runs in all frames; last-writer-wins per tab would drop other frames' // videos whenever two frames report at different times. +const MAX_VIDEO_FRAMES = 32; +const MAX_VIDEO_TITLE_LENGTH = 512; const videoFinds = new Map(); /// Storage.session outlives an MV3 service-worker instance and is cleared when @@ -340,6 +342,30 @@ function enqueueVideoStateWrite(tabId, write) { return next; } +/// Normalize page-derived data before retaining it. Bound scanning as well as +/// retained results; a blob video counts as one item. +function normalizeVideoReport(rawVideos, maximum) { + const input = Array.isArray(rawVideos) ? rawVideos.slice(0, MAX_VIDEO_ITEMS) : []; + const out = []; + const seen = new Set(); + for (const video of input) { + if (out.length >= maximum) break; + if (!video || typeof video !== "object") continue; + const src = + typeof video.src === "string" && video.src && fitsGrabUrl(video.src) + ? video.src + : null; + if (!src && video.blob !== true) continue; + const key = src || "__grab_blob__"; + if (seen.has(key)) continue; + seen.add(key); + const item = { src, blob: video.blob === true }; + if (Number.isFinite(video.height) && video.height > 0) item.height = video.height; + out.push(item); + } + return out; +} + function snapshotVideoFinds(finds) { const frames = []; let remaining = MAX_VIDEO_ITEMS; @@ -371,7 +397,8 @@ function snapshotVideoFinds(finds) { frames, sniffed: (finds.sniffed || []).filter((url) => fitsGrabUrl(url)).slice(-50), kinds: [...(finds.kinds || new Map()).entries()].slice(-200), - title: typeof finds.title === "string" ? finds.title : "", + title: + typeof finds.title === "string" ? finds.title.slice(0, MAX_VIDEO_TITLE_LENGTH) : "", }; } @@ -393,14 +420,63 @@ async function restoreVideoFinds(tabId) { const saved = result && result[key]; if (saved && typeof saved === "object") { const frames = new Map(); + let remaining = MAX_VIDEO_ITEMS; + // Avoid copying/sorting an arbitrarily large snapshot left by an older + // worker that did not cap frame IDs. Keep at most the configured limit + // while ensuring the top frame wins if it appears late in the array. + const savedFrames = []; + const seenFrameIds = new Set(); for (const pair of Array.isArray(saved.frames) ? saved.frames : []) { - if (!Array.isArray(pair) || pair.length !== 2 || !Number.isInteger(pair[0]) || !Array.isArray(pair[1])) continue; - const videos = pair[1].filter((video) => video && typeof video === "object" && (video.src == null || fitsGrabUrl(video.src))).map((video) => { - const item = { src: typeof video.src === "string" ? video.src : null, blob: video.blob === true }; + if ( + !Array.isArray(pair) || + pair.length !== 2 || + !Number.isInteger(pair[0]) || + pair[0] < 0 || + !Array.isArray(pair[1]) || + seenFrameIds.has(pair[0]) + ) { + continue; + } + if (pair[0] === 0) { + if (savedFrames.length >= MAX_VIDEO_FRAMES) { + const lastNonTop = savedFrames.findIndex(([id]) => id !== 0); + if (lastNonTop >= 0) { + const [removed] = savedFrames.splice(lastNonTop, 1); + seenFrameIds.delete(removed[0]); + } + } + if (savedFrames.length < MAX_VIDEO_FRAMES) { + savedFrames.unshift(pair); + seenFrameIds.add(0); + } + } else if (savedFrames.length < MAX_VIDEO_FRAMES) { + savedFrames.push(pair); + seenFrameIds.add(pair[0]); + } + if (savedFrames.length >= MAX_VIDEO_FRAMES && seenFrameIds.has(0)) break; + } + for (const [frameId, savedVideos] of savedFrames) { + if (remaining <= 0) break; + if (!frames.has(frameId) && frames.size >= MAX_VIDEO_FRAMES) continue; + const videos = []; + const seen = new Set(); + for (const video of savedVideos.slice(0, MAX_VIDEO_ITEMS)) { + if (remaining <= 0) break; + if (!video || typeof video !== "object") continue; + const src = + typeof video.src === "string" && video.src && fitsGrabUrl(video.src) + ? video.src + : null; + if (!src && video.blob !== true) continue; + const key = src || "__grab_blob__"; + if (seen.has(key)) continue; + seen.add(key); + const item = { src, blob: video.blob === true }; if (Number.isFinite(video.height) && video.height > 0) item.height = video.height; - return item; - }); - frames.set(pair[0], videos); + videos.push(item); + remaining -= 1; + } + frames.set(frameId, videos); } const sniffed = Array.isArray(saved.sniffed) ? saved.sniffed.filter((url) => fitsGrabUrl(url)).slice(-50) @@ -416,7 +492,8 @@ async function restoreVideoFinds(tabId) { frames, sniffed, kinds, - title: typeof saved.title === "string" ? saved.title : "", + title: + typeof saved.title === "string" ? saved.title.slice(0, MAX_VIDEO_TITLE_LENGTH) : "", }); } } catch { @@ -737,30 +814,55 @@ async function onVideoReport(msg, sender) { // Unparseable URL: fall through and let the menu-build check decide. } await restoreVideoFinds(tabId); - const frameId = sender && sender.frameId != null ? sender.frameId : 0; + const reportedFrameId = sender && sender.frameId != null ? sender.frameId : 0; + const frameId = + Number.isInteger(reportedFrameId) && reportedFrameId >= 0 ? reportedFrameId : 0; let f = videoFinds.get(tabId); if (!f) { f = { frames: new Map(), sniffed: [] }; videoFinds.set(tabId, f); } - // Ordinary empty re-reports may be a live stream swapping its