Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 2 additions & 0 deletions background.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
39 changes: 23 additions & 16 deletions test/video-shadow.test.js
Original file line number Diff line number Diff line change
@@ -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 = [];
Expand All @@ -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
Expand All @@ -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 };
}
Expand Down
77 changes: 77 additions & 0 deletions test/video.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
144 changes: 123 additions & 21 deletions video-menu.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
Expand Down Expand Up @@ -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) : "",
};
}

Expand All @@ -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)
Expand All @@ -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 {
Expand Down Expand Up @@ -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 <video>
// element before the replacement fires play; preserve that snapshot. Pause,
// end, and media-reset reports are marked authoritative so stale entries
// disappear immediately instead of lingering in the context menu.
const videos = (Array.isArray(msg.videos) ? msg.videos : []).filter(
(v) => !(v && typeof v.src === "string" && !fitsGrabUrl(v.src)),
);
// Bound frame state before accepting reports. Preserve the top frame when
// replacing an older entry because its title tracks SPA navigation.
const hadFrame = f.frames.has(frameId);
let changed = false;
if (videos.length > 0 || !hadFrame || msg.replaceFrame === true) {
f.frames.set(frameId, videos);
changed = true;
let acceptFrame = true;
if (!hadFrame && f.frames.size >= MAX_VIDEO_FRAMES) {
if (frameId === 0) {
const evictFrame = [...f.frames.keys()].find((id) => id !== 0);
if (evictFrame !== undefined) {
f.frames.delete(evictFrame);
changed = true;
} else {
acceptFrame = false;
}
} else {
acceptFrame = false;
}
}
if (acceptFrame) {
let otherVideoCount = 0;
for (const [id, existing] of f.frames) {
if (id !== frameId) otherVideoCount += Array.isArray(existing) ? existing.length : 0;
}
const allowance = Math.max(0, MAX_VIDEO_ITEMS - otherVideoCount);
const videos = normalizeVideoReport(msg.videos, allowance);
// Preserve ordinary empty re-reports for live streams; pause/end/reset
// reports intentionally replace their frame's prior snapshot.
if (videos.length > 0 || !hadFrame || msg.replaceFrame === true) {
f.frames.set(frameId, videos);
changed = true;
}
}
// The video's own title for menu labels.
// The top frame's title follows the page (SPAs change it without a
// navigation); a subframe only fills a gap.
if (typeof msg.title === "string" && msg.title && (frameId === 0 || !f.title)) {
f.title = msg.title;
if (
typeof msg.title === "string" &&
msg.title &&
(frameId === 0 || !f.title)
) {
f.title = msg.title.slice(0, MAX_VIDEO_TITLE_LENGTH);
changed = true;
}
// Skip the menu rebuild when nothing changed: a page with many iframes
Expand Down
Loading