Fix Firefox consent and release interface regressions

Keep private windows counts-only and separate account consent from core access. Correct stale account UI, Premium themes and ranges, display preferences, and RTL popup and release layouts.
This commit is contained in:
Anarios
2026-10-18 18:13:46 +02:00
parent 74651b76ee
commit c31f60cc24
77 changed files with 3563 additions and 394 deletions
+3 -1
View File
@@ -1,7 +1,9 @@
# Return YouTube Dislike 4.0.6
# Return YouTube Dislike 4.1.0
What's new since 3.0.0.18:
- **RYD comments:** read and react to discussions under videos and Shorts. Commenter and Premium members can post and reply, with notifications for replies and reactions.
- **Firefox consent and translations:** updated permission controls and new translations.
- **YouTube Shorts support:** see dislike counts and vote on Shorts, with fixes for switching between videos and keeping the buttons in sync.
- **More reliable counts and voting:** fixes for the latest YouTube layouts, stale counts after navigation, and votes that failed to submit.
- **Premium statistics:** explore likes and dislikes over time, compare activity by country, and browse interactive world and US maps.
@@ -755,7 +755,7 @@
const button = control?.querySelector("button");
if (control && button) {
control.setAttribute("data-fixture-native-hydration-pending", role);
delayedNativeButtons.push({ button, control });
delayedNativeButtons.push({ button, control, parent: button.parentElement });
button.remove();
}
}
@@ -772,9 +772,15 @@
}
if (delayedNativeButtons.length > 0) {
setTimeout(() => {
for (const { button, control } of delayedNativeButtons) {
if (replacement.isConnected && control.isConnected && !control.querySelector("button")) {
control.appendChild(button);
for (const { button, control, parent } of delayedNativeButtons) {
if (
replacement.isConnected &&
control.isConnected &&
parent.isConnected &&
control.contains(parent) &&
!control.querySelector("button")
) {
parent.appendChild(button);
control.removeAttribute("data-fixture-native-hydration-pending");
}
}
+22 -3
View File
@@ -1,11 +1,24 @@
import { consentApi, hasCoreDataPermission, requestCoreDataPermission } from "./src/core-data-consent";
import { isPrivateBrowsingContext } from "./src/private-browsing";
const continueButton = document.getElementById("consent-continue");
const declineButton = document.getElementById("consent-decline");
const status = document.getElementById("consent-status");
const canRequestPermissions = typeof consentApi()?.permissions?.request === "function";
const privateBrowsing = isPrivateBrowsingContext();
const privateSetupInstruction = "Enable RYD in a normal Firefox window to load counts here.";
const canRequestPermissions = !privateBrowsing && typeof consentApi()?.permissions?.request === "function";
if (!canRequestPermissions) {
if (privateBrowsing) {
const main = document.querySelector("main");
const message = document.createElement("p");
message.textContent =
"Counts only in private windows. Voting, sign-in, premium analytics and discussions are unavailable here.";
const instruction = document.getElementById("consent-next-step");
instruction.textContent = privateSetupInstruction;
const actions = document.getElementById("consent-actions");
actions.replaceChildren(declineButton);
main.replaceChildren(main.querySelector("img"), main.querySelector("h1"), message, instruction, actions, status);
} else if (!canRequestPermissions) {
document.getElementById("consent-next-step").textContent =
"Continue opens RYD's permission page. Firefox will only ask after you select Allow in Firefox there. " +
"If you decline, RYD will not load counts or submit votes. YouTube's own buttons will still work.";
@@ -27,13 +40,18 @@ function notifyParent(type) {
}
async function dismiss() {
if (!privateBrowsing) {
try {
await consentApi().storage.local.set({ rydConsentDismissed: true });
} catch (_) {}
status.textContent = "RYD is paused. You can enable it here whenever you're ready.";
}
status.textContent = privateBrowsing
? privateSetupInstruction
: "RYD is paused. You can enable it here whenever you're ready.";
notifyParent("ryd-consent-dismissed");
}
if (!privateBrowsing) {
continueButton.addEventListener("click", () => {
if (!canRequestPermissions) {
continueButton.disabled = true;
@@ -73,4 +91,5 @@ continueButton.addEventListener("click", () => {
}
});
});
}
declineButton.addEventListener("click", dismiss);
+45 -10
View File
@@ -6,21 +6,32 @@ jest.mock("./src/core-data-consent", () => ({
requestCoreDataPermission: jest.fn(),
consentApi: jest.fn(),
}));
jest.mock("./src/private-browsing", () => ({ isPrivateBrowsingContext: jest.fn() }));
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
describe("Firefox consent explainer", () => {
let permission;
let local;
beforeEach(() => {
let sendMessage;
function openExplainer({ privateBrowsing = false, canRequest = true } = {}) {
jest.resetModules();
document.documentElement.innerHTML = fs.readFileSync(path.join(__dirname, "consent.html"), "utf8");
permission = require("./src/core-data-consent");
local = { set: jest.fn(async () => {}), remove: jest.fn(async () => {}) };
permission.consentApi.mockReturnValue({ storage: { local }, permissions: { request: jest.fn() } });
require("./src/private-browsing").isPrivateBrowsingContext.mockReturnValue(privateBrowsing);
local = { get: jest.fn(), set: jest.fn(async () => {}), remove: jest.fn(async () => {}) };
sendMessage = jest.fn(async () => ({ success: true }));
permission.consentApi.mockReturnValue({
storage: { local },
runtime: { sendMessage },
...(canRequest ? { permissions: { request: jest.fn() } } : {}),
});
permission.hasCoreDataPermission.mockResolvedValue(false);
permission.requestCoreDataPermission.mockResolvedValue(false);
const continueButton = document.getElementById("consent-continue");
require("./consent");
});
return continueButton;
}
beforeEach(() => openExplainer());
test("opening the explainer and selecting Not now request no permissions", async () => {
expect(document.querySelector("main").textContent).toMatch(/random ID/);
@@ -50,16 +61,40 @@ describe("Firefox consent explainer", () => {
});
test("a page-embedded explainer opens the extension consent page without requesting permissions", async () => {
jest.resetModules();
document.documentElement.innerHTML = fs.readFileSync(path.join(__dirname, "consent.html"), "utf8");
permission = require("./src/core-data-consent");
const sendMessage = jest.fn(async () => ({ success: true }));
permission.consentApi.mockReturnValue({ storage: { local }, runtime: { sendMessage } });
require("./consent");
openExplainer({ canRequest: false });
document.getElementById("consent-continue").click();
await flush();
expect(sendMessage).toHaveBeenCalledWith({ message: "ryd_open_consent" });
expect(permission.requestCoreDataPermission).not.toHaveBeenCalled();
expect(document.getElementById("consent-next-step").textContent).toMatch(/permission page/);
});
test.each([true, false])(
"private setup stays local without requesting permissions or opening a normal page when request API availability is %s",
async (canRequest) => {
const continueButton = openExplainer({ privateBrowsing: true, canRequest });
expect(document.getElementById("consent-next-step").textContent).toBe(
"Enable RYD in a normal Firefox window to load counts here.",
);
expect(document.querySelector("main").textContent).toContain(
"Counts only in private windows. Voting, sign-in, premium analytics and discussions are unavailable here.",
);
expect(document.querySelector("main").textContent).not.toMatch(/random ID|Allow in Firefox|Continue/);
expect(document.getElementById("consent-continue")).toBeNull();
continueButton.click();
document.getElementById("consent-decline").click();
await flush();
expect(permission.consentApi).not.toHaveBeenCalled();
expect(permission.hasCoreDataPermission).not.toHaveBeenCalled();
expect(permission.requestCoreDataPermission).not.toHaveBeenCalled();
expect(local.get).not.toHaveBeenCalled();
expect(local.set).not.toHaveBeenCalled();
expect(local.remove).not.toHaveBeenCalled();
expect(sendMessage).not.toHaveBeenCalled();
expect(document.getElementById("consent-status").textContent).toBe(
"Enable RYD in a normal Firefox window to load counts here.",
);
},
);
});
+59 -38
View File
@@ -903,13 +903,33 @@ ytd-menu-renderer.ytd-watch-metadata {
color: var(--yt-spec-call-to-action, #3ea6ff);
}
.ryd-premium-analytics,
.ryd-premium-tier-notice {
--ryd-analytics-bg: var(--yt-spec-base-background, #fff);
--ryd-analytics-fg: var(--yt-spec-text-primary, #0f0f0f);
--ryd-analytics-muted: var(--yt-spec-text-secondary, #606060);
--ryd-analytics-border: rgba(0, 0, 0, 0.08);
--ryd-analytics-surface: rgba(15, 23, 42, 0.04);
--ryd-analytics-row: rgba(15, 23, 42, 0.03);
}
html[dark]:not([dark="false"]):not([light]) .ryd-premium-analytics,
html[dark]:not([dark="false"]):not([light]) .ryd-premium-tier-notice {
--ryd-analytics-bg: var(--yt-spec-base-background, #202020);
--ryd-analytics-fg: var(--yt-spec-text-primary, #fff);
--ryd-analytics-muted: var(--yt-spec-text-secondary, #bbb);
--ryd-analytics-border: rgba(255, 255, 255, 0.08);
--ryd-analytics-surface: rgba(255, 255, 255, 0.02);
--ryd-analytics-row: rgba(255, 255, 255, 0.03);
}
.ryd-premium-analytics {
background: var(--yt-spec-base-background, #202020);
border: 1px solid rgba(255, 255, 255, 0.08);
background: var(--ryd-analytics-bg);
border: 1px solid var(--ryd-analytics-border);
border-radius: 16px;
padding: 16px;
margin-bottom: 16px;
color: var(--yt-spec-text-primary, #ffffff);
color: var(--ryd-analytics-fg);
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.22);
font-family: Roboto, Arial, sans-serif;
position: relative;
@@ -996,12 +1016,12 @@ html[dark] .ryd-premium-teaser {
}
.ryd-premium-tier-notice {
background: var(--yt-spec-base-background, #202020);
border: 1px solid rgba(255, 255, 255, 0.08);
background: var(--ryd-analytics-bg);
border: 1px solid var(--ryd-analytics-border);
border-radius: 16px;
padding: 20px;
margin-bottom: 16px;
color: var(--yt-spec-text-primary, #ffffff);
color: var(--ryd-analytics-fg);
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.18);
font-family: Roboto, Arial, sans-serif;
display: flex;
@@ -1026,7 +1046,7 @@ html[dark] .ryd-premium-teaser {
.ryd-premium-tier-notice__message {
margin: 0;
font-size: 13px;
color: var(--yt-spec-text-secondary, #bcccdc);
color: var(--ryd-analytics-muted);
line-height: 1.5;
}
@@ -1282,6 +1302,7 @@ html[dark] .ryd-premium-teaser__toggle:focus-visible {
.ryd-analytics__controls {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
flex: 1 1 auto;
@@ -1329,30 +1350,30 @@ html[dark] .ryd-premium-teaser__toggle:focus-visible {
color: #0f172a;
}
html[dark] .ryd-analytics__ranges,
html[dark] .ryd-analytics__modes,
html[dark] .ryd-analytics__window-toggle {
html[dark]:not([dark="false"]):not([light]) .ryd-analytics__ranges,
html[dark]:not([dark="false"]):not([light]) .ryd-analytics__modes,
html[dark]:not([dark="false"]):not([light]) .ryd-analytics__window-toggle {
background: rgba(255, 255, 255, 0.05);
border: none;
}
html[dark] .ryd-analytics__ranges button,
html[dark] .ryd-analytics__modes button,
html[dark] .ryd-analytics__window-toggle button {
html[dark]:not([dark="false"]):not([light]) .ryd-analytics__ranges button,
html[dark]:not([dark="false"]):not([light]) .ryd-analytics__modes button,
html[dark]:not([dark="false"]):not([light]) .ryd-analytics__window-toggle button {
color: var(--yt-spec-text-secondary, #bbbbbb);
}
html[dark] .ryd-analytics__ranges button.is-active,
html[dark] .ryd-analytics__modes button.is-active,
html[dark] .ryd-analytics__window-toggle button.is-active {
html[dark]:not([dark="false"]):not([light]) .ryd-analytics__ranges button.is-active,
html[dark]:not([dark="false"]):not([light]) .ryd-analytics__modes button.is-active,
html[dark]:not([dark="false"]):not([light]) .ryd-analytics__window-toggle button.is-active {
background: rgba(255, 255, 255, 0.15);
color: var(--yt-spec-text-primary, #ffffff);
box-shadow: none;
}
html[dark] .ryd-analytics__ranges button:hover,
html[dark] .ryd-analytics__modes button:hover,
html[dark] .ryd-analytics__window-toggle button:hover {
html[dark]:not([dark="false"]):not([light]) .ryd-analytics__ranges button:hover,
html[dark]:not([dark="false"]):not([light]) .ryd-analytics__modes button:hover,
html[dark]:not([dark="false"]):not([light]) .ryd-analytics__window-toggle button:hover {
background: rgba(255, 255, 255, 0.1);
color: var(--yt-spec-text-primary, #ffffff);
}
@@ -1375,11 +1396,11 @@ html[dark] .ryd-analytics__window-toggle button:hover {
color: #4b5563;
}
html[dark] .ryd-analytics__window-label {
html[dark]:not([dark="false"]):not([light]) .ryd-analytics__window-label {
color: var(--yt-spec-text-primary, #ffffff);
}
html[dark] .ryd-analytics__window-hint {
html[dark]:not([dark="false"]):not([light]) .ryd-analytics__window-hint {
color: var(--yt-spec-text-secondary, #a3a3a3);
}
@@ -1390,7 +1411,7 @@ html[dark] .ryd-analytics__window-hint {
}
.ryd-analytics__section {
background: rgba(255, 255, 255, 0.02);
background: var(--ryd-analytics-surface);
border-radius: 16px;
padding: 12px;
display: flex;
@@ -1439,14 +1460,14 @@ html[dark] .ryd-analytics__window-hint {
box-shadow: inset 0 0 0 1px rgba(59, 130, 246, 0.4);
}
html[dark] .ryd-analytics__section-expand {
html[dark]:not([dark="false"]):not([light]) .ryd-analytics__section-expand {
border: none;
background: rgba(255, 255, 255, 0.08);
color: var(--yt-spec-text-secondary, #cbd5f5);
}
html[dark] .ryd-analytics__section-expand.is-active,
html[dark] .ryd-analytics__section-expand:hover {
html[dark]:not([dark="false"]):not([light]) .ryd-analytics__section-expand.is-active,
html[dark]:not([dark="false"]):not([light]) .ryd-analytics__section-expand:hover {
background: rgba(99, 102, 241, 0.25);
color: #f8fafc;
box-shadow: none;
@@ -1471,7 +1492,7 @@ html[dark] .ryd-analytics__section-expand:hover {
.ryd-analytics__bucket-label {
font-size: 12px;
color: var(--yt-spec-text-secondary, #bbbbbb);
color: var(--ryd-analytics-muted);
}
.ryd-analytics__chart {
@@ -1494,7 +1515,7 @@ html[dark] .ryd-analytics__section-expand:hover {
.ryd-analytics__map-label {
font-size: 12px;
color: var(--yt-spec-text-secondary, #bbbbbb);
color: var(--ryd-analytics-muted);
}
.ryd-analytics__map-reset {
@@ -1517,12 +1538,12 @@ html[dark] .ryd-analytics__section-expand:hover {
box-shadow: inset 0 0 0 1px rgba(59, 130, 246, 0.35);
}
html[dark] .ryd-analytics__map-reset {
html[dark]:not([dark="false"]):not([light]) .ryd-analytics__map-reset {
background: rgba(255, 255, 255, 0.08);
color: var(--yt-spec-text-secondary, #bbbbbb);
}
html[dark] .ryd-analytics__map-reset:hover {
html[dark]:not([dark="false"]):not([light]) .ryd-analytics__map-reset:hover {
background: rgba(255, 255, 255, 0.18);
color: var(--yt-spec-text-primary, #ffffff);
}
@@ -1544,7 +1565,7 @@ html[dark] .ryd-analytics__map-reset:hover {
margin: 0 0 6px;
font-size: 13px;
font-weight: 600;
color: var(--yt-spec-text-secondary, #bbbbbb);
color: var(--ryd-analytics-muted);
}
.ryd-analytics__list-items {
@@ -1561,8 +1582,8 @@ html[dark] .ryd-analytics__map-reset:hover {
justify-content: space-between;
align-items: center;
font-size: 12px;
color: var(--yt-spec-text-primary, #ffffff);
background: rgba(255, 255, 255, 0.03);
color: var(--ryd-analytics-fg);
background: var(--ryd-analytics-row);
border-radius: 6px;
padding: 6px 8px;
}
@@ -1580,7 +1601,7 @@ html[dark] .ryd-analytics__map-reset:hover {
}
.ryd-analytics__placeholder {
color: var(--yt-spec-text-secondary, #bbbbbb);
color: var(--ryd-analytics-muted);
font-style: italic;
background: transparent !important;
justify-content: flex-start !important;
@@ -1588,7 +1609,7 @@ html[dark] .ryd-analytics__map-reset:hover {
.ryd-analytics__footer {
font-size: 12px;
color: var(--yt-spec-text-secondary, #bbbbbb);
color: var(--ryd-analytics-muted);
}
.ryd-analytics__totals {
@@ -1617,21 +1638,21 @@ html[dark] .ryd-analytics__map-reset:hover {
letter-spacing: 0.08em;
font-weight: 600;
font-size: 10px;
color: var(--yt-spec-text-secondary, #bbbbbb);
color: var(--ryd-analytics-muted);
}
.ryd-analytics__totals-value {
font-weight: 600;
font-variant-numeric: tabular-nums;
color: var(--yt-spec-text-primary, #f1f1f1);
color: var(--ryd-analytics-fg);
}
.ryd-analytics__period-label {
font-size: 11px;
color: var(--yt-spec-text-secondary, #bbbbbb);
color: var(--ryd-analytics-muted);
}
.ryd-analytics__summary-meta {
color: var(--yt-spec-text-secondary, #bbbbbb);
color: var(--ryd-analytics-muted);
line-height: 1.4;
}
+1 -1
View File
@@ -4,7 +4,7 @@
"default_locale": "en",
"version": "__RYD_VERSION__",
"manifest_version": 2,
"incognito": "not_allowed",
"incognito": "spanning",
"background": {
"scripts": ["ryd.background.js"]
},
+8
View File
@@ -0,0 +1,8 @@
(() => {
if (location.protocol !== "moz-extension:") return;
// Prepare a distinct document URL before the first extension API access.
// Firefox uses that initial URL when identifying popup message senders.
const url = new URL(location.href);
url.searchParams.set("rydContext", crypto.randomUUID());
history.replaceState(null, "", url.href);
})();
+35
View File
@@ -0,0 +1,35 @@
const fs = require("node:fs");
const path = require("node:path");
const vm = require("node:vm");
test("Firefox popup establishes its document identity before touching extension APIs", () => {
const source = fs.readFileSync(path.join(__dirname, "popup-context.js"), "utf8");
const context = {
location: { protocol: "moz-extension:", href: "moz-extension://fixture/popup.html?existing=1#details" },
URL,
crypto: { randomUUID: () => "synthetic-context" },
history: { replaceState: jest.fn() },
};
for (const api of ["browser", "chrome"]) {
Object.defineProperty(context, api, {
get() {
throw new Error("Extension context initialized too soon");
},
});
}
vm.runInNewContext(source, context);
expect(context.history.replaceState).toHaveBeenCalledWith(
null,
"",
"moz-extension://fixture/popup.html?existing=1&rydContext=synthetic-context#details",
);
const html = fs.readFileSync(path.join(__dirname, "popup.html"), "utf8");
expect(html.match(/<script\b[^>]*src="([^"]+)"/)[1]).toBe("popup-context.js");
});
test.each(["chrome-extension:", "safari-web-extension:"])("%s popup URLs remain unchanged", (protocol) => {
const source = fs.readFileSync(path.join(__dirname, "popup-context.js"), "utf8");
const history = { replaceState: jest.fn() };
vm.runInNewContext(source, { location: { protocol }, history });
expect(history.replaceState).not.toHaveBeenCalled();
});
+48
View File
@@ -0,0 +1,48 @@
/** @jest-environment jsdom */
const fs = require("node:fs");
const path = require("node:path");
jest.mock("./src/consent-prompt", () => ({ waitForCoreDataConsent: () => new Promise(() => {}) }));
jest.mock("./src/core-data-consent", () => ({ onCoreDataPermissionChanged: jest.fn() }));
describe("popup locale direction", () => {
beforeEach(() => {
jest.resetModules();
document.documentElement.removeAttribute("dir");
document.documentElement.innerHTML = fs.readFileSync(path.join(__dirname, "popup.html"), "utf8");
const messages = require("./_locales/en/messages.json");
global.chrome = {
i18n: { getMessage: jest.fn((key) => messages[key]?.message || "") },
runtime: { getManifest: () => ({ version: "1.0.0" }) },
storage: {
sync: { get: jest.fn(), set: jest.fn() },
onChanged: { addListener: jest.fn() },
},
};
});
afterEach(() => {
delete global.chrome;
document.documentElement.removeAttribute("dir");
});
test.each(["rtl", "ltr"])("uses %s locale direction while localizing the popup", (direction) => {
const getMessage = chrome.i18n.getMessage.getMockImplementation();
chrome.i18n.getMessage.mockImplementation((key) => (key === "@@bidi_dir" ? direction : getMessage(key)));
require("./popup");
expect(document.documentElement.dir).toBe(direction);
expect(document.querySelector("h1").textContent).toBe("Return YouTube Dislike");
expect(document.documentElement.innerHTML).not.toContain("__MSG_");
});
test.each(["", "auto"])("preserves existing direction when locale metadata is %j", (direction) => {
document.documentElement.dir = "rtl";
chrome.i18n.getMessage.mockImplementation((key) => (key === "@@bidi_dir" ? direction : ""));
require("./popup");
expect(document.documentElement.dir).toBe("rtl");
});
});
+112
View File
@@ -0,0 +1,112 @@
/** @jest-environment jsdom */
const fs = require("fs");
const path = require("path");
jest.mock("./src/consent-prompt", () => ({ waitForCoreDataConsent: jest.fn(() => Promise.resolve()) }));
jest.mock("./src/core-data-consent", () => ({
fetchWithCoreConsent: jest.fn(),
onCoreDataPermissionChanged: jest.fn(),
}));
jest.mock("./src/discussion/popup", () => ({ initDiscussionPopup: jest.fn() }));
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
const privateMessage =
"Counts only in private windows. Voting, sign-in, premium analytics and discussions are unavailable here.";
describe("private popup", () => {
function openPopup({ privateBrowsing, firefox = true }) {
jest.resetModules();
const messages = require("./_locales/en/messages.json");
document.documentElement.innerHTML = fs.readFileSync(path.join(__dirname, "popup.html"), "utf8");
global.chrome = {
extension: { inIncognitoContext: privateBrowsing },
i18n: { getMessage: (key) => messages[key]?.message || "" },
runtime: {
getManifest: () => (firefox ? require("./manifest-firefox.json") : { version: "1.0.0" }),
sendMessage: jest.fn(),
},
permissions: {
getAll: jest.fn(),
request: jest.fn(),
onRemoved: { addListener: jest.fn() },
},
tabs: { create: jest.fn() },
storage: {
sync: {
get: jest.fn((keys, callback) =>
callback({ numberDisplayFormat: "compactShort", hideClutterButtons: false }),
),
set: jest.fn(),
remove: jest.fn(),
},
local: { get: jest.fn(), set: jest.fn(), remove: jest.fn() },
onChanged: { addListener: jest.fn() },
},
};
global.browser = global.chrome;
global.fetch = jest.fn();
require("./popup");
}
afterEach(() => {
delete global.chrome;
delete global.browser;
delete global.fetch;
});
test("shows only branding and the approved count-only message without initializing shared state", async () => {
openPopup({ privateBrowsing: true });
await flush();
expect(document.getElementById("ryd-private-counts-message").textContent).toBe(privateMessage);
expect(document.querySelector("h1").textContent).toBe("Return YouTube Dislike");
expect(document.querySelectorAll("img")).toHaveLength(1);
expect(document.querySelector("img").getAttribute("src")).toBe("icons/logo.svg");
expect(
document.querySelector("button, input, select, a, iframe, #patreon-section, #ryd-discussion-popup"),
).toBeNull();
expect(chrome.storage.sync.get).not.toHaveBeenCalled();
expect(chrome.storage.sync.set).not.toHaveBeenCalled();
expect(chrome.storage.sync.remove).not.toHaveBeenCalled();
expect(chrome.storage.local.get).not.toHaveBeenCalled();
expect(chrome.storage.local.set).not.toHaveBeenCalled();
expect(chrome.storage.local.remove).not.toHaveBeenCalled();
expect(chrome.storage.onChanged.addListener).not.toHaveBeenCalled();
expect(chrome.permissions.getAll).not.toHaveBeenCalled();
expect(chrome.permissions.request).not.toHaveBeenCalled();
expect(chrome.permissions.onRemoved.addListener).not.toHaveBeenCalled();
expect(chrome.runtime.sendMessage).not.toHaveBeenCalled();
expect(chrome.tabs.create).not.toHaveBeenCalled();
expect(fetch).not.toHaveBeenCalled();
expect(require("./src/consent-prompt").waitForCoreDataConsent).not.toHaveBeenCalled();
expect(require("./src/core-data-consent").onCoreDataPermissionChanged).not.toHaveBeenCalled();
expect(require("./src/discussion/popup").initDiscussionPopup).not.toHaveBeenCalled();
});
test.each([
["a normal Firefox window", false, true],
["a Chrome incognito window", true, false],
])(
"preserves consent, account, discussion, links and settings initialization in %s",
async (_, privateBrowsing, firefox) => {
openPopup({ privateBrowsing, firefox });
await flush();
expect(document.getElementById("ryd-private-counts-message")).toBeNull();
expect(require("./src/consent-prompt").waitForCoreDataConsent).toHaveBeenCalledWith({ alwaysExplain: true });
expect(require("./src/discussion/popup").initDiscussionPopup).toHaveBeenCalledTimes(1);
expect(chrome.storage.sync.get).toHaveBeenCalledWith(
["patreonUser", "patreonSessionToken"],
expect.any(Function),
);
expect(chrome.storage.onChanged.addListener).toHaveBeenCalledTimes(1);
document.getElementById("link_website").click();
expect(chrome.tabs.create).toHaveBeenCalledTimes(1);
document.getElementById("disable_vote_submission").click();
expect(chrome.storage.sync.set).toHaveBeenCalledWith({ disableVoteSubmission: true });
document.getElementById("advancedToggle").click();
expect(document.getElementById("advancedSettings").style.pointerEvents).toBe("auto");
},
);
});
+22
View File
@@ -617,3 +617,25 @@ input:checked + .slider::before {
flex-direction: column;
align-items: center;
}
[dir="rtl"] .switch::before,
[dir="rtl"] .label-with-hover-tip::before {
left: auto;
right: 0;
}
[dir="rtl"] .switchLabel {
margin-left: 0;
margin-right: 0.5rem;
transform: translateX(-35px);
}
[dir="rtl"] #advancedToggle {
right: auto;
left: 26px;
}
[dir="rtl"] .setting-help-popover {
right: auto;
left: 0;
}
+1
View File
@@ -3,6 +3,7 @@
<!--- HEADER -->
<head>
<meta charset="utf-8" />
<script src="popup-context.js"></script>
<title title="__MSG_extensionName__">__MSG_extensionName__</title>
<link rel="stylesheet" href="popup.css" />
<link rel="stylesheet" href="content-style.css" />
+33 -3
View File
@@ -2,7 +2,9 @@ import { config as appConfig, getApiUrl, getApiEndpoint } from "./src/config.js"
import { getGitHubLoginErrorMessage } from "./src/github-login-error";
import { initDiscussionPopup } from "./src/discussion/popup";
import { waitForCoreDataConsent } from "./src/consent-prompt";
import { fetchWithCoreConsent, onCoreDataPermissionChanged } from "./src/core-data-consent";
import { onCoreDataPermissionChanged } from "./src/core-data-consent";
import { fetchWithAccountConsent } from "./src/account-data-consent";
import { isPrivateBrowsingContext } from "./src/private-browsing";
import { DEFAULT_CUSTOM_COLORS, getThemeColor, normalizeCustomColors } from "../common/color-themes";
import {
hasAuthenticationDataPermission,
@@ -38,6 +40,8 @@ let customColorsReady = false;
/* Change language */
function localizeHtmlPage() {
const direction = chrome.i18n.getMessage("@@bidi_dir");
if (direction === "rtl" || direction === "ltr") document.documentElement.dir = direction;
//Localize by replacing __MSG_***__ meta tags
var objects = document.getElementsByTagName("html");
for (var j = 0; j < objects.length; j++) {
@@ -55,7 +59,21 @@ function localizeHtmlPage() {
}
localizeHtmlPage();
const coreConsentReady = waitForCoreDataConsent({ alwaysExplain: true });
const privateBrowsing = isPrivateBrowsingContext();
const coreConsentReady = privateBrowsing ? null : waitForCoreDataConsent({ alwaysExplain: true });
if (privateBrowsing) {
const home = document.createElement("center");
const message = document.createElement("p");
message.id = "ryd-private-counts-message";
message.textContent =
"Counts only in private windows. Voting, sign-in, premium analytics and discussions are unavailable here.";
home.append(
document.querySelector("body > center > img").cloneNode(true),
document.querySelector("body > center > h1").cloneNode(true),
message,
);
document.body.replaceChildren(home);
} else {
coreConsentReady.then(() => initDiscussionPopup());
onCoreDataPermissionChanged((granted) => {
if (!granted) void waitForCoreDataConsent({ alwaysExplain: true });
@@ -69,6 +87,7 @@ createLink(config.links.faq, "link_faq");
createLink(config.links.donate, "link_donate");
createLink(config.links.help, "link_help");
createLink(config.links.changelog, "link_changelog");
}
function createLink(url, id) {
document.getElementById(id).addEventListener("click", () => {
@@ -76,6 +95,7 @@ function createLink(url, id) {
});
}
if (!privateBrowsing) {
document.getElementById("disable_vote_submission").addEventListener("click", (ev) => {
chrome.storage.sync.set({ disableVoteSubmission: ev.target.checked });
});
@@ -142,6 +162,7 @@ document.getElementById("hide_premium_teaser").addEventListener("click", (ev) =>
document.getElementById("hide_clutter_buttons").addEventListener("click", (ev) => {
chrome.storage.sync.set({ hideClutterButtons: ev.target.checked });
});
}
function initPatreonAuth() {
const loggedOutView = document.getElementById("patreon-logged-out");
@@ -252,7 +273,7 @@ function initPatreonAuth() {
return { status: "consent_required" };
}
const response = await fetchWithCoreConsent(getApiEndpoint("/api/auth/verify"), {
const response = await fetchWithAccountConsent(getApiEndpoint("/api/auth/verify"), {
method: "POST",
headers: {
"Content-Type": "application/json",
@@ -479,6 +500,13 @@ function initPatreonAuth() {
showLoggedOutView();
});
onCoreDataPermissionChanged((granted) => {
if (granted) return;
authenticationGeneration++;
chrome.storage.sync.remove(["patreonAuthenticated", "patreonUser", "patreonSessionToken"]);
showLoggedOutView();
});
logoutBtn.addEventListener("click", () => {
authenticationGeneration++;
showLoggedOutView();
@@ -487,6 +515,7 @@ function initPatreonAuth() {
});
}
if (!privateBrowsing) {
coreConsentReady.then(() => initPatreonAuth());
/* Advanced Toggle */
@@ -509,6 +538,7 @@ advancedToggle.addEventListener("click", () => {
chrome.storage.onChanged.addListener(storageChangeHandler);
initConfig();
}
function initConfig() {
initializeDisableVoteSubmission();
+39 -1
View File
@@ -13,10 +13,18 @@ const cachedUser = { fullName: "Test User", imageUrl: "https://example.org/avata
describe("popup account consent lifecycle", () => {
let readCachedSession;
let removeConsent;
let removeCoreConsent;
let consent;
beforeEach(async () => {
jest.resetModules();
const permissionRemovedListeners = new Set();
const coreConsentListeners = new Set();
require("./src/core-data-consent").onCoreDataPermissionChanged.mockImplementation((listener) => {
coreConsentListeners.add(listener);
return () => coreConsentListeners.delete(listener);
});
removeCoreConsent = () => [...coreConsentListeners].forEach((listener) => listener(false));
consent = true;
document.documentElement.innerHTML = fs.readFileSync(path.join(__dirname, "popup.html"), "utf8");
global.chrome = {
@@ -28,8 +36,13 @@ describe("popup account consent lifecycle", () => {
request: jest.fn(() => Promise.resolve(consent)),
onRemoved: {
addListener: (listener) => {
removeConsent = () => listener({ data_collection: ["authenticationInfo"] });
permissionRemovedListeners.add(listener);
removeConsent = () =>
[...permissionRemovedListeners].forEach((listener) =>
listener({ data_collection: ["authenticationInfo"] }),
);
},
removeListener: (listener) => permissionRemovedListeners.delete(listener),
},
},
identity: { getRedirectURL: jest.fn() },
@@ -135,6 +148,31 @@ describe("popup account consent lifecycle", () => {
expectLoggedOut();
});
test("does not restore an account when core consent is removed during the final account-permission query", async () => {
let resolvePermission;
fetch.mockResolvedValue({
json: async () => {
chrome.permissions.getAll.mockImplementationOnce(() => new Promise((resolve) => (resolvePermission = resolve)));
return { valid: true, membershipTier: "premium" };
},
});
readCachedSession({ patreonUser: cachedUser, patreonSessionToken: "test-token" });
await flushPromises();
expect(typeof resolvePermission).toBe("function");
removeCoreConsent();
resolvePermission({ data_collection: ["authenticationInfo"] });
await flushPromises();
expect(chrome.storage.sync.set).not.toHaveBeenCalled();
expect(chrome.storage.sync.remove).toHaveBeenCalledWith([
"patreonAuthenticated",
"patreonUser",
"patreonSessionToken",
]);
expectLoggedOut();
});
test.each(["revocation", "logout"])(
"does not restore an account or avatar from verification after %s",
async (event) => {
@@ -1,4 +1,9 @@
jest.mock("../common/vote-client", () => ({ createVoteClient: jest.fn() }));
jest.mock("./src/core-data-consent", () => ({
hasCoreDataPermission: jest.fn(),
onCoreDataPermissionChanged: jest.fn(),
fetchWithCoreConsent: jest.fn(),
}));
jest.mock("./src/data-collection-permissions", () => ({
hasAuthenticationDataPermission: jest.fn(),
onAuthenticationDataPermissionRemoved: jest.fn(),
@@ -14,9 +19,11 @@ describe("background changelog lifecycle", () => {
let startupListener;
let stored;
beforeEach(() => {
beforeEach(async () => {
jest.resetModules();
stored = {};
require("./src/data-collection-permissions").usesFirefoxDataCollectionConsent.mockReturnValue(false);
require("./src/core-data-consent").hasCoreDataPermission.mockResolvedValue(false);
require("../common/vote-client").createVoteClient.mockReturnValue({ ensureRegistered: () => Promise.resolve({}) });
global.__RYD_LIVE_TEST_BUILD__ = false;
global.chrome = {
@@ -54,6 +61,8 @@ describe("background changelog lifecycle", () => {
global.browser = global.chrome;
jest.spyOn(console, "debug").mockImplementation(() => {});
require("./ryd.background");
await flushCallbacks();
require("./src/core-data-consent").hasCoreDataPermission.mockClear();
});
afterEach(() => {
@@ -69,8 +78,68 @@ describe("background changelog lifecycle", () => {
expect(chrome.tabs.create).toHaveBeenCalledTimes(1);
expect(chrome.tabs.create).toHaveBeenCalledWith({ url: CHANGELOG_URL }, expect.any(Function));
expect(stored).toEqual({ lastShownChangelogVersion: VERSION });
expect(require("./src/core-data-consent").hasCoreDataPermission).not.toHaveBeenCalled();
});
test.each([false, true])(
"opens Firefox setup on a fresh install with missing consent (temporary=%s)",
async (temporary) => {
require("./src/data-collection-permissions").usesFirefoxDataCollectionConsent.mockReturnValue(true);
installedListener({ reason: "install", temporary });
await flushCallbacks();
expect(chrome.tabs.create).toHaveBeenCalledTimes(2);
expect(chrome.tabs.create).toHaveBeenCalledWith({ url: CHANGELOG_URL, active: false }, expect.any(Function));
expect(chrome.tabs.create).toHaveBeenCalledWith(
{ url: "moz-extension://test/consent.html" },
expect.any(Function),
);
expect(require("./src/core-data-consent").hasCoreDataPermission).toHaveBeenCalledWith({ queryBackground: false });
expect(stored).toEqual({ lastShownChangelogVersion: VERSION });
},
);
test("skips Firefox setup when core consent is already granted", async () => {
require("./src/data-collection-permissions").usesFirefoxDataCollectionConsent.mockReturnValue(true);
require("./src/core-data-consent").hasCoreDataPermission.mockResolvedValue(true);
installedListener({ reason: "install" });
await flushCallbacks();
expect(chrome.tabs.create).toHaveBeenCalledTimes(1);
expect(chrome.tabs.create).toHaveBeenCalledWith({ url: CHANGELOG_URL }, expect.any(Function));
});
test("a delayed changelog storage read does not take focus from Firefox setup", async () => {
require("./src/data-collection-permissions").usesFirefoxDataCollectionConsent.mockReturnValue(true);
let readChangelogStorage;
chrome.storage.local.get.mockImplementationOnce((keys, callback) => {
readChangelogStorage = callback;
});
installedListener({ reason: "install" });
await flushCallbacks();
expect(chrome.tabs.create).toHaveBeenCalledTimes(1);
expect(chrome.tabs.create).toHaveBeenLastCalledWith(
{ url: "moz-extension://test/consent.html" },
expect.any(Function),
);
readChangelogStorage({});
await flushCallbacks();
expect(chrome.tabs.create).toHaveBeenLastCalledWith({ url: CHANGELOG_URL, active: false }, expect.any(Function));
expect(stored).toEqual({ lastShownChangelogVersion: VERSION });
});
test.each(["update", "browser_update", "chrome_update"])(
"does not open Firefox permission setup on %s or subsequent startup",
async (reason) => {
require("./src/data-collection-permissions").usesFirefoxDataCollectionConsent.mockReturnValue(true);
stored.lastShownChangelogVersion = VERSION;
installedListener({ reason, previousVersion: "3.0.0.18" });
await flushCallbacks();
startupListener();
await flushCallbacks();
expect(chrome.tabs.create).not.toHaveBeenCalled();
expect(require("./src/core-data-consent").hasCoreDataPermission).not.toHaveBeenCalled();
},
);
test.each(["install", "update"])("preserves already-seen suppression on %s", async (reason) => {
stored.lastShownChangelogVersion = "4.0.4";
installedListener({ reason, temporary: true, previousVersion: "4.0.4" });
+48 -11
View File
@@ -3,6 +3,8 @@ import { createVoteClient } from "../common/vote-client";
import { createBrowserCredentialStore } from "./src/vote-client-adapter";
import { loginWithGitHub } from "./src/github-auth";
import { createDiscussionBackground } from "./src/discussion-background";
import { fetchWithAccountConsent } from "./src/account-data-consent";
import { isFirefoxExtension, isNormalMessageSender } from "./src/private-browsing";
import {
coreConsentError,
fetchWithCoreConsent,
@@ -58,6 +60,7 @@ const discussionBackground = createDiscussionBackground({
api,
voteClient,
fetchImpl: fetchWithCoreConsent,
accountFetchImpl: fetchWithAccountConsent,
hasConsent: async () =>
(await hasCoreDataPermission({ queryBackground: false })) &&
(!usesFirefoxDataCollectionConsent() || (await hasAuthenticationDataPermission({ queryBackground: false }))),
@@ -71,6 +74,7 @@ function broadcastPatreonStatus(authenticated, user, sessionToken, generation =
if (generation !== authenticationGeneration) return;
tabs
.filter((tab) => {
if (isFirefoxExtension(api) && tab.incognito !== false) return false;
try {
const hostname = new URL(tab.url).hostname;
return hostname === "youtube.com" || hostname.endsWith(".youtube.com");
@@ -245,6 +249,18 @@ function extractOAuthParams(responseUrl) {
}
api.runtime.onMessage.addListener((request, sender, sendResponse) => {
if (request?.message === "ryd_has_core_consent" || isNormalMessageSender(api, sender)) {
return handleMessage(request, sender, sendResponse);
}
sendResponse({
success: false,
ok: false,
granted: false,
error: { code: "private_browsing", message: "Only count display is available in private windows." },
});
});
function handleMessage(request, sender, sendResponse) {
if (request?.message === "ryd_open_consent") {
if (sender.id !== api.runtime.id || sender.url !== api.runtime.getURL("consent.html")) {
sendResponse({ success: false });
@@ -256,13 +272,16 @@ api.runtime.onMessage.addListener((request, sender, sendResponse) => {
if (consentPageTabId !== null) {
try {
const existing = await tabs.get(consentPageTabId);
if (existing.url === api.runtime.getURL("consent.html")) {
if (existing.url === api.runtime.getURL("consent.html") && existing.incognito === false) {
await tabs.update(consentPageTabId, { active: true });
return;
}
} catch (_) {}
}
const tab = await tabs.create({ url: api.runtime.getURL("consent.html") });
const tab = await tabs.create({
url: api.runtime.getURL("consent.html"),
...(Number.isInteger(sender.tab?.windowId) ? { windowId: sender.tab.windowId } : {}),
});
consentPageTabId = tab.id;
})().finally(() => {
consentPageOpening = null;
@@ -381,7 +400,7 @@ api.runtime.onMessage.addListener((request, sender, sendResponse) => {
? chrome.identity.getRedirectURL()
: "";
const startRes = await fetchWithCoreConsent(
const startRes = await fetchWithAccountConsent(
getApiEndpoint(`/api/auth/oauth/login?redirectUri=${encodeURIComponent(redirectUri)}`),
);
const startData = await startRes.json();
@@ -396,7 +415,7 @@ api.runtime.onMessage.addListener((request, sender, sendResponse) => {
await requireCurrentAuthenticationConsent(generation);
const exchangeRes = await fetchWithCoreConsent(getApiEndpoint("/api/auth/oauth/exchange"), {
const exchangeRes = await fetchWithAccountConsent(getApiEndpoint("/api/auth/oauth/exchange"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -448,7 +467,7 @@ api.runtime.onMessage.addListener((request, sender, sendResponse) => {
: "";
const authData = await loginWithGitHub({
fetchImpl: fetchWithCoreConsent,
fetchImpl: fetchWithAccountConsent,
redirectUri,
launchWebAuthFlow,
requireConsent: () => requireCurrentAuthenticationConsent(generation),
@@ -471,12 +490,12 @@ api.runtime.onMessage.addListener((request, sender, sendResponse) => {
})();
return true;
}
});
}
function openChangelogTab(version) {
function openChangelogTab(version, { active } = {}) {
try {
const url = getChangelogUrl();
api.tabs.create({ url }, () => {
api.tabs.create({ url, ...(active === false ? { active: false } : {}) }, () => {
if (api.runtime.lastError) {
console.debug("Changelog tab could not open:", api.runtime.lastError.message);
return;
@@ -574,7 +593,7 @@ function persistChangelogVersion(version) {
}
}
function maybeShowChangelog(details) {
function maybeShowChangelog(details, { active } = {}) {
const reason = details?.reason;
if (!reason) {
return;
@@ -595,7 +614,7 @@ function maybeShowChangelog(details) {
const showImmediately = reason === "install" || details.temporary === true;
const showChangelog = () => {
openChangelogTab(currentVersion || null);
openChangelogTab(currentVersion || null, { active });
};
if (!storage || typeof storage.get !== "function") {
@@ -646,8 +665,26 @@ function maybeShowChangelog(details) {
}
}
async function handleInstallation(details) {
const needsConsent =
details?.reason === "install" &&
usesFirefoxDataCollectionConsent() &&
!(await hasCoreDataPermission({ queryBackground: false }));
maybeShowChangelog(details, { active: needsConsent ? false : undefined });
if (!needsConsent) return;
try {
api.tabs.create({ url: api.runtime.getURL("consent.html") }, (tab) => {
if (api.runtime.lastError) return;
consentPageTabId = tab?.id ?? null;
});
} catch (error) {
console.debug("Failed to open permission setup", error);
}
}
api.runtime.onInstalled.addListener((details) => {
maybeShowChangelog(details);
void handleInstallation(details);
});
if (api?.runtime?.onStartup && typeof api.runtime.onStartup.addListener === "function") {
+97 -25
View File
@@ -6,6 +6,7 @@ jest.mock("./src/core-data-consent", () => ({
onCoreDataPermissionChanged: jest.fn(),
}));
const normalSender = { id: "extension-id", tab: { id: 1, incognito: false, windowId: 1 } };
const flushPromises = () => new Promise((resolve) => setTimeout(resolve, 0));
const startData = () => {
const loginUrl = fetch.mock.calls.filter(([url]) => url.includes("/github/login?")).at(-1)?.[0];
@@ -35,8 +36,10 @@ describe("background account consent lifecycle", () => {
beforeEach(() => {
jest.resetModules();
const permissionRemovedListeners = new Set();
consent = true;
require("./src/core-data-consent").hasCoreDataPermission.mockResolvedValue(true);
require("./src/core-data-consent").onCoreDataPermissionChanged.mockReturnValue(() => {});
originalCrypto = Object.getOwnPropertyDescriptor(globalThis, "crypto");
Object.defineProperty(globalThis, "crypto", {
configurable: true,
@@ -66,8 +69,13 @@ describe("background account consent lifecycle", () => {
contains: jest.fn().mockResolvedValue(true),
onRemoved: {
addListener: (listener) => {
removeConsent = () => listener({ data_collection: ["authenticationInfo"] });
permissionRemovedListeners.add(listener);
removeConsent = () =>
[...permissionRemovedListeners].forEach((listener) =>
listener({ data_collection: ["authenticationInfo"] }),
);
},
removeListener: (listener) => permissionRemovedListeners.delete(listener),
},
},
identity: {
@@ -83,10 +91,12 @@ describe("background account consent lifecycle", () => {
onChanged: { addListener: jest.fn() },
},
tabs: {
get: jest.fn(async () => ({ id: 9, url: "moz-extension://fixture/consent.html" })),
get: jest.fn(async () => ({ id: 9, url: "moz-extension://fixture/consent.html", incognito: false })),
create: jest.fn(async () => ({ id: 9 })),
update: jest.fn(async () => ({ id: 9 })),
query: jest.fn((query, callback) => callback([{ id: 1, url: "https://www.youtube.com/watch?v=video123456" }])),
query: jest.fn((query, callback) =>
callback([{ id: 1, url: "https://www.youtube.com/watch?v=video123456", incognito: false }]),
),
sendMessage: jest.fn(),
},
};
@@ -147,7 +157,7 @@ describe("background account consent lifecycle", () => {
"data:text/plain,youtube.com",
"blob:https://www.youtube.com/test",
];
const youtubeTabs = youtubeUrls.map((url, id) => ({ id, url }));
const youtubeTabs = youtubeUrls.map((url, id) => ({ id, url, incognito: false }));
const otherTabs = otherUrls.map((url, index) => ({ id: youtubeTabs.length + index, url }));
chrome.tabs.query.mockImplementation((query, callback) => callback([...otherTabs, ...youtubeTabs]));
const user = { fullName: "Test" };
@@ -159,7 +169,7 @@ describe("background account consent lifecycle", () => {
const statusMessages = () =>
chrome.tabs.sendMessage.mock.calls.filter(([, payload]) => payload.message === "patreon_status_changed");
messageListener({ message }, {}, response);
messageListener({ message }, normalSender, response);
await flushPromises();
expect(response).toHaveBeenCalledWith({ success: true, user });
@@ -172,7 +182,7 @@ describe("background account consent lifecycle", () => {
);
chrome.tabs.sendMessage.mockClear();
messageListener({ message: "patreon_logout" }, {}, jest.fn());
messageListener({ message: "patreon_logout" }, normalSender, jest.fn());
await flushPromises();
expect(statusMessages()).toEqual(
@@ -196,16 +206,16 @@ describe("background account consent lifecycle", () => {
ok: true,
json: async () => ({ success: true, user: { fullName: "Test" }, sessionToken: "test-token" }),
});
messageListener({ message: "patreon_oauth_login" }, {}, jest.fn());
messageListener({ message: "patreon_oauth_login" }, normalSender, jest.fn());
await flushPromises();
expect(completeQuery).toEqual(expect.any(Function));
if (event === "revocation") revoke();
else messageListener({ message: "patreon_logout" }, {}, jest.fn());
else messageListener({ message: "patreon_logout" }, normalSender, jest.fn());
await flushPromises();
const callsBeforeCompletion = chrome.tabs.sendMessage.mock.calls.slice();
completeQuery([{ id: 1, url: "https://www.youtube.com/watch?v=test" }]);
completeQuery([{ id: 1, url: "https://www.youtube.com/watch?v=test", incognito: false }]);
expect(chrome.tabs.sendMessage.mock.calls).toEqual(callsBeforeCompletion);
expect(chrome.tabs.sendMessage).toHaveBeenCalledWith(
@@ -225,9 +235,71 @@ describe("background account consent lifecycle", () => {
expect(chrome.storage.sync.set).not.toHaveBeenCalled();
});
test.each([
"send_vote",
"register",
"patreon_oauth_login",
"github_oauth_login",
"patreon_logout",
"ryd_discussion",
"set_state",
"send_links",
"ryd_open_tab",
"ryd_open_consent",
"ryd_has_authentication_consent",
])("private %s is rejected before shared work", async (message) => {
const response = jest.fn();
messageListener(
{
message,
videoId: "abcdefghijk",
vote: -1,
videoIds: Array(20).fill("abcdefghijk"),
url: "https://example.org/",
},
{ ...normalSender, tab: { id: 2, incognito: true }, url: "moz-extension://fixture/consent.html" },
response,
);
await flushPromises();
expect(response).toHaveBeenCalledWith(
expect.objectContaining({
success: false,
granted: false,
error: expect.objectContaining({ code: "private_browsing" }),
}),
);
const client = require("../common/vote-client").createVoteClient.mock.results[0].value;
expect(client.ensureRegistered).not.toHaveBeenCalled();
expect(client.getRegisteredUser).not.toHaveBeenCalled();
expect(client.submitVote).not.toHaveBeenCalled();
expect(fetch).not.toHaveBeenCalled();
expect(chrome.storage.sync.set).not.toHaveBeenCalled();
expect(chrome.storage.sync.remove).not.toHaveBeenCalled();
expect(chrome.tabs.create).not.toHaveBeenCalled();
expect(chrome.identity.launchWebAuthFlow).not.toHaveBeenCalled();
});
test("private tabs can read consent but receive no account broadcasts", async () => {
chrome.tabs.query.mockImplementation((query, callback) =>
callback([
{ id: 1, url: "https://www.youtube.com/watch?v=test", incognito: false },
{ id: 2, url: "https://www.youtube.com/watch?v=test", incognito: true },
]),
);
const response = jest.fn();
messageListener({ message: "ryd_has_core_consent" }, { id: "extension-id", tab: { incognito: true } }, response);
messageListener({ message: "patreon_logout" }, normalSender, jest.fn());
await flushPromises();
expect(response).toHaveBeenCalledWith({ granted: true });
const recipients = chrome.tabs.sendMessage.mock.calls
.filter(([, payload]) => payload.message === "patreon_status_changed")
.map(([id]) => id);
expect(recipients).toEqual([1]);
});
test.each([-1, 0, 1])("submits vote %s through the client that registers on demand", async (vote) => {
const response = jest.fn();
messageListener({ message: "send_vote", videoId: "video123456", vote }, {}, response);
messageListener({ message: "send_vote", videoId: "video123456", vote }, normalSender, response);
await flushPromises();
const voteClient = require("../common/vote-client").createVoteClient.mock.results[0].value;
expect(voteClient.submitVote).toHaveBeenCalledWith("video123456", vote);
@@ -239,7 +311,7 @@ describe("background account consent lifecycle", () => {
async (message) => {
require("./src/core-data-consent").hasCoreDataPermission.mockResolvedValue(false);
const response = jest.fn();
messageListener({ message, videoId: "video123456", vote: -1 }, {}, response);
messageListener({ message, videoId: "video123456", vote: -1 }, normalSender, response);
await flushPromises();
const client = require("../common/vote-client").createVoteClient.mock.results[0].value;
expect(client.ensureRegistered).not.toHaveBeenCalled();
@@ -251,7 +323,7 @@ describe("background account consent lifecycle", () => {
test("a standalone registration request does not create an ID", async () => {
const response = jest.fn();
messageListener({ message: "register" }, {}, response);
messageListener({ message: "register" }, normalSender, response);
await flushPromises();
expect(response).toHaveBeenCalledWith({ success: false, error: "vote_required" });
expect(
@@ -261,7 +333,7 @@ describe("background account consent lifecycle", () => {
test("only the explainer can open the consent page and repeated clicks reuse the tab", async () => {
const response = jest.fn();
const sender = { id: "extension-id", url: "moz-extension://fixture/consent.html" };
const sender = { ...normalSender, url: "moz-extension://fixture/consent.html" };
messageListener({ message: "ryd_open_consent" }, { ...sender, url: "https://www.youtube.com/" }, response);
expect(response).toHaveBeenCalledWith({ success: false });
expect(chrome.tabs.create).not.toHaveBeenCalled();
@@ -275,7 +347,7 @@ describe("background account consent lifecycle", () => {
});
test("reopens the explainer if its previous tab has navigated away", async () => {
const sender = { id: "extension-id", url: "moz-extension://fixture/consent.html" };
const sender = { ...normalSender, url: "moz-extension://fixture/consent.html" };
messageListener({ message: "ryd_open_consent" }, sender, jest.fn());
await flushPromises();
chrome.tabs.get.mockResolvedValue({ id: 9, url: "https://example.org/" });
@@ -290,7 +362,7 @@ describe("background account consent lifecycle", () => {
async (message) => {
consent = false;
const response = jest.fn();
messageListener({ message }, {}, response);
messageListener({ message }, normalSender, response);
await flushPromises();
expect(fetch).not.toHaveBeenCalled();
expect(chrome.identity.launchWebAuthFlow).not.toHaveBeenCalled();
@@ -303,7 +375,7 @@ describe("background account consent lifecycle", () => {
async (message) => {
chrome.permissions.getAll.mockResolvedValue({ permissions: ["identity"], origins: [] });
const response = jest.fn();
messageListener({ message }, {}, response);
messageListener({ message }, normalSender, response);
await flushPromises();
expect(fetch).not.toHaveBeenCalled();
expect(chrome.identity.launchWebAuthFlow).not.toHaveBeenCalled();
@@ -316,7 +388,7 @@ describe("background account consent lifecycle", () => {
async (message) => {
const start = deferred();
fetch.mockReturnValueOnce(start.promise);
messageListener({ message }, {}, jest.fn());
messageListener({ message }, normalSender, jest.fn());
await flushPromises();
revoke();
start.resolve({ ok: true, json: async () => startData() });
@@ -332,9 +404,9 @@ describe("background account consent lifecycle", () => {
const oauth = deferred();
fetch.mockResolvedValue({ ok: true, json: async () => startData() });
chrome.identity.launchWebAuthFlow.mockReturnValue(oauth.promise);
messageListener({ message }, {}, jest.fn());
messageListener({ message }, normalSender, jest.fn());
await flushPromises();
messageListener({ message: "patreon_logout" }, {}, jest.fn());
messageListener({ message: "patreon_logout" }, normalSender, jest.fn());
oauth.resolve("https://extension.example/callback?code=test&state=test");
await flushPromises();
expect(fetch).toHaveBeenCalledTimes(1);
@@ -348,7 +420,7 @@ describe("background account consent lifecycle", () => {
const exchange = deferred();
fetch.mockResolvedValueOnce({ ok: true, json: async () => startData() }).mockReturnValueOnce(exchange.promise);
const response = jest.fn();
messageListener({ message }, {}, response);
messageListener({ message }, normalSender, response);
await flushPromises();
expect(fetch).toHaveBeenCalledTimes(2);
revoke();
@@ -382,10 +454,10 @@ describe("background account consent lifecycle", () => {
json: async () => ({ success: true, user: { fullName: "Test" }, sessionToken: "test-token" }),
});
const response = jest.fn();
messageListener({ message: "patreon_oauth_login" }, {}, response);
messageListener({ message: "patreon_oauth_login" }, normalSender, response);
await flushPromises();
if (event === "revocation") revoke();
else messageListener({ message: "patreon_logout" }, {}, jest.fn());
else messageListener({ message: "patreon_logout" }, normalSender, jest.fn());
await flushPromises();
expect(chrome.storage.sync.remove).not.toHaveBeenCalled();
saved();
@@ -436,11 +508,11 @@ describe("background account consent lifecycle", () => {
ok: true,
json: async () => ({ success: true, user: { fullName: "New" }, sessionToken: "new-token" }),
});
messageListener({ message: "patreon_oauth_login" }, {}, jest.fn());
messageListener({ message: "patreon_oauth_login" }, normalSender, jest.fn());
await flushPromises();
messageListener({ message: "patreon_logout" }, {}, jest.fn());
messageListener({ message: "patreon_logout" }, normalSender, jest.fn());
const newResponse = jest.fn();
messageListener({ message: "github_oauth_login" }, {}, newResponse);
messageListener({ message: "github_oauth_login" }, normalSender, newResponse);
await flushPromises();
completeOldSave();
await flushPromises();
+41 -2
View File
@@ -21,6 +21,7 @@ import {
restoreCurrentState,
setInitialState,
storedData,
NEUTRAL_STATE,
} from "./src/state";
import { getBrowser, getVideoId, isVideoLoaded } from "./src/utils";
import {
@@ -42,6 +43,7 @@ import { initDiscussion } from "./src/discussion";
import { waitForCoreDataConsent } from "./src/consent-prompt";
import { onCoreDataPermissionChanged } from "./src/core-data-consent";
import { cancelObsoleteVoteDataRequests, clearVoteDataRequestCache } from "./src/vote-data-request";
import { isPrivateBrowsingContext } from "./src/private-browsing";
import {
actionBarHasHydratedData,
captureShortsNativeControlInventory,
@@ -70,10 +72,34 @@ let pendingShortsMutationSurface = null;
let shortsRouteEpoch = 0;
let observedRouteKey = null;
let coreConsentGranted = false;
let privatePageActive = true;
const privateBrowsingContext = isPrivateBrowsingContext();
const pendingNavigation = createPendingNavigationTracker();
const SHORTS_HYDRATION_ATTRIBUTE = "data-ryd-shorts-action-bar-hydrated";
const SHORTS_HYDRATION_REQUEST_EVENT = "ryd-shorts-action-bar-hydration-request";
function clearPrivateVoteState() {
clearVoteDataRequestCache();
Object.assign(storedData, { likes: 0, dislikes: 0, previousState: NEUTRAL_STATE, videoId: null });
}
if (privateBrowsingContext) {
window.addEventListener("pagehide", () => {
privatePageActive = false;
clearPrivateVoteState();
clearRenderedVoteState();
initializedVideoId = null;
isSetInitialStateDone = false;
if (jsInitChecktimer !== null) {
clearInterval(jsInitChecktimer);
jsInitChecktimer = null;
}
});
window.addEventListener("pageshow", () => {
privatePageActive = true;
});
}
function injectPageWorldHelpers() {
return new Promise((resolve) => {
const script = document.createElement("script");
@@ -107,6 +133,7 @@ onCoreDataPermissionChanged((granted) => {
coreConsentGranted = granted;
if (!granted) {
clearVoteDataRequestCache();
if (privateBrowsingContext) clearPrivateVoteState();
storedData.videoId = null;
initializedVideoId = null;
isSetInitialStateDone = false;
@@ -119,13 +146,17 @@ onCoreDataPermissionChanged((granted) => {
const pageWorldHelpersReady = injectPageWorldHelpers();
// Listen before asynchronous initialization so settings changed in another tab
// are applied while the page is still loading.
if (!privateBrowsingContext) {
getBrowser().storage.onChanged.addListener(storageChangeHandler);
isStorageListenerRegistered = true;
}
await initExtConfig();
observeColorTheme();
await pageWorldHelpersReady;
if (!privateBrowsingContext) {
initPatreonFeatures();
initDiscussion();
}
function ensureShortsNavigationObserver() {
if (!isShorts()) {
@@ -308,6 +339,7 @@ function getStableShortsControls(videoId) {
}
async function checkForInitialization() {
if (privateBrowsingContext && !privatePageActive) return;
if (!coreConsentGranted) return;
if (initializationCheckRunning) return;
initializationCheckRunning = true;
@@ -341,7 +373,7 @@ async function checkForInitialization() {
} else {
rendered = await setInitialState();
}
if (rendered === false || !coreConsentGranted) return;
if (rendered === false || !coreConsentGranted || (privateBrowsingContext && !privatePageActive)) return;
const currentControls = getButtonControls();
if (
videoId !== getVideoId(window.location.href) ||
@@ -369,13 +401,17 @@ async function checkForInitialization() {
clearInterval(jsInitChecktimer);
jsInitChecktimer = null;
}
if (!isStorageListenerRegistered) {
if (!privateBrowsingContext && !isStorageListenerRegistered) {
getBrowser().storage.onChanged.addListener(storageChangeHandler);
isStorageListenerRegistered = true;
}
}
} catch (exception) {
if (privateBrowsingContext) {
console.warn("Initialization failed; retrying when the current controls are ready.");
} else {
console.warn("Initialization failed; retrying when the current controls are ready.", exception);
}
} finally {
initializationCheckRunning = false;
}
@@ -405,6 +441,7 @@ const initializationCycle = createInitializationCycleRunner(async () => {
});
function triggerInitializationCycle() {
if (privateBrowsingContext && !privatePageActive) return Promise.resolve();
if (!coreConsentGranted) return Promise.resolve();
return initializationCycle.request();
}
@@ -415,6 +452,7 @@ async function setEventListeners() {
document.addEventListener("yt-navigate-start", function () {
clearVoteDataRequestCache();
if (privateBrowsingContext) clearPrivateVoteState();
shortsRouteEpoch += 1;
pendingShortsMutationSurface = null;
pendingNavigation.begin(() => {
@@ -523,6 +561,7 @@ function watchControlsNeedInitialization() {
}
setInterval(() => {
if (privateBrowsingContext && !privatePageActive) return;
if (!coreConsentGranted) return;
syncShortsRouteEpoch();
if (isShorts()) ensureShortsNavigationObserver();
@@ -0,0 +1,53 @@
import { fetchWithCoreConsent, onCoreDataPermissionChanged } from "./core-data-consent";
import {
hasAuthenticationDataPermission,
onAuthenticationDataPermissionRemoved,
usesFirefoxDataCollectionConsent,
} from "./data-collection-permissions";
function accountConsentError() {
return new Error("Authentication data consent required.");
}
function fetchWithAccountConsent(input, options = {}, fetchImpl = globalThis.fetch) {
if (!usesFirefoxDataCollectionConsent()) return fetchWithCoreConsent(input, options, fetchImpl);
return (async () => {
const controller = new AbortController();
const abort = () => controller.abort();
const unsubscribe = onAuthenticationDataPermissionRemoved(abort);
const unsubscribeCore = onCoreDataPermissionChanged((granted) => {
if (!granted) abort();
});
const api = typeof browser !== "undefined" ? browser : globalThis.chrome;
const messages = typeof api?.permissions?.onRemoved?.addListener === "function" ? null : api?.runtime?.onMessage;
const onMessage = (message) => {
if (message?.message === "patreon_status_changed" && message.authenticated === false) abort();
};
messages?.addListener?.(onMessage);
options.signal?.addEventListener("abort", abort, { once: true });
if (options.signal?.aborted) abort();
try {
const response = await fetchWithCoreConsent(
input,
{ ...options, signal: controller.signal },
async (requestInput, requestOptions) => {
// Check after the core-consent query, immediately before sending account data.
const granted = await hasAuthenticationDataPermission();
if (!granted || controller.signal.aborted || requestOptions.signal.aborted) throw accountConsentError();
return fetchImpl(requestInput, requestOptions);
},
);
if (!(await hasAuthenticationDataPermission()) || controller.signal.aborted) throw accountConsentError();
return response;
} finally {
unsubscribe();
unsubscribeCore();
messages?.removeListener?.(onMessage);
options.signal?.removeEventListener("abort", abort);
}
})();
}
export { fetchWithAccountConsent };
@@ -0,0 +1,222 @@
import { fetchWithAccountConsent } from "./account-data-consent";
import { CORE_DATA_PERMISSIONS } from "./core-data-consent";
const URL = "https://api.example/account";
const OPTIONS = { headers: { Authorization: "Bearer synthetic-token" } };
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
const deferred = () => {
let resolve;
const promise = new Promise((done) => (resolve = done));
return { promise, resolve };
};
function event() {
const listeners = new Set();
return {
addListener: (listener) => listeners.add(listener),
removeListener: (listener) => listeners.delete(listener),
emit: (value) => [...listeners].forEach((listener) => listener(value)),
size: () => listeners.size,
};
}
describe("account request consent boundary", () => {
let granted;
let fetchImpl;
beforeEach(() => {
granted = [...CORE_DATA_PERMISSIONS, "authenticationInfo"];
global.browser = {
runtime: {
getManifest: () => ({ browser_specific_settings: { gecko: { data_collection_permissions: {} } } }),
onMessage: event(),
sendMessage: jest.fn(),
},
permissions: {
getAll: jest.fn(async () => ({ data_collection: [...granted] })),
request: jest.fn(),
onAdded: event(),
onRemoved: event(),
},
};
fetchImpl = jest.fn(async () => new Response('{"valid":true}'));
});
afterEach(() => {
expect(browser.runtime.onMessage.size()).toBe(0);
if (browser.permissions) {
expect(browser.permissions.onAdded.size()).toBe(0);
expect(browser.permissions.onRemoved.size()).toBe(0);
}
delete global.browser;
});
function revokeAuthentication() {
granted = [...CORE_DATA_PERMISSIONS];
browser.permissions.onRemoved.emit({ data_collection: ["authenticationInfo"] });
}
test("granted account requests return their response without requesting permissions", async () => {
const response = await fetchWithAccountConsent(URL, OPTIONS, fetchImpl);
expect(await response.json()).toEqual({ valid: true });
expect(fetchImpl).toHaveBeenCalledWith(URL, expect.objectContaining(OPTIONS));
expect(browser.permissions.request).not.toHaveBeenCalled();
});
test.each(["missing", "unavailable"])("%s authentication consent blocks account traffic", async (state) => {
granted = [...CORE_DATA_PERMISSIONS];
if (state === "unavailable") {
browser.permissions.getAll
.mockResolvedValueOnce({ data_collection: [...CORE_DATA_PERMISSIONS] })
.mockRejectedValueOnce(new Error("Unavailable"));
}
await expect(fetchWithAccountConsent(URL, OPTIONS, fetchImpl)).rejects.toThrow("consent");
expect(fetchImpl).not.toHaveBeenCalled();
});
test("rechecks authentication after a delayed core query even without a removal event", async () => {
const coreCheck = deferred();
browser.permissions.getAll.mockReturnValueOnce(coreCheck.promise);
const pending = fetchWithAccountConsent(URL, OPTIONS, fetchImpl);
granted = [...CORE_DATA_PERMISSIONS];
coreCheck.resolve({ data_collection: [...CORE_DATA_PERMISSIONS, "authenticationInfo"] });
await expect(pending).rejects.toThrow("consent");
expect(fetchImpl).not.toHaveBeenCalled();
});
test.each([false, true])("revocation during a core query blocks traffic after regrant=%s", async (regrant) => {
const coreCheck = deferred();
browser.permissions.getAll.mockReturnValueOnce(coreCheck.promise);
const pending = fetchWithAccountConsent(URL, OPTIONS, fetchImpl);
revokeAuthentication();
if (regrant) granted.push("authenticationInfo");
coreCheck.resolve({ data_collection: [...granted] });
await expect(pending).rejects.toThrow("consent");
expect(fetchImpl).not.toHaveBeenCalled();
});
test.each(["authenticationInfo", CORE_DATA_PERMISSIONS[0]])(
"revoking %s during the final account query blocks a stale granting result",
async (permission) => {
const accountCheck = deferred();
browser.permissions.getAll
.mockResolvedValueOnce({ data_collection: [...granted] })
.mockReturnValueOnce(accountCheck.promise);
const pending = fetchWithAccountConsent(URL, OPTIONS, fetchImpl);
await flush();
browser.permissions.onRemoved.emit({ data_collection: [permission] });
accountCheck.resolve({ data_collection: [...granted] });
await expect(pending).rejects.toThrow("consent");
expect(fetchImpl).not.toHaveBeenCalled();
},
);
test("revocation aborts in-flight work and discards a body that finishes after regrant", async () => {
const body = deferred();
let signal;
fetchImpl.mockImplementationOnce(async (_, options) => {
signal = options.signal;
return { arrayBuffer: () => body.promise };
});
const pending = fetchWithAccountConsent(URL, OPTIONS, fetchImpl);
await flush();
revokeAuthentication();
granted.push("authenticationInfo");
expect(signal.aborted).toBe(true);
body.resolve(new ArrayBuffer(0));
await expect(pending).rejects.toThrow("consent");
});
test("a final permission check discards a completed response if removal events are unavailable", async () => {
fetchImpl.mockImplementationOnce(async () => {
granted = [...CORE_DATA_PERMISSIONS];
return new Response('{"valid":true}');
});
await expect(fetchWithAccountConsent(URL, OPTIONS, fetchImpl)).rejects.toThrow("consent");
expect(fetchImpl).toHaveBeenCalledTimes(1);
});
test("content requests observe account revocation while their background permission query is pending", async () => {
delete browser.permissions;
const accountCheck = deferred();
browser.runtime.sendMessage.mockImplementation(({ message }) =>
message === "ryd_has_core_consent" ? Promise.resolve({ granted: true }) : accountCheck.promise,
);
const pending = fetchWithAccountConsent(URL, OPTIONS, fetchImpl);
await flush();
browser.runtime.onMessage.emit({ message: "patreon_status_changed", authenticated: false });
accountCheck.resolve({ granted: true });
await expect(pending).rejects.toThrow("consent");
expect(fetchImpl).not.toHaveBeenCalled();
});
test("content revocation aborts a pending body and rejects its result", async () => {
delete browser.permissions;
browser.runtime.sendMessage.mockResolvedValue({ granted: true });
const body = deferred();
let signal;
fetchImpl.mockImplementationOnce(async (_, options) => {
signal = options.signal;
return { arrayBuffer: () => body.promise };
});
const pending = fetchWithAccountConsent(URL, OPTIONS, fetchImpl);
await flush();
browser.runtime.onMessage.emit({ message: "patreon_status_changed", authenticated: false });
expect(signal.aborted).toBe(true);
body.resolve(new ArrayBuffer(0));
await expect(pending).rejects.toThrow("consent");
});
test.each(["native", "content"])(
"core revocation during the final %s account query discards a buffered response",
async (context) => {
const accountCheck = deferred();
if (context === "native") {
browser.permissions.getAll
.mockResolvedValueOnce({ data_collection: [...granted] })
.mockResolvedValueOnce({ data_collection: [...granted] })
.mockResolvedValueOnce({ data_collection: [...granted] })
.mockReturnValueOnce(accountCheck.promise);
} else {
delete browser.permissions;
let accountQueries = 0;
browser.runtime.sendMessage.mockImplementation(({ message }) => {
if (message === "ryd_has_core_consent" || ++accountQueries === 1) return Promise.resolve({ granted: true });
return accountCheck.promise;
});
}
const pending = fetchWithAccountConsent(URL, OPTIONS, fetchImpl);
await flush();
expect(fetchImpl).toHaveBeenCalledTimes(1);
if (context === "native") {
browser.permissions.onRemoved.emit({ data_collection: [CORE_DATA_PERMISSIONS[0]] });
accountCheck.resolve({ data_collection: [...granted] });
} else {
browser.runtime.onMessage.emit({ message: "ryd_core_consent_changed", granted: false });
accountCheck.resolve({ granted: true });
}
await expect(pending).rejects.toThrow("consent");
},
);
test("caller cancellation prevents a pending account request from starting", async () => {
const accountCheck = deferred();
browser.permissions.getAll
.mockResolvedValueOnce({ data_collection: [...granted] })
.mockReturnValueOnce(accountCheck.promise);
const controller = new AbortController();
const pending = fetchWithAccountConsent(URL, { ...OPTIONS, signal: controller.signal }, fetchImpl);
await flush();
controller.abort();
accountCheck.resolve({ data_collection: [...granted] });
await expect(pending).rejects.toThrow("consent");
expect(fetchImpl).not.toHaveBeenCalled();
});
test("other browsers keep their ordinary request behavior", async () => {
browser.runtime.getManifest = () => ({});
await fetchWithAccountConsent(URL, OPTIONS, fetchImpl);
expect(fetchImpl).toHaveBeenCalledWith(URL, OPTIONS);
expect(browser.permissions.getAll).not.toHaveBeenCalled();
});
});
+1 -1
View File
@@ -12,7 +12,7 @@ describe("Firefox AMO policy declarations", () => {
const manifest = JSON.parse(readExtensionFile("manifest-firefox.json"));
const gecko = manifest.browser_specific_settings.gecko;
expect(manifest.incognito).toBe("not_allowed");
expect(manifest.incognito).toBe("spanning");
expect(gecko.id).toBe("{762f9885-5a13-4abd-9c77-433dcd38b8fd}");
expect(gecko.strict_min_version).toBe("140.0");
expect(gecko.data_collection_permissions).toEqual({
+2 -1
View File
@@ -1,3 +1,4 @@
import { logger } from "./logging";
import { getButtons, getDislikeButton, getLikeButton } from "./buttons";
import { extConfig, isMobile, isLikesDisabled, isNewDesign, isRoundedDesign, isShorts, isVideoLiked } from "./state";
import { getColorFromTheme, getElementWidth, getVideoId, isInViewport, querySelector } from "./utils";
@@ -192,7 +193,7 @@ function createRateBar(likes, dislikes) {
}
}
} else {
console.log("removing bar");
logger.log("removing bar");
if (rateBar) {
(rateBar.closest(".ryd-tooltip") ?? rateBar).remove();
}
+3
View File
@@ -1,5 +1,6 @@
import { isMobile, isShorts, extConfig } from "./state";
import { getVideoId, querySelector, querySelectorAll } from "./utils";
import { isPrivateBrowsingContext } from "./private-browsing";
import {
actionBarHasHydratedData,
getShortsIdentityLinkVideoIds,
@@ -310,6 +311,7 @@ function isSyntheticShortsDislike(dislikeButton) {
function setSyntheticShortsDislikePressed(pressed, dislikeButton = getDislikeButton()) {
if (!isSyntheticShortsDislike(dislikeButton)) return false;
if (isPrivateBrowsingContext()) pressed = false;
dislikeButton.classList.toggle("style-default-active", pressed);
dislikeButton.classList.toggle("style-text", !pressed);
@@ -319,6 +321,7 @@ function setSyntheticShortsDislikePressed(pressed, dislikeButton = getDislikeBut
function setSyntheticShortsDislikeEnabled(enabled, dislikeButton = getDislikeButton()) {
if (!isSyntheticShortsDislike(dislikeButton)) return false;
if (isPrivateBrowsingContext()) enabled = false;
const button = dislikeButton.querySelector("button");
if (!button) return false;
+44
View File
@@ -1,5 +1,11 @@
/** @jest-environment jsdom */
jest.mock("./private-browsing", () => ({
...jest.requireActual("./private-browsing"),
isPrivateBrowsingContext: jest.fn(),
}));
const { isPrivateBrowsingContext } = require("./private-browsing");
const { extConfig, refreshReactionPresentation } = require("./state");
const {
ensureSyntheticShortsDislikeButton,
@@ -94,6 +100,7 @@ function setNativeTextBoxes() {
describe("desktop watch button ownership", () => {
beforeEach(() => {
isPrivateBrowsingContext.mockReturnValue(false);
history.replaceState({}, "", "/watch?v=BBBBBBBBBBB");
extConfig.selectors.buttons.regular.desktopMenu = ["ytd-menu-renderer.ytd-watch-metadata > div"];
extConfig.selectors.buttons.regular.desktopNoMenu = ["#top-level-buttons-computed"];
@@ -686,6 +693,43 @@ describe("desktop watch button ownership", () => {
expect(dislike.classList.contains("style-default-active")).toBe(true);
});
test("keeps a private synthetic Shorts count disabled and unpressed through direct setters", () => {
isPrivateBrowsingContext.mockReturnValue(true);
history.replaceState({}, "", "/shorts/BBBBBBBBBBB");
document.body.innerHTML = `
<ytd-reel-video-renderer video-id="BBBBBBBBBBB" is-active>
<reel-action-bar-view-model>
<like-button-view-model><button aria-pressed="true"><span role="text">100</span></button></like-button-view-model>
<button-view-model><button type="button">Comments</button></button-view-model>
</reel-action-bar-view-model>
</ytd-reel-video-renderer>`;
const actionBar = document.querySelector("reel-action-bar-view-model");
setBox(actionBar);
const dislike = ensureSyntheticShortsDislikeButton(actionBar, {
currentVideoId: "BBBBBBBBBBB",
isHydrated: true,
isStable: true,
});
const nativeLike = actionBar.querySelector("like-button-view-model button");
const nativeClick = jest.fn();
nativeLike.addEventListener("click", nativeClick);
const syntheticClick = jest.fn();
dislike.querySelector("button").addEventListener("click", syntheticClick);
setSyntheticShortsDislikeEnabled(true, dislike);
setSyntheticShortsDislikePressed(true, dislike);
dislike.querySelector("button").click();
expect(dislike.querySelector("button").disabled).toBe(true);
expect(dislike.querySelector("button").getAttribute("aria-disabled")).toBe("true");
expect(dislike.querySelector("button").getAttribute("aria-pressed")).toBe("false");
expect(dislike.classList.contains("style-default-active")).toBe(false);
expect(getButtonControls(actionBar).ready).toBe(true);
expect(syntheticClick).not.toHaveBeenCalled();
expect(nativeClick).not.toHaveBeenCalled();
expect(nativeLike.getAttribute("aria-pressed")).toBe("true");
});
test("does not mutate an offscreen URL-matching Shorts placeholder before activation", () => {
history.replaceState({}, "", "/shorts/BBBBBBBBBBB");
document.body.innerHTML = `
@@ -26,6 +26,8 @@ function applyLocaleMetadata() {
if (browserLocale) {
document.documentElement.lang = browserLocale;
}
const direction = chrome?.i18n?.getMessage?.("@@bidi_dir");
if (direction === "rtl" || direction === "ltr") document.documentElement.dir = direction;
} catch (error) {
console.debug("Unable to resolve UI locale", error);
}
@@ -0,0 +1,54 @@
/** @jest-environment jsdom */
const fs = require("node:fs");
const path = require("node:path");
jest.mock("../utils", () => ({
getBrowser: () => "Chrome",
localize: (key) => require("../../_locales/en/messages.json")[key]?.message || "",
}));
const { initChangelogPage } = require("./index");
describe("release page locale metadata", () => {
beforeEach(() => {
jest.spyOn(document, "readyState", "get").mockReturnValue("complete");
document.documentElement.removeAttribute("lang");
document.documentElement.removeAttribute("dir");
document.documentElement.innerHTML = fs.readFileSync(
path.join(__dirname, "../../changelog/4/changelog_4.1.html"),
"utf8",
);
});
afterEach(() => {
jest.restoreAllMocks();
delete global.chrome;
document.documentElement.removeAttribute("lang");
document.documentElement.removeAttribute("dir");
});
test.each([
["fa", "rtl"],
["en", "ltr"],
])("uses %s language and %s direction while localizing the release page", (locale, direction) => {
global.chrome = { i18n: { getMessage: (key) => ({ "@@ui_locale": locale, "@@bidi_dir": direction })[key] } };
initChangelogPage();
expect(document.documentElement.lang).toBe(locale);
expect(document.documentElement.dir).toBe(direction);
expect(document.querySelector("h1").textContent).not.toContain("__MSG_");
expect(document.documentElement.innerHTML).not.toContain("__MSG_");
});
test.each(["", "auto"])("preserves document metadata when locale metadata is %j", (metadata) => {
document.documentElement.lang = "en";
document.documentElement.dir = "ltr";
global.chrome = { i18n: { getMessage: (key) => (key === "@@bidi_dir" ? metadata : "") } };
initChangelogPage();
expect(document.documentElement.lang).toBe("en");
expect(document.documentElement.dir).toBe("ltr");
});
});
@@ -1,5 +1,6 @@
import { consentApi, hasCoreDataPermission, onCoreDataPermissionChanged } from "./core-data-consent";
import { usesFirefoxDataCollectionConsent } from "./data-collection-permissions";
import { isPrivateBrowsingContext } from "./private-browsing";
let pendingConsent = null;
@@ -10,9 +11,11 @@ function waitForCoreDataConsent({ alwaysExplain = false } = {}) {
if (await hasCoreDataPermission()) return;
const api = consentApi();
let dismissed = false;
if (!isPrivateBrowsingContext()) {
try {
dismissed = (await api.storage.local.get("rydConsentDismissed"))?.rydConsentDismissed === true;
} catch (_) {}
}
await new Promise((resolve) => {
const host = document.createElement("div");
host.id = "ryd-consent-prompt";
+42 -1
View File
@@ -5,6 +5,7 @@ jest.mock("./core-data-consent", () => ({
onCoreDataPermissionChanged: jest.fn(),
}));
jest.mock("./data-collection-permissions", () => ({ usesFirefoxDataCollectionConsent: jest.fn() }));
jest.mock("./private-browsing", () => ({ isPrivateBrowsingContext: jest.fn() }));
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
describe("consent overlay lifecycle", () => {
@@ -16,7 +17,8 @@ describe("consent overlay lifecycle", () => {
jest.resetModules();
core = require("./core-data-consent");
require("./data-collection-permissions").usesFirefoxDataCollectionConsent.mockReturnValue(true);
local = { get: jest.fn(async () => ({})) };
require("./private-browsing").isPrivateBrowsingContext.mockReturnValue(false);
local = { get: jest.fn(async () => ({})), set: jest.fn(), remove: jest.fn() };
core.consentApi.mockReturnValue({
storage: { local },
runtime: { getURL: () => "moz-extension://fixture/consent.html" },
@@ -96,4 +98,43 @@ describe("consent overlay lifecycle", () => {
changed(true);
await pending;
});
test("private setup skips stored dismissal state and dismisses only the current overlay", async () => {
require("./private-browsing").isPrivateBrowsingContext.mockReturnValue(true);
local.get.mockResolvedValue({ rydConsentDismissed: true });
const pending = waitForConsent();
let granted = false;
void pending.then(() => (granted = true));
const { frame, host } = await mounted();
expect(host.hasAttribute("collapsed")).toBe(false);
expect(local.get).not.toHaveBeenCalled();
message(frame, "ryd-consent-dismissed");
await flush();
expect(host.hasAttribute("collapsed")).toBe(true);
expect(granted).toBe(false);
host.shadowRoot.querySelector("button").click();
expect(host.hasAttribute("collapsed")).toBe(false);
expect(local.set).not.toHaveBeenCalled();
expect(local.remove).not.toHaveBeenCalled();
core.hasCoreDataPermission.mockResolvedValue(true);
changed(true);
await pending;
expect(host.isConnected).toBe(false);
});
test.each([false, true])(
"an existing grant skips setup and stored dismissal state (private=%s)",
async (privateBrowsing) => {
require("./private-browsing").isPrivateBrowsingContext.mockReturnValue(privateBrowsing);
local.get.mockResolvedValue({ rydConsentDismissed: true });
core.hasCoreDataPermission.mockResolvedValue(true);
await waitForConsent();
expect(document.querySelector("#ryd-consent-prompt")).toBeNull();
expect(core.consentApi).not.toHaveBeenCalled();
expect(core.onCoreDataPermissionChanged).not.toHaveBeenCalled();
expect(local.get).not.toHaveBeenCalled();
expect(local.set).not.toHaveBeenCalled();
expect(local.remove).not.toHaveBeenCalled();
},
);
});
@@ -0,0 +1,199 @@
/** @jest-environment jsdom */
import { readFileSync } from "fs";
import { join } from "path";
import { transformSync } from "@babel/core";
import * as initializationCycle from "./initialization-cycle";
const source = readFileSync(join(__dirname, "../ryd.content-script.js"), "utf8");
const executable = transformSync(source, {
babelrc: false,
configFile: false,
plugins: [
({ types }) => ({
visitor: {
ImportDeclaration(path) {
const bindings = path.node.specifiers.map(({ imported, local }) =>
types.objectProperty(imported, local, false, imported.name === local.name),
);
path.replaceWith(
types.variableDeclaration("const", [
types.variableDeclarator(
types.objectPattern(bindings),
types.memberExpression(types.identifier("dependencies"), path.node.source, true),
),
]),
);
},
},
}),
],
}).code;
const execute = new Function(
"dependencies",
"window",
"document",
"setInterval",
"clearInterval",
"setTimeout",
"clearTimeout",
"__RYD_LIVE_TEST_BUILD__",
`return (async () => { ${executable} })();`,
);
function harness(privateContext) {
const page = document.implementation.createHTMLDocument();
const pageWindow = new EventTarget();
pageWindow.location = new URL("https://www.youtube.com/watch?v=abcdefghijk");
const buttons = page.createElement("div");
const likeButton = page.createElement("div");
const dislikeButton = page.createElement("div");
const nativeLikeButton = page.createElement("button");
const nativeDislikeButton = page.createElement("button");
likeButton.append(nativeLikeButton);
dislikeButton.append(nativeDislikeButton);
buttons.append(likeButton, dislikeButton);
page.body.append(buttons);
const controls = {
buttons,
dislikeButton,
dislikeTextContainer: page.createElement("span"),
likeButton,
nativeDislikeButton,
nativeLikeButton,
ready: true,
};
const storedData = { likes: 0, dislikes: 0, videoId: null, previousState: "NEUTRAL_STATE" };
const storage = { onChanged: { addListener: jest.fn() } };
const state = {
NEUTRAL_STATE: "NEUTRAL_STATE",
storedData,
clearRenderedVoteState: jest.fn(),
hasLoadedStateForVideo: (videoId) => storedData.videoId === videoId,
initExtConfig: jest.fn(async () => undefined),
isLikesDisabled: () => false,
isShorts: () => false,
restoreCurrentState: jest.fn(() => true),
setInitialState: jest.fn(async () => {
Object.assign(storedData, { likes: 100, dislikes: 10, videoId: "abcdefghijk" });
return true;
}),
};
let permissionChanged;
const buttonFunctions = {
getButtonControls: () => controls,
getDislikeTextContainer: () => controls.dislikeTextContainer,
hasRenderedBox: () => true,
markButtonsForVideo: jest.fn(),
setSyntheticShortsDislikeEnabled: jest.fn(),
};
const dependencies = {
"./src/buttons": buttonFunctions,
"./src/state": state,
"./src/utils": {
getBrowser: () => ({ storage, runtime: { getURL: (path) => path } }),
getVideoId: () => "abcdefghijk",
isVideoLoaded: () => true,
},
"./src/events": {
addLikeDislikeEventListener: jest.fn(),
createSmartimationObserver: jest.fn(),
storageChangeHandler: jest.fn(),
observeColorTheme: jest.fn(),
},
"./src/bar": { hasUsableRateBar: () => true },
"./src/initialization-cycle": initializationCycle,
"./src/patreon": { initPatreonFeatures: jest.fn() },
"./src/discussion": { initDiscussion: jest.fn() },
"./src/consent-prompt": { waitForCoreDataConsent: jest.fn(async () => true) },
"./src/core-data-consent": {
onCoreDataPermissionChanged: (listener) => {
permissionChanged = listener;
},
},
"./src/vote-data-request": {
cancelObsoleteVoteDataRequests: jest.fn(),
clearVoteDataRequestCache: jest.fn(),
},
"./src/private-browsing": { isPrivateBrowsingContext: () => privateContext },
"../common/shorts-control-readiness": {},
};
const append = page.head.appendChild.bind(page.head);
page.head.appendChild = (script) => {
append(script);
script.onload.call(script);
return script;
};
return {
buttonFunctions,
dependencies,
page,
pageWindow,
permissionChanged: (granted) => permissionChanged(granted),
run: () => execute(dependencies, pageWindow, page, setInterval, clearInterval, setTimeout, clearTimeout, false),
state,
storage,
};
}
const flush = async () => {
for (let index = 0; index < 20; index++) await Promise.resolve();
};
beforeEach(() => jest.useFakeTimers());
afterEach(() => {
jest.clearAllTimers();
jest.useRealTimers();
});
test.each([false, true])(
"initializes count display with context-specific account and storage setup (private=%s)",
async (privateContext) => {
const context = harness(privateContext);
await context.run();
expect(context.state.initExtConfig).toHaveBeenCalledTimes(1);
expect(context.state.setInitialState).toHaveBeenCalledTimes(1);
expect(context.buttonFunctions.markButtonsForVideo).toHaveBeenCalledTimes(1);
expect(context.storage.onChanged.addListener).toHaveBeenCalledTimes(privateContext ? 0 : 1);
expect(context.dependencies["./src/patreon"].initPatreonFeatures).toHaveBeenCalledTimes(privateContext ? 0 : 1);
expect(context.dependencies["./src/discussion"].initDiscussion).toHaveBeenCalledTimes(privateContext ? 0 : 1);
},
);
test.each(["pagehide", "yt-navigate-start", "consent-loss"])(
"clears private document count state on %s",
async (eventName) => {
const context = harness(true);
await context.run();
const requests = context.dependencies["./src/vote-data-request"];
requests.clearVoteDataRequestCache.mockClear();
if (eventName === "consent-loss") context.permissionChanged(false);
else (eventName === "pagehide" ? context.pageWindow : context.page).dispatchEvent(new Event(eventName));
expect(requests.clearVoteDataRequestCache).toHaveBeenCalled();
expect(context.state.storedData).toEqual({ likes: 0, dislikes: 0, previousState: "NEUTRAL_STATE", videoId: null });
expect(context.state.clearRenderedVoteState).toHaveBeenCalled();
},
);
test("private page exit suppresses late initialization and requests a fresh state on return", async () => {
const context = harness(true);
let resolveInitialState;
context.state.setInitialState.mockImplementationOnce(() => new Promise((resolve) => (resolveInitialState = resolve)));
const initialized = context.run();
await flush();
expect(context.state.setInitialState).toHaveBeenCalledTimes(1);
context.pageWindow.dispatchEvent(new Event("pagehide"));
resolveInitialState(true);
await initialized;
jest.advanceTimersByTime(1000);
await flush();
expect(context.buttonFunctions.markButtonsForVideo).not.toHaveBeenCalled();
expect(context.buttonFunctions.setSyntheticShortsDislikeEnabled).not.toHaveBeenCalled();
expect(context.state.setInitialState).toHaveBeenCalledTimes(1);
context.pageWindow.dispatchEvent(new Event("pageshow"));
jest.advanceTimersByTime(500);
await flush();
expect(context.state.setInitialState).toHaveBeenCalledTimes(2);
expect(context.buttonFunctions.markButtonsForVideo).toHaveBeenCalledTimes(1);
});
@@ -1,4 +1,6 @@
import { usesFirefoxDataCollectionConsent } from "./data-collection-permissions";
import { getApiUrl } from "./config";
import { isPrivateBrowsingContext } from "./private-browsing";
const CORE_DATA_PERMISSIONS = Object.freeze([
"personallyIdentifyingInfo",
@@ -32,6 +34,7 @@ async function hasCoreDataPermission({ queryBackground = true } = {}) {
}
function requestCoreDataPermission() {
if (isPrivateBrowsingContext()) return Promise.resolve(false);
if (!usesFirefoxDataCollectionConsent()) return Promise.resolve(true);
try {
// Request directly in the explainer's click handler to preserve Firefox's user gesture.
@@ -72,6 +75,37 @@ function onCoreDataPermissionChanged(listener) {
function fetchWithCoreConsent(input, options = {}, fetchImpl = globalThis.fetch) {
if (!usesFirefoxDataCollectionConsent()) return fetchImpl(input, options);
return (async () => {
if (isPrivateBrowsingContext()) {
const url = new URL(typeof input === "string" || input instanceof URL ? input : input.url);
const method = options.method ?? input?.method ?? "GET";
const allowedKeys = url.pathname === "/votes" ? ["videoId", "likeCount"] : [];
let allowedQuery = true;
// Firefox content-script wrappers do not expose URLSearchParams iterators.
url.searchParams.forEach((value, key) => {
if (!allowedKeys.includes(key)) allowedQuery = false;
});
if (
url.origin !== new URL(getApiUrl()).origin ||
!["/votes", "/configs/selectors"].includes(url.pathname) ||
method.toUpperCase() !== "GET" ||
url.username ||
url.password ||
!allowedQuery ||
options.body != null ||
input?.body != null
)
throw new Error("Only count display is available in private windows.");
input = url.href;
options = {
method: "GET",
headers: { Accept: "application/json" },
credentials: "omit",
cache: "no-store",
redirect: "error",
referrerPolicy: "no-referrer",
signal: options.signal,
};
}
const controller = new AbortController();
const abort = () => controller.abort();
const unsubscribe = onCoreDataPermissionChanged((granted) => {
@@ -135,6 +135,75 @@ describe("optional Firefox core data consent", () => {
expect(browser.runtime.onMessage.size()).toBe(0);
});
test("private counts strip credentials and headers and disable caching and redirects", async () => {
granted = [...CORE_DATA_PERMISSIONS];
browser.extension = { inIncognitoContext: true };
const fetchImpl = jest.fn(async () => new Response('{"dislikes":42}'));
for (const path of ["/votes?videoId=abcdefghijk&likeCount=100", "/configs/selectors"]) {
const result = await fetchWithCoreConsent(
`https://returnyoutubedislikeapi.com${path}`,
{ credentials: "include", cache: "force-cache", headers: { Authorization: "synthetic-account-token" } },
fetchImpl,
);
expect(await result.json()).toEqual({ dislikes: 42 });
expect(fetchImpl.mock.calls.at(-1)[1]).toEqual(
expect.objectContaining({
method: "GET",
credentials: "omit",
cache: "no-store",
redirect: "error",
referrerPolicy: "no-referrer",
headers: { Accept: "application/json" },
}),
);
}
expect(await requestCoreDataPermission()).toBe(false);
expect(browser.permissions.request).not.toHaveBeenCalled();
});
test("private count checks work when Firefox blocks URLSearchParams iterators", async () => {
granted = [...CORE_DATA_PERMISSIONS];
browser.extension = { inIncognitoContext: true };
const keys = jest.spyOn(URLSearchParams.prototype, "keys").mockReturnValue({
[Symbol.iterator]() {
throw new TypeError("Iterator access denied");
},
});
try {
const fetchImpl = jest.fn(async () => new Response('{"dislikes":42}'));
const response = await fetchWithCoreConsent(
"https://returnyoutubedislikeapi.com/votes?videoId=abcdefghijk&likeCount=100",
{},
fetchImpl,
);
expect(await response.json()).toEqual({ dislikes: 42 });
await expect(
fetchWithCoreConsent(
"https://returnyoutubedislikeapi.com/votes?videoId=abcdefghijk&sessionToken=synthetic",
{},
fetchImpl,
),
).rejects.toThrow("count display");
expect(fetchImpl).toHaveBeenCalledTimes(1);
} finally {
keys.mockRestore();
}
});
test.each([
["https://returnyoutubedislikeapi.com/send", {}],
["https://returnyoutubedislikeapi.com/votes", { method: "POST" }],
["https://returnyoutubedislikeapi.com/votes?sessionToken=synthetic", {}],
["https://returnyoutubedislikeapi.com/configs/selectors?videoId=abcdefghijk", {}],
["https://api.example/votes", {}],
])("private requests reject non-display operations: %s", async (url, options) => {
granted = [...CORE_DATA_PERMISSIONS];
browser.extension = { inIncognitoContext: true };
const fetchImpl = jest.fn();
await expect(fetchWithCoreConsent(url, options, fetchImpl)).rejects.toThrow("count display");
expect(fetchImpl).not.toHaveBeenCalled();
});
test("other browsers keep their normal fetch and do not request Firefox permissions", async () => {
browser.runtime.getManifest = () => ({ manifest_version: 3 });
const response = {};
@@ -1,3 +1,5 @@
import { isPrivateBrowsingContext } from "./private-browsing";
const AUTHENTICATION_DATA_PERMISSION = "authenticationInfo";
const ACCOUNT_DATA_PERMISSIONS = Object.freeze([AUTHENTICATION_DATA_PERMISSION]);
const AUTHENTICATION_DATA_DESCRIPTOR = Object.freeze({
@@ -51,6 +53,7 @@ function queryBackgroundForAuthenticationDataPermission() {
}
function hasAuthenticationDataPermission({ queryBackground = true } = {}) {
if (isPrivateBrowsingContext()) return Promise.resolve(false);
if (!usesFirefoxDataCollectionConsent()) return Promise.resolve(true);
const permissions = getPermissionsApi();
if (!permissions) {
@@ -71,6 +74,7 @@ function hasAuthenticationDataPermission({ queryBackground = true } = {}) {
}
function requestAuthenticationDataPermission() {
if (isPrivateBrowsingContext()) return Promise.resolve(false);
if (!usesFirefoxDataCollectionConsent()) return Promise.resolve(true);
// Keep this direct request as the first operation in the login click stack.
@@ -56,6 +56,21 @@ describe("Firefox data-collection permissions", () => {
expect(request).not.toHaveBeenCalled();
});
it("private windows cannot query or request account consent", async () => {
global.browser = {
extension: { inIncognitoContext: true },
runtime: { getManifest: () => firefoxManifest, sendMessage: jest.fn() },
permissions: { request: jest.fn(), getAll: jest.fn() },
};
await expect(hasAuthenticationDataPermission()).resolves.toBe(false);
await expect(requestAuthenticationDataPermission()).resolves.toBe(false);
expect(browser.permissions.getAll).not.toHaveBeenCalled();
expect(browser.permissions.request).not.toHaveBeenCalled();
delete browser.permissions;
await expect(hasAuthenticationDataPermission()).resolves.toBe(false);
expect(browser.runtime.sendMessage).not.toHaveBeenCalled();
});
it.each([{}, { data_collection: [] }, { data_collection: ["financialAndPaymentInfo"] }])(
"does not accept a successful request without a recorded authentication grant: %j",
async (grantedPermissions) => {
@@ -1,6 +1,7 @@
import { HubConnectionBuilder, HttpTransportType, LogLevel } from "@microsoft/signalr";
import { createVoteClient, solvePuzzle } from "../../common/vote-client";
import { discussionConfig } from "./discussion-config";
import { isFirefoxExtension, isNormalMessageSender } from "./private-browsing";
const ANONYMOUS_SESSION = Object.freeze({
authenticated: false,
@@ -205,6 +206,7 @@ export function createDiscussionBackground({
api,
voteClient,
fetchImpl = (...args) => fetch(...args),
accountFetchImpl = fetchImpl,
hasConsent = async () => true,
settings = discussionConfig,
connectionFactory = createConnection,
@@ -303,7 +305,7 @@ export function createDiscussionBackground({
scope?.controllers.add(controller);
const timeout = setTimeout(() => controller.abort(), 20000);
try {
const response = await fetchImpl(`${origin}/api/discussion${path}`, {
const response = await (auth ? accountFetchImpl : fetchImpl)(`${origin}/api/discussion${path}`, {
method,
credentials: "omit",
cache: "no-store",
@@ -359,22 +361,58 @@ export function createDiscussionBackground({
type === "toast" ? { active: true, lastFocusedWindow: true } : {},
);
if (expected !== generation || stopped) return;
const targets = (tabs || []).filter((tab) => isYouTubePage(tab.url));
const firefox = isFirefoxExtension(api);
const targets = (tabs || []).filter((tab) => isYouTubePage(tab.url) && (!firefox || tab.incognito === false));
await Promise.allSettled(
(type === "toast" ? targets.slice(0, 1) : targets).map((tab) =>
callBrowser(api, api.tabs, "sendMessage", tab.id, message),
),
);
if (type !== "toast" && expected === generation) {
if (firefox) {
try {
const extensionUrl = api.runtime.getURL("");
for (const view of api.extension?.getViews?.() || []) {
try {
if (
view.browser?.extension?.inIncognitoContext === false &&
view.location?.href?.startsWith(extensionUrl)
)
view.dispatchEvent(new view.CustomEvent("ryd-discussion-event", { detail: structuredClone(message) }));
} catch (_) {}
}
} catch (_) {}
} else {
await callBrowser(api, api.runtime, "sendMessage", message).catch(() => {});
}
}
}
function badgeText() {
return inbox.unreadCount ? String(Math.min(inbox.unreadCount, 99)) + (inbox.unreadCount > 99 ? "+" : "") : "";
}
async function updateTabBadge(tab, expected = generation) {
if (expected !== generation || stopped || tab?.incognito !== false || !Number.isInteger(tab.id)) return;
const action = api.action || api.browserAction;
await callBrowser(api, action, "setBadgeText", { tabId: tab.id, text: badgeText() }).catch(() => {});
if (expected === generation && !stopped)
await callBrowser(api, action, "setBadgeBackgroundColor", { tabId: tab.id, color: "#cc0000" }).catch(() => {});
}
async function updateBadge(expected = generation) {
if (expected !== generation || stopped) return;
const action = api.action || api.browserAction;
if (isFirefoxExtension(api)) {
await callBrowser(api, action, "setBadgeText", { text: "" }).catch(() => {});
if (expected !== generation || stopped) return;
const tabs = await callBrowser(api, api.tabs, "query", {});
if (expected !== generation || stopped) return;
await Promise.allSettled((tabs || []).map((tab) => updateTabBadge(tab, expected)));
return;
}
await callBrowser(api, action, "setBadgeText", {
text: inbox.unreadCount ? String(Math.min(inbox.unreadCount, 99)) + (inbox.unreadCount > 99 ? "+" : "") : "",
text: badgeText(),
}).catch(() => {});
if (expected === generation)
await callBrowser(api, action, "setBadgeBackgroundColor", { color: "#cc0000" }).catch(() => {});
@@ -706,6 +744,7 @@ export function createDiscussionBackground({
async function handleMessage(requestMessage, sender) {
try {
if (!isNormalMessageSender(api, sender)) throw new DiscussionError("private_browsing");
const { operation, payload, scope: scopeValue } = validateRequest(api, requestMessage, sender);
const scope = scopeFor(scopeValue, sender);
if (operation === "cancelScope") {
@@ -745,6 +784,11 @@ export function createDiscussionBackground({
}
function initialize() {
if (isFirefoxExtension(api)) {
api.tabs?.onCreated?.addListener((tab) => updateTabBadge(tab).catch(() => {}));
api.tabs?.onUpdated?.addListener((tabId, change, tab) => updateTabBadge(tab).catch(() => {}));
updateBadge().catch(() => {});
}
api.tabs?.onRemoved?.addListener((tabId) => {
for (const [key, scope] of navigationScopes)
if (scope.tabId === tabId) {
@@ -32,6 +32,17 @@ const deferred = () => {
return { promise, resolve };
};
function extensionView(incognito, url = "moz-extension://extension-id/popup.html") {
return {
browser: { extension: { inIncognitoContext: incognito } },
location: { href: url },
CustomEvent: jest.fn(function (type, options) {
return { type, detail: options.detail };
}),
dispatchEvent: jest.fn(),
};
}
function storageArea(data = {}) {
return {
data,
@@ -47,16 +58,27 @@ function storageArea(data = {}) {
};
}
function harness({ authenticated = false, consent = true, fetchOverride, testMode = false, solveOverride } = {}) {
function harness({
authenticated = false,
consent = true,
fetchOverride,
accountFetchImpl,
testMode = false,
solveOverride,
firefox = false,
views = [],
} = {}) {
const sync = storageArea(authenticated ? { patreonAuthenticated: true, patreonSessionToken: "account-token" } : {});
const local = storageArea();
let alarmListener;
const api = {
runtime: {
id: "extension-id",
getURL: (path) => `chrome-extension://extension-id/${path}`,
getURL: (path) => `${firefox ? "moz" : "chrome"}-extension://extension-id/${path}`,
getManifest: () => (firefox ? { browser_specific_settings: { gecko: {} } } : {}),
sendMessage: jest.fn((message, callback) => callback?.()),
},
extension: { getViews: jest.fn(() => views) },
storage: { sync, local },
action: {
setBadgeText: jest.fn((value, callback) => callback?.()),
@@ -65,12 +87,14 @@ function harness({ authenticated = false, consent = true, fetchOverride, testMod
tabs: {
query: jest.fn((query, callback) =>
callback([
{ id: 1, url: sender.url },
{ id: 2, url: sender.url },
{ id: 3, url: "https://example.com/" },
{ id: 1, url: sender.url, incognito: false },
{ id: 2, url: sender.url, incognito: false },
{ id: 3, url: "https://example.com/", incognito: false },
]),
),
sendMessage: jest.fn((id, message, callback) => callback?.()),
onCreated: { addListener: jest.fn() },
onUpdated: { addListener: jest.fn() },
},
alarms: {
create: jest.fn(),
@@ -168,6 +192,7 @@ function harness({ authenticated = false, consent = true, fetchOverride, testMod
api,
voteClient,
fetchImpl,
accountFetchImpl,
hasConsent: async () => consent,
connectionFactory,
solve: solveOverride || jest.fn(async () => ({ solution: "AAAAAA==" })),
@@ -206,7 +231,242 @@ function harness({ authenticated = false, consent = true, fetchOverride, testMod
};
}
describe("Firefox private discussion isolation", () => {
const normalSender = { ...sender, tab: { ...sender.tab, incognito: false } };
test.each(["session", "capabilities", "comments", "react", "notifications", "cancelScope"])(
"rejects private %s before validation, account reads, or shared work",
async (operation) => {
const h = harness({ authenticated: true, firefox: true });
const source = { ...normalSender, tab: { ...normalSender.tab, incognito: true } };
expect(await h.send(operation, { invalid: true }, source)).toMatchObject({
ok: false,
error: { code: "private_browsing" },
});
expect(h.sync.get).not.toHaveBeenCalled();
expect(h.local.get).not.toHaveBeenCalled();
expect(h.fetchImpl).not.toHaveBeenCalled();
expect(h.voteClient.getRegisteredUser).not.toHaveBeenCalled();
expect(h.voteClient.ensureRegistered).not.toHaveBeenCalled();
expect(h.api.tabs.sendMessage).not.toHaveBeenCalled();
expect(h.api.action.setBadgeText).not.toHaveBeenCalled();
},
);
test("rejects a tab whose normal browsing context cannot be verified", async () => {
const h = harness({ authenticated: true, firefox: true });
expect(await h.send("session", {}, sender)).toMatchObject({ error: { code: "private_browsing" } });
expect(h.sync.get).not.toHaveBeenCalled();
expect(h.fetchImpl).not.toHaveBeenCalled();
});
test.each([true, undefined])(
"rejects a private or unverified extension view (%s) before account reads",
async (incognito) => {
const url = "moz-extension://extension-id/popup.html?rydContext=private-test-context";
const h = harness({ authenticated: true, firefox: true, views: [extensionView(incognito, url)] });
expect(await h.send("session", {}, { id: sender.id, url })).toMatchObject({
ok: false,
error: { code: "private_browsing" },
});
expect(h.sync.get).not.toHaveBeenCalled();
expect(h.fetchImpl).not.toHaveBeenCalled();
},
);
test("preserves account requests from a verified normal Firefox extension view", async () => {
const url = "moz-extension://extension-id/popup.html?rydContext=normal-test-context";
const h = harness({ authenticated: true, firefox: true, views: [extensionView(false, url)] });
expect((await h.send("session", {}, { id: sender.id, url })).data.authenticated).toBe(true);
h.client.stop();
});
test("delivers shared account events only to normal YouTube tabs and verified extension views", async () => {
const normal = extensionView(false);
const privateView = extensionView(true);
const unknown = extensionView(undefined);
const foreign = extensionView(false, "https://www.youtube.com/watch?v=video123456");
const unavailable = extensionView(false);
unavailable.dispatchEvent.mockImplementation(() => {
throw new Error("View closed");
});
const h = harness({
authenticated: true,
firefox: true,
views: [privateView, unknown, foreign, unavailable, normal],
});
h.api.tabs.query.mockImplementation((query, done) =>
done([
{ id: 2, url: sender.url, incognito: true },
{ id: 1, url: sender.url, incognito: false },
{ id: 3, url: sender.url },
]),
);
expect((await h.send("session", {}, normalSender)).data.authenticated).toBe(true);
await flush();
expect(h.api.tabs.sendMessage.mock.calls.length).toBeGreaterThan(0);
expect(h.api.tabs.sendMessage.mock.calls.every(([tabId]) => tabId === 1)).toBe(true);
expect(
normal.dispatchEvent.mock.calls.some(
([event]) => event.detail.type === "session" && event.detail.data.authenticated,
),
).toBe(true);
expect(
normal.dispatchEvent.mock.calls.some(
([event]) => event.detail.type === "inbox" && event.detail.data.unreadCount === 1,
),
).toBe(true);
expect(normal.CustomEvent).toHaveBeenCalledWith(
"ryd-discussion-event",
expect.objectContaining({ detail: expect.any(Object) }),
);
for (const view of [privateView, unknown, foreign]) {
expect(view.CustomEvent).not.toHaveBeenCalled();
expect(view.dispatchEvent).not.toHaveBeenCalled();
}
expect(h.api.runtime.sendMessage).not.toHaveBeenCalled();
h.client.stop();
});
test("isolates each Firefox view's session and inbox mutations from the worker and other views", async () => {
const first = extensionView(false);
const second = extensionView(false);
first.dispatchEvent.mockImplementation(({ detail }) => {
if (detail.type === "session" && detail.data.profile) detail.data.profile.nickname = "Changed in one view";
if (detail.type === "inbox" && detail.data.items.length) {
detail.data.items[0].read = true;
detail.data.items[0].actorNickname = "Changed in one view";
}
});
const h = harness({ authenticated: true, firefox: true, views: [first, second] });
const response = await h.send("session", {}, normalSender);
await flush();
expect(response.data.profile.nickname).toBe("Tester");
expect((await h.send("session", {}, normalSender)).data.profile.nickname).toBe("Tester");
const sessionEvent = second.dispatchEvent.mock.calls.find(
([event]) => event.detail.type === "session" && event.detail.data.authenticated,
)[0];
expect(sessionEvent.detail.data.profile.nickname).toBe("Tester");
const inboxEvent = second.dispatchEvent.mock.calls.find(
([event]) => event.detail.type === "inbox" && event.detail.data.items.length,
)[0];
expect(inboxEvent.detail.data.items[0]).toMatchObject({ read: false, actorNickname: null });
expect(h.local.data.discussionState.items[0]).toMatchObject({ read: false, actorNickname: null });
h.client.stop();
});
test("never falls back to global runtime account events when extension views are unavailable", async () => {
const h = harness({ authenticated: true, firefox: true });
h.api.extension.getViews.mockImplementation(() => {
throw new Error("Unavailable");
});
expect((await h.send("session", {}, normalSender)).data.authenticated).toBe(true);
await flush();
expect(h.api.runtime.sendMessage).not.toHaveBeenCalled();
expect(h.api.tabs.sendMessage.mock.calls.some(([, event]) => event.type === "inbox")).toBe(true);
h.client.stop();
});
test("does not send account toasts to an active private YouTube tab", async () => {
const h = harness({ authenticated: true, firefox: true });
h.api.tabs.query.mockImplementation((query, done) =>
done(
query.active ? [{ id: 2, url: sender.url, incognito: true }] : [{ id: 1, url: sender.url, incognito: false }],
),
);
await h.send("session", {}, normalSender);
await flush();
h.setInbox({
items: [{ ...h.notification, revision: "2", likeCount: 2 }],
unreadCount: 1,
revision: "2",
nextCursor: null,
});
h.sockets[0].handlers.NotificationsChanged({ revision: "2" });
await flush();
await flush();
expect(h.api.tabs.sendMessage.mock.calls.some(([, event]) => event.type === "toast")).toBe(false);
expect(h.api.tabs.sendMessage.mock.calls.some(([tabId]) => tabId === 2)).toBe(false);
h.client.stop();
});
test("keeps the default badge empty and applies account counts only to normal tabs, including new tabs", async () => {
const h = harness({ authenticated: true, firefox: true });
h.api.tabs.query.mockImplementation((query, done) =>
done([
{ id: 1, url: sender.url, incognito: false },
{ id: 2, url: sender.url, incognito: true },
{ id: 3, url: "https://example.com/", incognito: false },
{ id: 4, url: sender.url },
]),
);
h.client.initialize();
await flush();
await flush();
const badgeValues = h.api.action.setBadgeText.mock.calls.map(([value]) => value);
expect(badgeValues).toContainEqual({ text: "" });
expect(badgeValues).toContainEqual({ tabId: 1, text: "1" });
expect(badgeValues).toContainEqual({ tabId: 3, text: "1" });
expect(
badgeValues.every((value) => (value.tabId === undefined ? value.text === "" : [1, 3].includes(value.tabId))),
).toBe(true);
expect(h.api.action.setBadgeBackgroundColor.mock.calls.every(([value]) => [1, 3].includes(value.tabId))).toBe(true);
h.api.action.setBadgeText.mockClear();
h.api.action.setBadgeBackgroundColor.mockClear();
const created = h.api.tabs.onCreated.addListener.mock.calls[0][0];
const updated = h.api.tabs.onUpdated.addListener.mock.calls[0][0];
await created({ id: 5, incognito: true });
await updated(5, {}, { id: 5, incognito: true });
await created({ id: 6 });
expect(h.api.action.setBadgeText).not.toHaveBeenCalled();
expect(h.api.action.setBadgeBackgroundColor).not.toHaveBeenCalled();
await created({ id: 7, incognito: false });
await updated(8, {}, { id: 8, incognito: false });
expect(h.api.action.setBadgeText.mock.calls.map(([value]) => value)).toEqual([
{ tabId: 7, text: "1" },
{ tabId: 8, text: "1" },
]);
h.client.clearSession();
await flush();
expect(h.api.action.setBadgeText).toHaveBeenCalledWith({ tabId: 1, text: "" }, expect.any(Function));
h.client.stop();
});
test("preserves non-Firefox incognito discussion requests and global account updates", async () => {
const h = harness({ authenticated: true });
const response = await h.send("session", {}, { ...sender, tab: { ...sender.tab, incognito: true } });
expect(response.data.authenticated).toBe(true);
await flush();
expect(
h.api.runtime.sendMessage.mock.calls.some(([event]) => event.type === "session" && event.data.authenticated),
).toBe(true);
expect(h.api.action.setBadgeText).toHaveBeenCalledWith({ text: "1" }, expect.any(Function));
expect(h.api.extension.getViews).not.toHaveBeenCalled();
h.client.stop();
});
});
describe("discussion worker boundary", () => {
test("authenticated discussion traffic uses the account guard while anonymous reads keep their ordinary path", async () => {
const accountFetchImpl = jest.fn(async () => {
throw new Error("Authentication data consent required.");
});
const authenticated = harness({ authenticated: true, accountFetchImpl });
expect((await authenticated.send("session")).ok).toBe(false);
expect(accountFetchImpl).toHaveBeenCalledWith(
`${ORIGIN}/api/discussion/me`,
expect.objectContaining({ headers: expect.objectContaining({ Authorization: "Bearer account-token" }) }),
);
expect(authenticated.fetchImpl).not.toHaveBeenCalled();
accountFetchImpl.mockClear();
const anonymous = harness({ accountFetchImpl });
expect((await anonymous.send("comments", { videoId: VIDEO_ID, sort: "new" })).ok).toBe(true);
expect(anonymous.fetchImpl).toHaveBeenCalled();
expect(accountFetchImpl).not.toHaveBeenCalled();
});
test("public comments do not create a voter identity before the first reaction", async () => {
const h = harness();
h.voteClient.getRegisteredUser.mockResolvedValue(null);
@@ -0,0 +1,31 @@
/**
* @jest-environment jsdom
* @jest-environment-options {"url":"https://www.youtube.com/watch?v=abcdefghijk"}
*/
import { subscribeDiscussionEvents } from "./client";
afterEach(() => {
delete global.browser;
});
test("Firefox content clients ignore page-level account events and retain tab messages", () => {
const onMessage = { addListener: jest.fn(), removeListener: jest.fn() };
global.browser = {
extension: { inIncognitoContext: false },
runtime: {
getManifest: () => ({ browser_specific_settings: { gecko: {} } }),
getURL: (path) => `moz-extension://extension-id/${path}`,
onMessage,
},
};
const callback = jest.fn();
const unsubscribe = subscribeDiscussionEvents(callback);
const message = { message: "ryd_discussion_event", type: "session", data: { authenticated: true } };
window.dispatchEvent(new CustomEvent("ryd-discussion-event", { detail: message }));
expect(callback).not.toHaveBeenCalled();
const receive = onMessage.addListener.mock.calls[0][0];
receive(message);
expect(callback).toHaveBeenCalledWith(message);
unsubscribe();
expect(onMessage.removeListener).toHaveBeenCalledWith(receive);
});
@@ -1,9 +1,15 @@
import { isFirefoxExtension, isPrivateBrowsingContext } from "../private-browsing";
function runtimeApi() {
return typeof chrome !== "undefined" ? chrome.runtime : globalThis.browser?.runtime;
}
export function requestDiscussion(operation, payload = {}, scope) {
return new Promise((resolve, reject) => {
if (isPrivateBrowsingContext()) {
reject(Object.assign(new Error("Discussion service unavailable"), { code: "private_browsing" }));
return;
}
const runtime = runtimeApi();
if (!runtime?.sendMessage) {
reject(Object.assign(new Error("Discussion service unavailable"), { code: "unavailable" }));
@@ -79,6 +85,22 @@ export function createDiscussionRequestScope(videoId, request = requestDiscussio
}
export function subscribeDiscussionEvents(listener) {
if (isPrivateBrowsingContext()) return () => {};
const api = typeof browser !== "undefined" ? browser : globalThis.chrome;
const extensionUrl = api?.runtime?.getURL?.("");
if (
isFirefoxExtension(api) &&
typeof window !== "undefined" &&
window.location.protocol === "moz-extension:" &&
extensionUrl &&
window.location.href.startsWith(extensionUrl)
) {
const receive = (event) => {
if (event.detail?.message === "ryd_discussion_event") listener(event.detail);
};
window.addEventListener("ryd-discussion-event", receive);
return () => window.removeEventListener("ryd-discussion-event", receive);
}
const event = runtimeApi()?.onMessage;
const receive = (message) => {
if (message?.message === "ryd_discussion_event") listener(message);
@@ -1,8 +1,93 @@
/** @jest-environment jsdom */
/**
* @jest-environment jsdom
* @jest-environment-options {"url":"moz-extension://extension-id/popup.html"}
*/
import { createDiscussionRequestScope, requestDiscussion, subscribeDiscussionEvents } from "./client";
function firefoxApi(incognito = false) {
return {
extension: { inIncognitoContext: incognito },
runtime: {
getManifest: () => ({ browser_specific_settings: { gecko: {} } }),
getURL: (path) => `moz-extension://extension-id/${path}`,
sendMessage: jest.fn((message, done) => done({ ok: true, data: { authenticated: true } })),
onMessage: { addListener: jest.fn(), removeListener: jest.fn() },
},
};
}
afterEach(() => {
delete global.chrome;
delete global.browser;
});
test("private Firefox requests fail before reaching the shared runtime", async () => {
global.browser = firefoxApi(true);
await expect(requestDiscussion("session")).rejects.toMatchObject({ code: "private_browsing" });
await expect(requestDiscussion("comments", { videoId: "abcdefghijk" })).rejects.toMatchObject({
code: "private_browsing",
});
expect(global.browser.runtime.sendMessage).not.toHaveBeenCalled();
});
test("private Firefox clients do not subscribe to either account event channel", () => {
global.browser = firefoxApi(true);
const addEventListener = jest.spyOn(window, "addEventListener");
const callback = jest.fn();
const unsubscribe = subscribeDiscussionEvents(callback);
window.dispatchEvent(
new CustomEvent("ryd-discussion-event", { detail: { message: "ryd_discussion_event", type: "inbox" } }),
);
expect(callback).not.toHaveBeenCalled();
expect(global.browser.runtime.onMessage.addListener).not.toHaveBeenCalled();
expect(addEventListener).not.toHaveBeenCalled();
unsubscribe();
addEventListener.mockRestore();
});
test("normal Firefox requests retain the runtime request channel", async () => {
global.browser = firefoxApi();
await expect(requestDiscussion("session")).resolves.toEqual({ authenticated: true });
expect(global.browser.runtime.sendMessage).toHaveBeenCalledTimes(1);
});
test("normal Firefox extension pages receive account events from their own view and unsubscribe", () => {
global.browser = firefoxApi();
const callback = jest.fn();
const unsubscribe = subscribeDiscussionEvents(callback);
window.dispatchEvent(new CustomEvent("ryd-discussion-event", { detail: { message: "unrelated" } }));
expect(callback).not.toHaveBeenCalled();
const data = { message: "ryd_discussion_event", type: "inbox", data: { unreadCount: 3 } };
window.dispatchEvent(new CustomEvent("ryd-discussion-event", { detail: data }));
expect(callback).toHaveBeenCalledWith(data);
expect(global.browser.runtime.onMessage.addListener).not.toHaveBeenCalled();
unsubscribe();
window.dispatchEvent(new CustomEvent("ryd-discussion-event", { detail: data }));
expect(callback).toHaveBeenCalledTimes(1);
});
test("does not accept view events on a different extension's page", () => {
global.browser = firefoxApi();
global.browser.runtime.getURL = (path) => `moz-extension://different-extension/${path}`;
const callback = jest.fn();
const unsubscribe = subscribeDiscussionEvents(callback);
window.dispatchEvent(new CustomEvent("ryd-discussion-event", { detail: { message: "ryd_discussion_event" } }));
expect(callback).not.toHaveBeenCalled();
const receive = global.browser.runtime.onMessage.addListener.mock.calls[0][0];
const message = { message: "ryd_discussion_event", type: "session", data: { authenticated: false } };
receive(message);
expect(callback).toHaveBeenCalledWith(message);
unsubscribe();
expect(global.browser.runtime.onMessage.removeListener).toHaveBeenCalledWith(receive);
});
test("preserves non-Firefox private request behavior", async () => {
global.chrome = {
extension: { inIncognitoContext: true },
runtime: { getManifest: () => ({}), sendMessage: jest.fn((message, done) => done({ ok: true, data: {} })) },
};
await expect(requestDiscussion("session")).resolves.toEqual({});
expect(global.chrome.runtime.sendMessage).toHaveBeenCalledTimes(1);
});
test("uses a typed worker envelope and returns only the response data", async () => {
@@ -4,10 +4,12 @@ import { discussionLanguage, discussionText as t } from "./localize";
import { createDiscussionPanel } from "./panel";
import { showDiscussionToast } from "./inbox";
import { button, element, discussionLinkTarget, isDiscussionCommentId } from "./dom";
import { isPrivateBrowsingContext } from "../private-browsing";
let installed = null;
export function initDiscussion({ request = requestDiscussion } = {}) {
if (isPrivateBrowsingContext()) return null;
if (installed) return installed;
let panel = null;
let panelScope = null;
@@ -3,7 +3,13 @@
* @jest-environment-options {"url":"https://www.youtube.com/watch?v=abcdefghijk"}
*/
jest.mock("../private-browsing", () => ({
...jest.requireActual("../private-browsing"),
isPrivateBrowsingContext: jest.fn(),
}));
import { initDiscussion } from "./index";
import { isPrivateBrowsingContext } from "../private-browsing";
import { NATIVE_STATE_REQUEST, NATIVE_STATE_ATTRIBUTE } from "./detector";
import { threadUrl } from "./dom";
import { discussionText } from "./localize";
@@ -20,6 +26,7 @@ let listener;
let eventListener;
beforeEach(() => {
isPrivateBrowsingContext.mockReturnValue(false);
jest.useFakeTimers();
document.body.innerHTML = `<ytd-watch-flexy video-id="${VIDEO}"><ytd-comments></ytd-comments></ytd-watch-flexy>`;
window.history.replaceState({}, "", `/watch?v=${VIDEO}`);
@@ -42,6 +49,24 @@ beforeEach(() => {
);
document.addEventListener(NATIVE_STATE_REQUEST, listener, true);
});
test("private discussion initialization creates no requests, bridge, UI or subscriptions", () => {
isPrivateBrowsingContext.mockReturnValue(true);
const privateRequest = jest.fn();
const append = jest.spyOn(document.head, "append");
const timer = jest.spyOn(global, "setInterval");
try {
expect(initDiscussion({ request: privateRequest })).toBeNull();
expect(privateRequest).not.toHaveBeenCalled();
expect(append).not.toHaveBeenCalled();
expect(timer).not.toHaveBeenCalled();
expect(document.querySelector(".ryd-discussion-panel")).toBeNull();
expect(eventListener).toBeUndefined();
} finally {
append.mockRestore();
timer.mockRestore();
}
});
afterEach(() => {
feature?.destroy();
document.removeEventListener(NATIVE_STATE_REQUEST, listener, true);
+11 -3
View File
@@ -75,6 +75,7 @@ export function createDiscussionPanel({
let activeTab = "discussion";
const drafts = new Map();
const postingIds = new Map();
let draftGeneration = 0;
const rootReplyHandlers = new Map();
let moderationSequence = 0;
let reportCursor = null;
@@ -237,6 +238,8 @@ export function createDiscussionPanel({
if (submit.disabled) return;
const text = input.value.trim();
if (!text || text.length > maxLength) return;
const accountSnapshot = draftGeneration;
const stillCurrent = () => current() && accountSnapshot === draftGeneration;
submit.disabled = true;
input.disabled = true;
message.textContent = t("saving");
@@ -247,7 +250,7 @@ export function createDiscussionPanel({
? { id: comment.id, text }
: { videoId, text, requestId, ...(parentId ? { parentId, replyToId } : {}) };
const result = await request(comment ? "editComment" : "createComment", payload);
if (!current()) return;
if (!stillCurrent()) return;
drafts.delete(key);
postingIds.delete(key);
if (onSaved) onSaved(result);
@@ -258,9 +261,9 @@ export function createDiscussionPanel({
await load();
}
} catch (error) {
if (current()) message.textContent = discussionError(error);
if (stillCurrent()) message.textContent = discussionError(error);
} finally {
if (current()) {
if (stillCurrent()) {
input.disabled = false;
submit.disabled = !input.value.trim();
}
@@ -676,6 +679,11 @@ export function createDiscussionPanel({
setSession(next) {
const previousIdentity = session.authenticated ? session.profile?.id || "authenticated" : "anonymous";
const nextIdentity = next?.authenticated ? next.profile?.id || "authenticated" : "anonymous";
if (previousIdentity !== nextIdentity) {
draftGeneration++;
drafts.clear();
postingIds.clear();
}
accountGeneration++;
moderationSequence++;
session = next || SIGNED_OUT;
@@ -3,6 +3,101 @@
import { createDiscussionPanel } from "./panel";
import { discussionText } from "./localize";
const otherSession = {
authenticated: true,
canPost: true,
isModerator: false,
profile: { id: "55555555-5555-4555-8555-555555555555", nickname: "Other writer" },
};
function identityForm(root, kind) {
if (kind === "root") return root.querySelector(".ryd-discussion-composer form");
root.querySelector(`[data-action='${kind === "edit" ? "edit" : "reply"}']`).click();
return root.querySelector(".ryd-discussion-inline-editor form");
}
function typeIdentityDraft(form, text) {
const input = form.querySelector("textarea");
input.value = text;
input.dispatchEvent(new Event("input", { bubbles: true }));
}
test.each(["root", "reply", "edit"])("identity change discards the previous account's %s draft", async (kind) => {
const root = await mount({ session });
typeIdentityDraft(identityForm(root, kind), "Private draft for first account");
panel.setSession(otherSession);
await flush();
expect(root.querySelector(".ryd-discussion-composer textarea").value).toBe("");
expect(root.querySelector("[data-action='edit']")).toBeNull();
panel.setSession(session);
await flush();
expect(identityForm(root, kind).querySelector("textarea").value).toBe(kind === "edit" ? "First comment" : "");
});
test("logout prevents a root draft returning after signing in again", async () => {
const root = await mount({ session });
typeIdentityDraft(identityForm(root, "root"), "First account draft");
panel.setSession({ authenticated: false, canPost: false, profile: null });
await flush();
panel.setSession(session);
await flush();
expect(identityForm(root, "root").querySelector("textarea").value).toBe("");
});
test("same-account refresh preserves a failed submission's retry ID", async () => {
Object.defineProperty(globalThis.crypto, "randomUUID", {
configurable: true,
value: jest
.fn()
.mockReturnValueOnce("44444444-4444-4444-8444-444444444444")
.mockReturnValue("66666666-6666-4666-8666-666666666666"),
});
const root = await mount({ session });
request.mockRejectedValueOnce({ code: "unavailable" });
submit(identityForm(root, "root"), "Retry my own draft");
await flush();
panel.setSession({ ...session });
await flush();
request.mockResolvedValueOnce(comment({ text: "Retry my own draft" }));
identityForm(root, "root").dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
await flush();
const creates = request.mock.calls.filter(([operation]) => operation === "createComment");
expect(creates).toHaveLength(2);
expect(creates[1][1].requestId).toBe(creates[0][1].requestId);
});
test.each(["root", "reply", "edit"])("same-account refresh preserves its %s draft", async (kind) => {
const root = await mount({ session });
typeIdentityDraft(identityForm(root, kind), "Keep my own draft");
panel.setSession({ ...session, profile: { ...session.profile } });
await flush();
expect(identityForm(root, kind).querySelector("textarea").value).toBe("Keep my own draft");
});
test.each(["root", "reply", "edit"])("old %s completion cannot clear a draft after A to B to A", async (kind) => {
const root = await mount({ session });
let finish;
const operation = kind === "edit" ? "editComment" : "createComment";
request.mockImplementation((name) => {
if (name === operation)
return new Promise((resolve) => {
finish = resolve;
});
return Promise.resolve({ items: [comment()], nextCursor: null });
});
submit(identityForm(root, kind), "Old pending submission");
panel.setSession(otherSession);
await flush();
panel.setSession(session);
await flush();
typeIdentityDraft(identityForm(root, kind), "New draft after returning");
finish(comment({ ...(kind === "reply" ? { id: REPLY, parentId: ROOT } : {}), text: "Old pending submission" }));
await flush();
panel.setSession({ ...session });
await flush();
expect(identityForm(root, kind).querySelector("textarea").value).toBe("New draft after returning");
});
const VIDEO = "abcdefghijk";
const ROOT = "11111111-1111-1111-1111-111111111111";
const REPLY = "22222222-2222-2222-2222-222222222222";
@@ -2,8 +2,10 @@ import { requestDiscussion, subscribeDiscussionEvents } from "./client";
import { createDiscussionInbox } from "./inbox";
import { discussionText as t } from "./localize";
import { button, element } from "./dom";
import { isPrivateBrowsingContext } from "../private-browsing";
export function initDiscussionPopup() {
if (isPrivateBrowsingContext()) return;
const root = document.getElementById("ryd-discussion-popup");
if (!root) return;
if (!document.documentElement.lang && chrome.i18n?.getUILanguage)
@@ -111,6 +111,29 @@ test("a shared inbox update preserves the current screen and its navigation", as
expect(document.activeElement).toBe(toggle);
});
test("private Firefox popup initialization does not request or subscribe to account data", async () => {
await flush();
window.dispatchEvent(new Event("unload"));
document.body.innerHTML = new DOMParser().parseFromString(popupHtml, "text/html").body.innerHTML;
requestDiscussion.mockClear();
subscribeDiscussionEvents.mockClear();
global.browser = {
runtime: { getManifest: () => ({ browser_specific_settings: { gecko: {} } }) },
extension: { inIncognitoContext: true },
};
try {
initDiscussionPopup();
const privateRoot = document.getElementById("ryd-discussion-popup");
document.getElementById("ryd-discussion-inbox-toggle").click();
expect(privateRoot.hidden).toBe(true);
expect(privateRoot.childElementCount).toBe(0);
expect(requestDiscussion).not.toHaveBeenCalled();
expect(subscribeDiscussionEvents).not.toHaveBeenCalled();
} finally {
delete global.browser;
}
});
test("Escape on the home screen leaves the popup unchanged", () => {
const event = new KeyboardEvent("keydown", { key: "Escape", cancelable: true });
document.dispatchEvent(event);
+25 -1
View File
@@ -1,3 +1,4 @@
import { logger } from "./logging";
import { getBrowser, getVideoId, numberFormat, createObserver, querySelector } from "./utils";
import {
checkForSignInButton,
@@ -14,6 +15,7 @@ import {
storedData,
refreshReactionPresentation,
persistSyntheticShortsDislikeState,
updateDisableLogging,
} from "./state";
import { publishHideClutterButtons } from "./clutter-button-setting";
import { createRateBar } from "./bar";
@@ -28,8 +30,10 @@ import {
} from "../../common/vote-transition";
import { resolveDelegatedVoteActivation } from "../../common/delegated-vote-activation";
import { normalizeCustomColors, observeYouTubeTheme } from "../../common/color-themes";
import { isPrivateBrowsingContext } from "./private-browsing";
function sendVote(vote) {
if (isPrivateBrowsingContext()) return;
if (shouldSubmitVote({ disableVoteSubmission: extConfig.disableVoteSubmission })) {
const result = getBrowser().runtime.sendMessage({
message: "send_vote",
@@ -69,6 +73,7 @@ function clearNativeLikeForSyntheticDislike(action, stateBeforeActivation, synth
}
function handleVoteAction(action) {
if (isPrivateBrowsingContext()) return;
if (checkForSignInButton() === false) {
const videoId = getVideoId(window.location.href);
if (!videoId || storedData.videoId !== videoId) return;
@@ -150,6 +155,10 @@ function ensureDelegatedReactionListener() {
function addLikeDislikeEventListener(likeButton = getLikeButton(), dislikeButton = getDislikeButton()) {
if (!likeButton || !dislikeButton) return;
if (isPrivateBrowsingContext()) {
observeReactionPresentation(likeButton, dislikeButton);
return;
}
ensureDelegatedReactionListener();
observeReactionPresentation(likeButton, dislikeButton);
if (!boundDislikeButtons.has(dislikeButton)) {
@@ -176,7 +185,7 @@ function createSmartimationObserver(buttons = getButtons()) {
const smartimationContainer = querySelector(extConfig.selectors.buttons.smartimation, buttons);
if (smartimationContainer && smartimationObserver.container != smartimationContainer) {
console.log("Initializing smartimation mutation observer");
logger.log("Initializing smartimation mutation observer");
smartimationObserver.disconnect();
smartimationObserver.observe(smartimationContainer);
smartimationObserver.container = smartimationContainer;
@@ -184,6 +193,10 @@ function createSmartimationObserver(buttons = getButtons()) {
}
function storageChangeHandler(changes, area) {
if (isPrivateBrowsingContext()) return;
if (changes.disableLogging !== undefined && area === "sync") {
updateDisableLogging(changes.disableLogging.newValue);
}
if (changes.disableVoteSubmission !== undefined) {
handleDisableVoteSubmissionChangeEvent(changes.disableVoteSubmission.newValue);
}
@@ -203,6 +216,16 @@ function storageChangeHandler(changes, area) {
if (changes.numberDisplayFormat !== undefined) {
handleNumberDisplayFormatChangeEvent(changes.numberDisplayFormat.newValue);
}
if (area === "sync" && (changes.showTooltipPercentage !== undefined || changes.tooltipPercentageMode !== undefined)) {
if (changes.showTooltipPercentage !== undefined) {
extConfig.showTooltipPercentage = changes.showTooltipPercentage.newValue ?? false;
}
if (changes.tooltipPercentageMode !== undefined) {
extConfig.tooltipPercentageMode = changes.tooltipPercentageMode.newValue || "dash_like";
}
const videoId = getVideoId(window.location.href);
if (videoId && storedData.videoId === videoId) createRateBar(storedData.likes, storedData.dislikes);
}
if (changes.numberDisplayReformatLikes !== undefined) {
handleNumberDisplayReformatLikesChangeEvent(changes.numberDisplayReformatLikes.newValue);
}
@@ -249,6 +272,7 @@ function observeColorTheme() {
function handleNumberDisplayFormatChangeEvent(value) {
extConfig.numberDisplayFormat = value;
refreshReactionPresentation();
updateDOMDislikes();
}
function handleNumberDisplayReformatLikesChangeEvent(value) {
+78 -4
View File
@@ -5,6 +5,11 @@
const mockSendMessage = jest.fn();
jest.mock("./private-browsing", () => ({
...jest.requireActual("./private-browsing"),
isPrivateBrowsingContext: jest.fn(),
}));
jest.mock("./utils", () => ({
createObserver: () => ({ disconnect: jest.fn(), observe: jest.fn() }),
getBrowser: jest.fn(),
@@ -19,7 +24,7 @@ jest.mock("./buttons", () => ({
getDislikeButton: jest.fn(),
getLikeButton: jest.fn(),
getNativeButton: (control) => control?.querySelector("button[aria-pressed]"),
isSyntheticShortsDislike: () => false,
isSyntheticShortsDislike: jest.fn(() => false),
setSyntheticShortsDislikePressed: jest.fn(),
}));
@@ -32,6 +37,7 @@ jest.mock("./state", () => ({
getLikeCountFromButton: jest.fn(),
persistSyntheticShortsDislikeState: jest.fn(async () => undefined),
setDislikes: jest.fn(),
updateDisableLogging: jest.fn(),
setLikes: jest.fn(),
refreshReactionPresentation: jest.fn(),
storedData: { dislikes: 100, likes: 300, previousState: "neutral", videoId: "abcdefghijk" },
@@ -42,12 +48,23 @@ jest.mock("./bar", () => ({
}));
import { createRateBar } from "./bar";
import { getButtons, getDislikeButton, getLikeButton } from "./buttons";
import { extConfig, setDislikes, storedData } from "./state";
import { addLikeDislikeEventListener, storageChangeHandler, updateDOMDislikes } from "./events";
import { getButtons, getDislikeButton, getLikeButton, isSyntheticShortsDislike } from "./buttons";
import { extConfig, persistSyntheticShortsDislikeState, setDislikes, storedData, updateDisableLogging } from "./state";
import {
addLikeDislikeEventListener,
dislikeClicked,
likeClicked,
sendVote,
storageChangeHandler,
updateDOMDislikes,
} from "./events";
import { isPrivateBrowsingContext } from "./private-browsing";
import { getBrowser } from "./utils";
beforeEach(() => {
isPrivateBrowsingContext.mockReturnValue(false);
isSyntheticShortsDislike.mockReturnValue(false);
extConfig.disableVoteSubmission = false;
createRateBar.mockClear();
setDislikes.mockClear();
mockSendMessage.mockReset();
@@ -62,6 +79,47 @@ beforeEach(() => {
history.replaceState({}, "", "/watch?v=abcdefghijk");
});
test.each([false, true])("private direct reactions do not alter counts or submit (disabled=%s)", (disabled) => {
isPrivateBrowsingContext.mockReturnValue(true);
extConfig.disableVoteSubmission = disabled;
storedData.previousState = "LIKED_STATE";
const before = { ...storedData };
const likeButton = document.createElement("div");
const nativeLike = document.createElement("button");
const nativeLikeClick = jest.fn();
nativeLike.addEventListener("click", nativeLikeClick);
likeButton.append(nativeLike);
getLikeButton.mockReturnValue(likeButton);
isSyntheticShortsDislike.mockReturnValue(true);
sendVote(-1);
dislikeClicked();
likeClicked();
expect(mockSendMessage).not.toHaveBeenCalled();
expect(nativeLikeClick).not.toHaveBeenCalled();
expect(persistSyntheticShortsDislikeState).not.toHaveBeenCalled();
expect(storedData).toEqual(before);
expect(setDislikes).not.toHaveBeenCalled();
});
test("private native reactions keep their YouTube click behavior without local reaction changes", () => {
isPrivateBrowsingContext.mockReturnValue(true);
const controls = createReactionControls("private");
document.body.replaceChildren(controls.buttons);
getButtons.mockReturnValue(controls.buttons);
getLikeButton.mockReturnValue(controls.likeButton);
getDislikeButton.mockReturnValue(controls.dislikeButton);
const nativeAction = jest.fn();
controls.nativeDislikeButton.addEventListener("click", nativeAction);
const before = { ...storedData };
addLikeDislikeEventListener(controls.likeButton, controls.dislikeButton);
controls.nativeDislikeButton.click();
expect(nativeAction).toHaveBeenCalledTimes(1);
expect(mockSendMessage).not.toHaveBeenCalled();
expect(storedData).toEqual(before);
});
test("renders cached counts only while they belong to the current video", () => {
expect(updateDOMDislikes()).toBe(true);
expect(setDislikes).toHaveBeenCalledWith("100");
@@ -84,6 +142,22 @@ test("does not render cached counts when the current URL has no video identity",
expect(createRateBar).not.toHaveBeenCalled();
});
test.each([true, false, undefined])("applies live sync logging preference changes (%s)", (newValue) => {
storageChangeHandler({ disableLogging: { oldValue: !newValue, newValue } }, "sync");
expect(updateDisableLogging).toHaveBeenCalledWith(newValue);
});
test.each(["local", "session", "managed", undefined])("ignores logging preference events outside sync (%s)", (area) => {
storageChangeHandler({ disableLogging: { newValue: false } }, area);
expect(updateDisableLogging).not.toHaveBeenCalled();
});
test("private tabs ignore normal logging preference changes", () => {
isPrivateBrowsingContext.mockReturnValue(true);
storageChangeHandler({ disableLogging: { newValue: false } }, "sync");
expect(updateDisableLogging).not.toHaveBeenCalled();
});
test("publishes hide-clutter setting changes to the page-world menu fixer", () => {
storageChangeHandler({ hideClutterButtons: { newValue: true } }, "sync");
+14
View File
@@ -0,0 +1,14 @@
let loggingDisabled = false;
export function setLoggingDisabled(value) {
loggingDisabled = value;
}
export const logger = {
log(...args) {
if (!loggingDisabled) console.log(...args);
},
debug(...args) {
if (!loggingDisabled) console.debug(...args);
},
};
+34
View File
@@ -0,0 +1,34 @@
import { logger, setLoggingDisabled } from "./logging";
afterEach(() => {
setLoggingDisabled(false);
jest.restoreAllMocks();
});
test("suppresses only extension log/debug calls without changing the console", () => {
const methods = Object.fromEntries(
["log", "debug", "info", "warn", "error"].map((name) => [
name,
jest.spyOn(console, name).mockImplementation(() => {}),
]),
);
const payload = { count: 42 };
for (const disabled of [true, false, true, false]) {
Object.values(methods).forEach((method) => method.mockClear());
setLoggingDisabled(disabled);
logger.log("extension log", payload);
logger.debug("extension debug", payload);
for (const [name, method] of Object.entries(methods)) {
expect(console[name]).toBe(method);
console[name]("unrelated message");
const expectedCalls = !disabled && ["log", "debug"].includes(name) ? 2 : 1;
expect(method).toHaveBeenCalledTimes(expectedCalls);
expect(method).toHaveBeenLastCalledWith("unrelated message");
if (expectedCalls === 2) {
expect(method).toHaveBeenNthCalledWith(1, `extension ${name}`, payload);
expect(method.mock.contexts[0]).toBe(console);
}
}
}
});
+2
View File
@@ -1,5 +1,6 @@
import { initPremiumAnalytics, teardownPremiumAnalytics, updatePremiumSession } from "./premiumAnalytics";
import { initPremiumTeaser, setTeaserSuppressed, TEASER_SUPPRESSION_REASON_PREMIUM } from "./premiumAnalytics/teaser";
import { isPrivateBrowsingContext } from "./private-browsing";
import {
hasAuthenticationDataPermission,
onAuthenticationDataPermissionRemoved,
@@ -13,6 +14,7 @@ let patreonState = {
};
function initPatreonFeatures() {
if (isPrivateBrowsingContext()) return;
initPremiumTeaser();
let sessionGeneration = 0;
const initialGeneration = sessionGeneration;
+20
View File
@@ -20,6 +20,12 @@ jest.mock("./data-collection-permissions", () => ({
usesFirefoxDataCollectionConsent: jest.fn(),
}));
jest.mock("./private-browsing", () => ({
...jest.requireActual("./private-browsing"),
isPrivateBrowsingContext: jest.fn(),
}));
import { isPrivateBrowsingContext } from "./private-browsing";
import { initPatreonFeatures, patreonState } from "./patreon";
const analyticsMocks = jest.requireMock("./premiumAnalytics");
@@ -32,6 +38,7 @@ describe("Patreon authentication consent", () => {
let permissionRemovedListener;
beforeEach(() => {
isPrivateBrowsingContext.mockReturnValue(false);
jest.clearAllMocks();
document.body.innerHTML = '<div class="ryd-premium-feature"></div>';
patreonState.authenticated = false;
@@ -65,6 +72,19 @@ describe("Patreon authentication consent", () => {
delete global.chrome;
});
it("skips all private account reads, teasers and session listeners even when initialized directly", async () => {
isPrivateBrowsingContext.mockReturnValue(true);
consentMocks.hasAuthenticationDataPermission.mockResolvedValue(true);
initPatreonFeatures();
await flushPromises();
expect(chrome.storage.sync.get).not.toHaveBeenCalled();
expect(chrome.runtime.onMessage.addListener).not.toHaveBeenCalled();
expect(consentMocks.hasAuthenticationDataPermission).not.toHaveBeenCalled();
expect(consentMocks.onAuthenticationDataPermissionRemoved).not.toHaveBeenCalled();
expect(jest.requireMock("./premiumAnalytics/teaser").initPremiumTeaser).not.toHaveBeenCalled();
expect(analyticsMocks.initPremiumAnalytics).not.toHaveBeenCalled();
});
it("does not read a cached token without authentication consent", async () => {
initPatreonFeatures();
await flushPromises();
@@ -5,7 +5,7 @@ import { setActivityBucketLabel } from "../panel";
import { clampRangeToBounds, combineBounds, computeChartBounds, updateGlobalBounds } from "./time";
import { toEpoch, sanitizeCount } from "../utils";
import { localize } from "../../utils";
import { getTextColor, getMutedTextColor, getBorderColor } from "../theme";
import { getTextColor, getMutedTextColor, getBorderColor, getSurfaceColor } from "../theme";
import { logRangeSelection, logTimeBounds } from "../logging";
let activityTranslator = (key, substitutions) => localize(key, substitutions);
@@ -273,6 +273,28 @@ export function resizeActivityChart() {
analyticsState.activityChart?.resize();
}
export function refreshActivityTheme() {
analyticsState.activityChart?.setOption({
legend: { textStyle: { color: getTextColor() } },
xAxis: {
axisLabel: { color: getMutedTextColor() },
axisLine: { lineStyle: { color: getBorderColor() } },
},
yAxis: {
axisLabel: { color: getMutedTextColor() },
splitLine: { lineStyle: { color: getBorderColor(0.35) } },
},
dataZoom: [
{
backgroundColor: getSurfaceColor(0.05, 0.05),
borderColor: getBorderColor(0.1),
textStyle: { color: getMutedTextColor() },
handleStyle: { color: getTextColor(), borderColor: getBorderColor(0.6) },
},
],
});
}
export function disposeActivityChart() {
if (analyticsState.activityChart) {
analyticsState.activityChart.off?.("dataZoom", handleDataZoom);
@@ -297,8 +319,8 @@ function createDataZoom(bounds, selection) {
xAxisIndex: 0,
height: 18,
bottom: 20,
backgroundColor: "rgba(255,255,255,0.05)",
borderColor: "rgba(255,255,255,0.1)",
backgroundColor: getSurfaceColor(0.05, 0.05),
borderColor: getBorderColor(0.1),
textStyle: { color: getMutedTextColor() },
handleStyle: { color: getTextColor(), borderColor: getBorderColor(0.6) },
realtime: true,
@@ -30,6 +30,7 @@ jest.mock("../theme", () => ({
getTextColor: jest.fn(() => "#111111"),
getMutedTextColor: jest.fn(() => "#222222"),
getBorderColor: jest.fn(() => "#333333"),
getSurfaceColor: jest.fn(() => "#444444"),
}));
jest.mock("../logging", () => ({
@@ -46,6 +47,7 @@ import {
clearActivityChart,
resetChartZoom,
resizeActivityChart,
refreshActivityTheme,
disposeActivityChart,
registerZoomSelectionListener,
setActivityTranslator,
@@ -134,6 +136,34 @@ describe("premiumAnalytics.activity", () => {
jest.restoreAllMocks();
});
it("refreshes only chart colors while keeping the selected range and series", () => {
const theme = jest.requireMock("../theme");
theme.getTextColor.mockReturnValue("#111111");
theme.getMutedTextColor.mockReturnValue("#222222");
theme.getBorderColor.mockReturnValue("#333333");
theme.getSurfaceColor.mockReturnValue("#444444");
const chart = ensureActivityChart();
analyticsState.selectionRange = { from: 10, to: 20 };
const points = [{ timestampUtc: "2026-01-01T00:00:00Z", likes: 4, dislikes: 1 }];
analyticsState.latestSeriesPoints = points;
refreshActivityTheme();
expect(chart.setOption).toHaveBeenCalledWith(
expect.objectContaining({
legend: { textStyle: { color: "#111111" } },
xAxis: expect.objectContaining({ axisLabel: { color: "#222222" } }),
yAxis: expect.objectContaining({ axisLabel: { color: "#222222" } }),
dataZoom: [expect.objectContaining({ backgroundColor: "#444444" })],
}),
);
const update = chart.setOption.mock.calls.at(-1)[0];
expect(update).not.toHaveProperty("series");
expect(update.dataZoom[0]).not.toHaveProperty("startValue");
expect(update.dataZoom[0]).not.toHaveProperty("endValue");
expect(analyticsState.selectionRange).toEqual({ from: 10, to: 20 });
expect(analyticsState.latestSeriesPoints).toBe(points);
expect(chart.dispatchAction).not.toHaveBeenCalled();
});
it("initializes and caches the chart", () => {
const chart = ensureActivityChart();
@@ -162,7 +192,6 @@ describe("premiumAnalytics.activity", () => {
expect(logging.logRangeSelection).toHaveBeenCalled();
expect(analyticsState.latestSeriesPoints).toHaveLength(2);
expect(analyticsState.selectionRange.from).toBeLessThanOrEqual(analyticsState.selectionRange.to);
});
it("updates the bucket label when rendering", () => {
@@ -10,6 +10,7 @@ jest.mock("../theme", () => ({
getTextColor: () => "#ffffff",
getMutedTextColor: () => "#b3b3b3",
getBorderColor: () => "#444444",
getSurfaceColor: () => "rgba(255,255,255,0.06)",
}));
jest.mock("../logging", () => ({ logRangeSelection: jest.fn(), logTimeBounds: jest.fn() }));
@@ -9,6 +9,7 @@ jest.mock("../config", () => ({
jest.mock("../data-collection-permissions", () => ({
hasAuthenticationDataPermission: jest.fn(),
onAuthenticationDataPermissionRemoved: () => () => {},
usesFirefoxDataCollectionConsent: jest.fn(),
}));
@@ -33,6 +34,7 @@ jest.mock("./activity", () => ({
clearActivityChart: jest.fn(),
resetChartZoom: jest.fn(),
resizeActivityChart: jest.fn(),
refreshActivityTheme: jest.fn(),
disposeActivityChart: jest.fn(),
registerZoomSelectionListener: jest.fn(),
}));
@@ -40,6 +42,7 @@ jest.mock("./activity", () => ({
jest.mock("./map", () => ({
ensureMapChart: jest.fn(),
renderMap: jest.fn(),
refreshMapTheme: jest.fn(),
clearMapChart: jest.fn(),
resizeMapChart: jest.fn(),
disposeMapChart: jest.fn(),
@@ -210,10 +213,36 @@ describe("premiumAnalytics", () => {
});
afterEach(() => {
teardownPremiumAnalytics();
document.documentElement.removeAttribute("dark");
delete global.fetch;
delete global.chrome;
});
it("repaints existing charts after a host theme toggle without fetching or changing the selected range", async () => {
document.documentElement.removeAttribute("dark");
initPremiumAnalytics();
await flushPromises();
analyticsState.selectionRange = { from: 10, to: 20 };
analyticsState.mapView = "us";
fetch.mockClear();
activityMocks.refreshActivityTheme.mockClear();
mapMocks.refreshMapTheme.mockClear();
mockRenderMap.mockClear();
document.documentElement.setAttribute("dark", "");
await flushPromises();
expect(activityMocks.refreshActivityTheme).toHaveBeenCalledTimes(1);
expect(mapMocks.refreshMapTheme).toHaveBeenCalledTimes(1);
expect(mockRenderMap).not.toHaveBeenCalled();
expect(fetch).not.toHaveBeenCalled();
expect(analyticsState.selectionRange).toEqual({ from: 10, to: 20 });
expect(analyticsState.mapView).toBe("us");
teardownPremiumAnalytics();
document.documentElement.removeAttribute("dark");
await flushPromises();
expect(activityMocks.refreshActivityTheme).toHaveBeenCalledTimes(1);
});
it("initializes only once and wires listeners", () => {
initPremiumAnalytics();
expect(mockConfigurePanelCallbacks).toHaveBeenCalled();
@@ -461,6 +490,27 @@ describe("premiumAnalytics", () => {
expect(firstUrl).toBe(secondUrl);
});
it("restores a preset when a custom selection rounds to the same number of days", async () => {
mockGetApiEndpoint.mockImplementation((path) => `https://api.example${path}`);
initPremiumAnalytics();
const callbacks = mockConfigurePanelCallbacks.mock.calls.at(-1)[0];
analyticsState.currentVideoId = "abcdefghijk";
analyticsState.currentRange = 30;
analyticsState.usingCustomRange = true;
analyticsState.customSelection = { from: 10, to: 20 };
analyticsState.selectionRange = { from: 10, to: 20 };
fetch.mockClear();
callbacks.onRangePreset(30);
expect(analyticsState.usingCustomRange).toBe(false);
expect(analyticsState.customSelection).toBeNull();
expect(analyticsState.selectionRange).toEqual({ from: null, to: null });
await flushPromises();
expect(fetch).toHaveBeenCalledTimes(1);
expect(new URL(fetch.mock.calls[0][0]).searchParams.has("selectedRangeStartUtc")).toBe(false);
});
it("defers chart zoom resets until preset data arrives", () => {
initPremiumAnalytics();
const callbacks = mockConfigurePanelCallbacks.mock.calls.at(-1)?.[0];
@@ -7,8 +7,13 @@ import {
updateModeButtons,
applyChartExpansionState,
} from "./panel";
import { resizeActivityChart, disposeActivityChart, registerZoomSelectionListener } from "./activity";
import { renderMap, resizeMapChart, disposeMapChart } from "./map";
import {
resizeActivityChart,
disposeActivityChart,
registerZoomSelectionListener,
refreshActivityTheme,
} from "./activity";
import { renderMap, resizeMapChart, disposeMapChart, refreshMapTheme } from "./map";
import { debounce } from "./utils";
import { getVideoId } from "../utils";
@@ -16,12 +21,18 @@ import { MS_PER_DAY, EXPANDABLE_SECTIONS } from "./constants";
import { requestAnalytics, scheduleSelectionFetch, normalizeSelection } from "./requests";
import { setTeaserSuppressed, TEASER_SUPPRESSION_REASON_PREMIUM } from "./teaser";
import { showTierNotice, hideTierNotice } from "./tierNotice";
import { observeTheme } from "./theme";
let resizeListener = null;
let themeObserver = null;
function initPremiumAnalytics() {
if (analyticsState.initialized) return;
analyticsState.initialized = true;
themeObserver = observeTheme(() => {
refreshActivityTheme();
refreshMapTheme();
});
if (!analyticsState.zoomListenerRegistered) {
registerZoomSelectionListener(handleChartSelection);
@@ -65,7 +76,7 @@ function handleNavigation() {
function handleRangePreset(rangeDays) {
const state = analyticsState;
const samePreset = rangeDays === state.currentRange;
const samePreset = !state.usingCustomRange && rangeDays === state.currentRange;
if (samePreset) {
return;
}
@@ -135,8 +146,7 @@ function handleChartSelection(range) {
}
const sameAsCurrent =
analyticsState.selectionRange.from === normalized.from &&
analyticsState.selectionRange.to === normalized.to;
analyticsState.selectionRange.from === normalized.from && analyticsState.selectionRange.to === normalized.to;
analyticsState.selectionRange = normalized;
analyticsState.customSelection = { ...normalized };
@@ -169,6 +179,8 @@ function teardownPanel() {
}
function teardownPremiumAnalytics() {
themeObserver?.disconnect();
themeObserver = null;
teardownPanel();
if (resizeListener) {
window.removeEventListener("resize", resizeListener);
@@ -1,8 +1,10 @@
import { logger } from "../logging";
const LOG_PREFIX = '[PremiumAnalytics]';
export function logTimeBounds(label, bounds) {
if (!bounds) return;
console.log(
logger.log(
`${LOG_PREFIX} ${label} bounds:`,
bounds.min != null ? new Date(bounds.min).toISOString() : 'null',
'->',
@@ -12,7 +14,7 @@ export function logTimeBounds(label, bounds) {
export function logRangeSelection(label, range) {
if (!range) return;
console.log(
logger.log(
`${LOG_PREFIX} ${label} selection:`,
range.from != null ? new Date(range.from).toISOString() : 'null',
'->',
@@ -21,12 +23,12 @@ export function logRangeSelection(label, range) {
}
export function logFetchRequest(videoId, params) {
console.log(`${LOG_PREFIX} fetch video=${videoId} params=`, params.toString());
logger.log(`${LOG_PREFIX} fetch video=${videoId} params=`, params.toString());
}
export function logZoomPreview(label, bounds) {
if (!bounds) return;
console.log(
logger.log(
`${LOG_PREFIX} ${label} preview:`,
new Date(bounds.from).toISOString(),
'->',
@@ -2,6 +2,7 @@
* @jest-environment jsdom
*/
import { setLoggingDisabled } from "../logging";
import {
logTimeBounds,
logRangeSelection,
@@ -17,9 +18,20 @@ describe("premiumAnalytics.logging", () => {
});
afterEach(() => {
setLoggingDisabled(false);
consoleSpy.mockRestore();
});
it("suppresses analytics messages without replacing console.log", () => {
setLoggingDisabled(true);
logTimeBounds("chart", { min: 0, max: 1 });
logRangeSelection("selection", { from: 0, to: 1 });
logFetchRequest("video-id", new URLSearchParams({ a: "1" }));
logZoomPreview("preview", { from: 3, to: 4 });
expect(consoleSpy).not.toHaveBeenCalled();
expect(console.log).toBe(consoleSpy);
});
it("logs time bounds when values are provided", () => {
logTimeBounds("chart", { min: 0, max: 1 });
@@ -238,6 +238,20 @@ export function renderMap(countries) {
updateMapResetButtonVisibility();
}
export function refreshMapTheme() {
analyticsState.mapChart?.setOption({
visualMap: { textStyle: { color: getMutedTextColor() } },
geo: {
itemStyle: {
borderColor: getBorderColor(0.4),
borderWidth: 0.6,
areaColor: getSurfaceColor(0.06, 0.02),
},
emphasis: { itemStyle: { areaColor: getHoverFillColor(0.18, 0.18) } },
},
});
}
function resolveMapContext(state) {
const focusCode = typeof state.mapFocusCountry === "string" ? normalizeIsoCode(state.mapFocusCountry) : "";
const subdivisions = Array.isArray(state.latestSubdivisions) ? state.latestSubdivisions : [];
@@ -101,6 +101,7 @@ import { analyticsState } from "../state";
import {
ensureMapChart,
renderMap,
refreshMapTheme,
disposeMapChart,
clearMapChart,
resizeMapChart,
@@ -110,6 +111,25 @@ import {
const enMessages = require("../../../_locales/en/messages.json");
test("theme refresh merges only colors without replacing map data, center or zoom", () => {
const chart = { setOption: jest.fn() };
analyticsState.mapChart = chart;
try {
refreshMapTheme();
expect(chart.setOption).toHaveBeenCalledTimes(1);
expect(chart.setOption.mock.calls[0]).toHaveLength(1);
const update = chart.setOption.mock.calls[0][0];
expect(Object.keys(update).sort()).toEqual(["geo", "visualMap"]);
expect(Object.keys(update.geo).sort()).toEqual(["emphasis", "itemStyle"]);
expect(update.geo).not.toHaveProperty("center");
expect(update.geo).not.toHaveProperty("zoom");
expect(update.geo).not.toHaveProperty("map");
expect(update).not.toHaveProperty("series");
} finally {
analyticsState.mapChart = null;
}
});
function getMessage(key, substitutions) {
const entry = enMessages[key];
if (!entry) return key;
@@ -418,7 +418,7 @@ function positionExpandedSection(section, button) {
applyAnchorPosition(section, anchorRect, { maxWidthFallback: "min(960px, calc(100vw - 48px))" });
section.style.maxHeight = "calc(100vh - 120px)";
section.style.zIndex = "2147483647";
section.style.background = "var(--yt-spec-base-background, #202020)";
section.style.background = "var(--ryd-analytics-bg)";
section.style.boxShadow = "0 30px 60px rgba(0, 0, 0, 0.45)";
section.style.padding = "16px";
section.style.overflowY = "auto";
@@ -1,5 +1,5 @@
import { getApiEndpoint } from "../config";
import { fetchWithCoreConsent } from "../core-data-consent";
import { fetchWithAccountConsent } from "../account-data-consent";
import { analyticsState, COUNTRY_LIMIT } from "./state";
import { ensurePanel, updateRangeButtons, updateRangeAnchorButtons, setFooterMessage, setLoadingState } from "./panel";
import { debounce, safeJson } from "./utils";
@@ -104,7 +104,7 @@ function requestAnalyticsWithConsent({ selection } = {}) {
analyticsState.sessionToken === sessionToken &&
analyticsState.sessionActive;
fetchWithCoreConsent(url, {
fetchWithAccountConsent(url, {
headers: {
Authorization: `Bearer ${sessionToken}`,
"Content-Type": "application/json",
@@ -1,9 +1,15 @@
export function getTextColor() {
return getComputedStyle(document.documentElement).getPropertyValue("--yt-spec-text-primary").trim() || "#ffffff";
return (
getComputedStyle(document.documentElement).getPropertyValue("--yt-spec-text-primary").trim() ||
(isDarkTheme() ? "#ffffff" : "#0f0f0f")
);
}
export function getMutedTextColor() {
return getComputedStyle(document.documentElement).getPropertyValue("--yt-spec-text-secondary").trim() || "#b3b3b3";
return (
getComputedStyle(document.documentElement).getPropertyValue("--yt-spec-text-secondary").trim() ||
(isDarkTheme() ? "#b3b3b3" : "#606060")
);
}
export function getBorderColor(alpha = 0.15) {
@@ -25,48 +31,21 @@ export function getHoverFillColor(lightAlpha = 0.18, darkAlpha = 0.18) {
export function isDarkTheme() {
const root = document.documentElement;
if (root.hasAttribute("dark") && !root.hasAttribute("light")) {
return true;
}
if (root.getAttribute("dark") === "false") {
return false;
}
const colorValue = getComputedStyle(root).getPropertyValue("--yt-spec-text-primary").trim();
const color = parseColor(colorValue);
if (color) {
const luminance = (0.2126 * color.r + 0.7152 * color.g + 0.0722 * color.b) / 255;
return luminance > 0.5;
}
return true;
return root.hasAttribute("dark") && root.getAttribute("dark") !== "false" && !root.hasAttribute("light");
}
function parseColor(value) {
if (!value) {
return null;
}
const trimmed = value.trim();
if (trimmed.startsWith('#')) {
const hex = trimmed.slice(1);
if (hex.length === 3) {
const r = parseInt(hex[0] + hex[0], 16);
const g = parseInt(hex[1] + hex[1], 16);
const b = parseInt(hex[2] + hex[2], 16);
return { r, g, b };
}
if (hex.length === 6) {
const r = parseInt(hex.slice(0, 2), 16);
const g = parseInt(hex.slice(2, 4), 16);
const b = parseInt(hex.slice(4, 6), 16);
return { r, g, b };
}
return null;
}
const rgbMatch = trimmed.match(/rgba?\(([^)]+)\)/i);
if (rgbMatch) {
const parts = rgbMatch[1].split(',').map((part) => parseFloat(part.trim()));
if (parts.length >= 3) {
return { r: parts[0], g: parts[1], b: parts[2] };
}
}
return null;
export function observeTheme(onChange) {
const colors = () => `${isDarkTheme()}:${getTextColor()}:${getMutedTextColor()}`;
let previous = colors();
const observer = new MutationObserver(() => {
const next = colors();
if (next === previous) return;
previous = next;
onChange();
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["dark", "light", "style", "class"],
});
return observer;
}
@@ -2,12 +2,22 @@
* @jest-environment jsdom
*/
import { getTextColor, getMutedTextColor, getBorderColor, getSurfaceColor, getHoverFillColor, isDarkTheme } from "./theme";
import {
getTextColor,
getMutedTextColor,
getBorderColor,
getSurfaceColor,
getHoverFillColor,
isDarkTheme,
observeTheme,
} from "./theme";
describe("premiumAnalytics.theme", () => {
const originalGetComputedStyle = window.getComputedStyle;
beforeEach(() => {
document.documentElement.setAttribute("dark", "");
document.documentElement.removeAttribute("light");
window.getComputedStyle = jest.fn().mockImplementation(() => ({
getPropertyValue: (name) => {
if (name === "--yt-spec-text-primary") return " #fefefe ";
@@ -19,6 +29,9 @@ describe("premiumAnalytics.theme", () => {
afterEach(() => {
window.getComputedStyle = originalGetComputedStyle;
document.documentElement.removeAttribute("dark");
document.documentElement.removeAttribute("light");
document.documentElement.removeAttribute("style");
});
it("returns trimmed primary text color", () => {
@@ -34,8 +47,47 @@ describe("premiumAnalytics.theme", () => {
expect(getBorderColor()).toBe("rgba(255,255,255,0.15)");
});
it.each([null, "false"])("uses light fallbacks without an enabled dark marker (%s)", (dark) => {
if (dark === null) document.documentElement.removeAttribute("dark");
else document.documentElement.setAttribute("dark", dark);
window.getComputedStyle.mockReturnValue({ getPropertyValue: () => "" });
expect(isDarkTheme()).toBe(false);
expect(getTextColor()).toBe("#0f0f0f");
expect(getMutedTextColor()).toBe("#606060");
expect(getBorderColor()).toBe("rgba(15,23,42,0.15)");
});
it("uses dark fallbacks only while the host dark marker is enabled", () => {
window.getComputedStyle.mockReturnValue({ getPropertyValue: () => "" });
expect(isDarkTheme()).toBe(true);
expect(getTextColor()).toBe("#ffffff");
expect(getMutedTextColor()).toBe("#b3b3b3");
document.documentElement.setAttribute("light", "");
expect(isDarkTheme()).toBe(false);
expect(getTextColor()).toBe("#0f0f0f");
});
it("notifies for effective theme colors and stops observing after disconnect", async () => {
window.getComputedStyle.mockReturnValue({ getPropertyValue: () => "" });
const changed = jest.fn();
const observer = observeTheme(changed);
document.documentElement.style.setProperty("--unrelated", "1");
await Promise.resolve();
expect(changed).not.toHaveBeenCalled();
document.documentElement.removeAttribute("dark");
await Promise.resolve();
expect(changed).toHaveBeenCalledTimes(1);
document.documentElement.setAttribute("dark", "");
await Promise.resolve();
expect(changed).toHaveBeenCalledTimes(2);
observer.disconnect();
document.documentElement.removeAttribute("dark");
await Promise.resolve();
expect(changed).toHaveBeenCalledTimes(2);
});
it("derives surface colors based on theme", () => {
document.documentElement.setAttribute('dark', '');
document.documentElement.setAttribute("dark", "");
window.getComputedStyle.mockImplementation(() => ({
getPropertyValue: (name) => {
if (name === "--yt-spec-text-primary") return "#fefefe";
@@ -44,11 +96,11 @@ describe("premiumAnalytics.theme", () => {
},
}));
expect(isDarkTheme()).toBe(true);
expect(getSurfaceColor()).toBe('rgba(255,255,255,0.02)');
expect(getHoverFillColor()).toBe('rgba(255,255,255,0.18)');
expect(getSurfaceColor()).toBe("rgba(255,255,255,0.02)");
expect(getHoverFillColor()).toBe("rgba(255,255,255,0.18)");
document.documentElement.removeAttribute('dark');
document.documentElement.setAttribute('light', '');
document.documentElement.removeAttribute("dark");
document.documentElement.setAttribute("light", "");
window.getComputedStyle.mockImplementation(() => ({
getPropertyValue: (name) => {
if (name === "--yt-spec-text-primary") return "#111111";
@@ -57,9 +109,9 @@ describe("premiumAnalytics.theme", () => {
},
}));
expect(isDarkTheme()).toBe(false);
expect(getBorderColor(0.2)).toBe('rgba(15,23,42,0.2)');
expect(getSurfaceColor()).toBe('rgba(15,23,42,0.06)');
expect(getHoverFillColor()).toBe('rgba(15,23,42,0.18)');
document.documentElement.removeAttribute('light');
expect(getBorderColor(0.2)).toBe("rgba(15,23,42,0.2)");
expect(getSurfaceColor()).toBe("rgba(15,23,42,0.06)");
expect(getHoverFillColor()).toBe("rgba(15,23,42,0.18)");
document.documentElement.removeAttribute("light");
});
});
@@ -0,0 +1,28 @@
function extensionApi() {
return typeof browser !== "undefined" ? browser : globalThis.chrome;
}
function isFirefoxExtension(api = extensionApi()) {
return Boolean(api?.runtime?.getManifest?.()?.browser_specific_settings?.gecko);
}
function isPrivateBrowsingContext() {
const api = extensionApi();
return isFirefoxExtension(api) && api?.extension?.inIncognitoContext === true;
}
function isNormalMessageSender(api, sender) {
if (!isFirefoxExtension(api)) return true;
if (sender?.id !== api.runtime.id) return false;
if (sender.tab) return sender.tab.incognito === false;
try {
const url = new URL(sender.url);
if (!url.searchParams.get("rydContext")) return false;
const views = api.extension.getViews().filter((view) => view.location.href === sender.url);
return views.length === 1 && views[0].browser?.extension?.inIncognitoContext === false;
} catch (_) {
return false;
}
}
export { isFirefoxExtension, isPrivateBrowsingContext, isNormalMessageSender };
@@ -0,0 +1,44 @@
import { isFirefoxExtension, isPrivateBrowsingContext, isNormalMessageSender } from "./private-browsing";
afterEach(() => {
delete global.browser;
delete global.chrome;
});
test("background routing uses browser tab privacy metadata", () => {
const api = { runtime: { id: "fixture", getManifest: () => ({ browser_specific_settings: { gecko: {} } }) } };
expect(isNormalMessageSender(api, { id: "fixture", tab: { incognito: false } })).toBe(true);
expect(isNormalMessageSender(api, { id: "fixture", tab: { incognito: true } })).toBe(false);
expect(isNormalMessageSender(api, { id: "fixture", tab: {} })).toBe(false);
expect(isNormalMessageSender(api, { id: "foreign", tab: { incognito: false } })).toBe(false);
});
test("Firefox popup routing requires a distinct live normal view", () => {
const url = "moz-extension://fixture/popup.html?rydContext=synthetic-view";
const view = { location: { href: url }, browser: { extension: { inIncognitoContext: false } } };
const api = {
runtime: { id: "fixture", getManifest: () => ({ browser_specific_settings: { gecko: {} } }) },
extension: { getViews: jest.fn(() => [view]) },
};
const sender = { id: "fixture", url };
expect(isNormalMessageSender(api, sender)).toBe(true);
expect(isNormalMessageSender(api, { ...sender, url: "moz-extension://fixture/popup.html" })).toBe(false);
view.browser.extension.inIncognitoContext = true;
expect(isNormalMessageSender(api, sender)).toBe(false);
api.extension.getViews.mockReturnValue([]);
expect(isNormalMessageSender(api, sender)).toBe(false);
});
test("private display mode is limited to Firefox private contexts", () => {
global.browser = {
runtime: { getManifest: () => ({ browser_specific_settings: { gecko: {} } }) },
extension: { inIncognitoContext: true },
};
expect(isFirefoxExtension()).toBe(true);
expect(isPrivateBrowsingContext()).toBe(true);
browser.extension.inIncognitoContext = false;
expect(isPrivateBrowsingContext()).toBe(false);
browser.extension.inIncognitoContext = true;
browser.runtime.getManifest = () => ({ manifest_version: 3 });
expect(isPrivateBrowsingContext()).toBe(false);
});
@@ -13,7 +13,7 @@ import {
setSyntheticShortsDislikeEnabled,
__RewireAPI__ as buttonsRewire,
} from "./buttons";
import { addLikeDislikeEventListener, storageChangeHandler } from "./events";
import { addLikeDislikeEventListener, storageChangeHandler, __RewireAPI__ as eventsRewire } from "./events";
import {
extConfig,
isVideoDisliked,
@@ -91,6 +91,8 @@ beforeEach(() => {
coloredThumbs: false,
colorTheme: "classic",
disableVoteSubmission: true,
showTooltipPercentage: false,
tooltipPercentageMode: "dash_like",
});
});
@@ -448,3 +450,176 @@ test("synthetic Shorts Dislike clears the native Like without activating a prepe
expect(dislike.querySelector("button").getAttribute("aria-pressed")).toBe("true");
expect(storedData).toMatchObject({ previousState: DISLIKED_STATE, likes: 729, dislikes: 13 });
});
describe("live tooltip preferences", () => {
function mountTooltip({ enabled = false, mode = "dash_like", nested = false } = {}) {
mount({ likes: 260 });
storedData.dislikes = 4;
extConfig.showTooltipPercentage = enabled;
extConfig.tooltipPercentageMode = mode;
createRateBar.mockImplementation(renderRateBar);
renderRateBar(storedData.likes, storedData.dislikes);
const bar = document.querySelector("#ryd-bar-container");
bar.getBoundingClientRect = () => ({ top: 0, left: 0, right: 200, bottom: 2, width: 200, height: 2 });
const tooltip = document.querySelector("#ryd-dislike-tooltip");
if (nested) {
const content = document.createElement("div");
content.id = "tooltip";
content.innerHTML = tooltip.innerHTML;
tooltip.replaceChildren(content);
}
createRateBar.mockClear();
return tooltip;
}
const tooltipText = (tooltip) => tooltip.textContent.replace(/\s+/g, " ").trim();
test.each([
["dash_like", "260 / 4 - 98.5%"],
["dash_dislike", "260 / 4 - 1.5%"],
["both", "98.5% / 1.5%"],
["only_like", "98.5%"],
["only_dislike", "1.5%"],
])("updates an existing nested tooltip when sync mode changes to %s", (mode, expected) => {
const tooltip = mountTooltip({ enabled: true, mode: mode === "dash_like" ? "both" : "dash_like", nested: true });
const beforeCounts = { ...storedData };
storageChangeHandler({ tooltipPercentageMode: { newValue: mode } }, "sync");
expect(tooltipText(tooltip)).toBe(expected);
expect(document.querySelector("#ryd-dislike-tooltip")).toBe(tooltip);
expect(storedData).toEqual(beforeCounts);
});
test.each([true, false])("updates the existing tooltip when percentages become %s", (enabled) => {
const tooltip = mountTooltip({ enabled: !enabled, mode: "only_dislike" });
storageChangeHandler({ showTooltipPercentage: { newValue: enabled } }, "sync");
expect(tooltipText(tooltip)).toBe(enabled ? "1.5%" : "260 / 4");
expect(document.querySelector("#ryd-dislike-tooltip")).toBe(tooltip);
});
test("applies a combined preference event before repainting once", () => {
const tooltip = mountTooltip();
storageChangeHandler(
{ showTooltipPercentage: { newValue: true }, tooltipPercentageMode: { newValue: "only_dislike" } },
"sync",
);
expect(tooltipText(tooltip)).toBe("1.5%");
expect(createRateBar).toHaveBeenCalledTimes(1);
});
test("removing tooltip settings restores their existing defaults", () => {
const tooltip = mountTooltip({ enabled: true, mode: "only_dislike" });
storageChangeHandler(
{ showTooltipPercentage: { oldValue: true }, tooltipPercentageMode: { oldValue: "only_dislike" } },
"sync",
);
expect(tooltipText(tooltip)).toBe("260 / 4");
storageChangeHandler({ showTooltipPercentage: { newValue: true } }, "sync");
expect(tooltipText(tooltip)).toBe("260 / 4 - 98.5%");
});
test.each(["local", "session", "managed", undefined])("ignores tooltip preferences outside sync (%s)", (area) => {
const tooltip = mountTooltip();
storageChangeHandler(
{ showTooltipPercentage: { newValue: true }, tooltipPercentageMode: { newValue: "only_dislike" } },
area,
);
expect(tooltipText(tooltip)).toBe("260 / 4");
expect(extConfig.showTooltipPercentage).toBe(false);
expect(extConfig.tooltipPercentageMode).toBe("dash_like");
expect(createRateBar).not.toHaveBeenCalled();
});
test.each(["/watch?v=zyxwvutsrqp", "/feed/subscriptions"])(
"retains preferences without repainting outgoing cached counts on %s",
(url) => {
const tooltip = mountTooltip();
history.replaceState({}, "", url);
storageChangeHandler(
{ showTooltipPercentage: { newValue: true }, tooltipPercentageMode: { newValue: "only_dislike" } },
"sync",
);
expect(extConfig.showTooltipPercentage).toBe(true);
expect(extConfig.tooltipPercentageMode).toBe("only_dislike");
expect(tooltipText(tooltip)).toBe("260 / 4");
expect(createRateBar).not.toHaveBeenCalled();
},
);
test("private tabs retain their existing isolation from normal tooltip preferences", () => {
const tooltip = mountTooltip();
const restore = eventsRewire.__set__("isPrivateBrowsingContext", () => true);
try {
storageChangeHandler(
{ showTooltipPercentage: { newValue: true }, tooltipPercentageMode: { newValue: "only_dislike" } },
"sync",
);
expect(extConfig.showTooltipPercentage).toBe(false);
expect(extConfig.tooltipPercentageMode).toBe("dash_like");
expect(tooltipText(tooltip)).toBe("260 / 4");
expect(createRateBar).not.toHaveBeenCalled();
} finally {
restore();
}
});
});
describe("live dislike number format", () => {
test.each([
["compactShort", false, "1.3K", "11299"],
["compactLong", false, "1.3 thousand", "11299"],
["standard", false, "1,335", "11299"],
["compactShort", true, "1.3K", "11K"],
["compactLong", true, "1.3 thousand", "11 thousand"],
["standard", true, "1,335", "11,299"],
])("formats cached dislikes as %s with native Like reformatting=%s", (mode, reformatLikes, dislikes, likes) => {
document.documentElement.lang = "en";
const controls = mount({ likes: 11299 });
const nativeLikeText = getLikeTextContainer().firstChild;
storedData.dislikes = 1335;
extConfig.numberDisplayReformatLikes = reformatLikes;
extConfig.numberDisplayFormat = mode === "standard" ? "compactShort" : "standard";
controls.dislikeTextContainer.innerText = mode === "standard" ? "1.3K" : "1,335";
const readVotes = jest.fn();
const restore = stateRewire.__set__("requestVoteData", readVotes);
try {
storageChangeHandler({ numberDisplayFormat: { newValue: mode } }, "sync");
expect(controls.dislikeTextContainer.innerText).toBe(dislikes);
expect(getLikeTextContainer().firstChild).toBe(nativeLikeText);
expect(nativeLikeText.data).toBe(likes);
expect(storedData.likes).toBe(11299);
expect(storedData.dislikes).toBe(1335);
expect(readVotes).not.toHaveBeenCalled();
} finally {
restore();
}
});
test.each(["/watch?v=zyxwvutsrqp", "/feed/subscriptions"])(
"does not format outgoing cached dislikes on %s",
(url) => {
const controls = mount({ likes: 11299 });
storedData.dislikes = 1335;
controls.dislikeTextContainer.innerText = "1,335";
history.replaceState({}, "", url);
createRateBar.mockClear();
storageChangeHandler({ numberDisplayFormat: { newValue: "compactLong" } }, "sync");
expect(extConfig.numberDisplayFormat).toBe("compactLong");
expect(controls.dislikeTextContainer.innerText).toBe("1,335");
expect(createRateBar).not.toHaveBeenCalled();
},
);
});
+66 -15
View File
@@ -1,3 +1,4 @@
import { logger } from "./logging";
import {
getButtonControls,
getLikeButton,
@@ -32,6 +33,7 @@ import { createBrowserSyntheticDislikeStore } from "./vote-client-adapter";
import { requestVoteData } from "./vote-data-request";
import { fetchWithCoreConsent } from "./core-data-consent";
import { normalizeCustomColors } from "../../common/color-themes";
import { isPrivateBrowsingContext } from "./private-browsing";
const DEFAULT_SELECTORS = {
dislikeTextContainer: [
@@ -183,6 +185,7 @@ function isRoundedDesign() {
let syntheticDislikeStore = null;
function getSyntheticDislikeStore() {
if (isPrivateBrowsingContext()) return null;
if (syntheticDislikeStore) return syntheticDislikeStore;
const browserApi = getBrowser();
if (!browserApi?.storage?.local) return null;
@@ -199,7 +202,7 @@ async function readSyntheticShortsDislikeState(videoId) {
try {
return await store.isDisliked(videoId);
} catch (error) {
console.debug("Could not restore the synthetic Shorts Dislike state.", error?.message ?? error);
logger.debug("Could not restore the synthetic Shorts Dislike state.", error?.message ?? error);
return false;
}
}
@@ -333,7 +336,7 @@ function refreshReactionPresentation() {
}
function setDislikes(dislikesCount) {
console.log(`SET dislikes ${dislikesCount}`);
if (!isPrivateBrowsingContext()) logger.log(`SET dislikes ${dislikesCount}`);
const controls = getButtonControls();
const _container = getDislikeTextContainer(controls);
@@ -344,7 +347,7 @@ function setDislikes(dislikesCount) {
if (!isLikesDisabled(controls)) {
_dislikeText = dislikesCount;
} else {
console.log("likes count disabled by creator");
logger.log("likes count disabled by creator");
_dislikeText = localize("TextLikesDisabled");
}
@@ -425,7 +428,7 @@ async function setState(storedData) {
}
const videoId = getVideoId(window.location.href);
const dislikeButton = getDislikeButton();
if (isSyntheticShortsDislike(dislikeButton)) {
if (isSyntheticShortsDislike(dislikeButton) && !isPrivateBrowsingContext()) {
const storedDisliked = await readSyntheticShortsDislikeState(videoId);
if (getVideoId(window.location.href) !== videoId) return;
const liked = isVideoLiked();
@@ -435,7 +438,7 @@ async function setState(storedData) {
} else {
storedData.previousState = isVideoDisliked() ? DISLIKED_STATE : isVideoLiked() ? LIKED_STATE : NEUTRAL_STATE;
}
console.log("Video is loaded. Adding buttons...");
logger.log("Video is loaded. Adding buttons...");
const likeCount = getLikeCountFromButton() || null;
let response;
@@ -446,8 +449,10 @@ async function setState(storedData) {
displayError(error, videoId);
return;
}
console.log("response from api:");
console.log(JSON.stringify(response));
if (!isPrivateBrowsingContext()) {
logger.log("response from api:");
logger.log(JSON.stringify(response));
}
if (getVideoId(window.location.href) !== videoId) {
return;
}
@@ -520,6 +525,11 @@ function clearRenderedVoteState(controls = getButtonControls()) {
}
async function initExtConfig() {
if (isPrivateBrowsingContext()) {
await initializePrivateDisplayPreferences();
await initializeSelectors();
return;
}
initializeDisableVoteSubmission();
initializeDisableLogging();
initializeColoredThumbs();
@@ -534,8 +544,39 @@ async function initExtConfig() {
await initializeSelectors();
}
function initializePrivateDisplayPreferences() {
const preferences = [
"coloredThumbs",
"coloredBar",
"colorTheme",
"customColors",
"numberDisplayFormat",
"showTooltipPercentage",
"tooltipPercentageMode",
"numberDisplayReformatLikes",
HIDE_CLUTTER_BUTTONS_STORAGE_KEY,
];
extConfig.disableLogging = true;
initializeLogging();
extConfig.hidePremiumTeaser = true;
return new Promise((resolve) => {
getBrowser().storage.sync.get(preferences, (values = {}) => {
for (const key of preferences) {
if (values[key] !== undefined) extConfig[key] = values[key];
}
extConfig.colorTheme = values.colorTheme || "classic";
extConfig.customColors = normalizeCustomColors(values.customColors);
extConfig.hideClutterButtons = publishHideClutterButtons(values[HIDE_CLUTTER_BUTTONS_STORAGE_KEY]);
resolve();
});
});
}
async function initializeSelectors() {
const controller = new AbortController();
const privateContext = isPrivateBrowsingContext();
const abortOnPageHide = () => controller.abort();
if (privateContext) window.addEventListener("pagehide", abortOnPageHide, { once: true });
const timeout = setTimeout(() => controller.abort(), SELECTOR_REQUEST_TIMEOUT_MS);
try {
const response = await fetchWithCoreConsent(getApiEndpoint("/configs/selectors"), {
@@ -544,20 +585,23 @@ async function initializeSelectors() {
Accept: "application/json",
},
signal: controller.signal,
...(privateContext ? { credentials: "omit", cache: "no-store" } : {}),
});
if (!response.ok) {
throw new Error(`Selector request failed with HTTP ${response.status}`);
}
const result = await response.json();
if (privateContext && controller.signal.aborted) return false;
extConfig.selectors = mergeConfig(DEFAULT_SELECTORS, result);
console.log(result);
if (!isPrivateBrowsingContext()) logger.log(result);
return true;
} catch (error) {
extConfig.selectors = cloneConfig(DEFAULT_SELECTORS);
console.debug("Remote selectors unavailable; using bundled selectors.", error?.name ?? error);
logger.debug("Remote selectors unavailable; using bundled selectors.", error?.name ?? error);
return false;
} finally {
clearTimeout(timeout);
if (privateContext) window.removeEventListener("pagehide", abortOnPageHide);
}
}
@@ -571,16 +615,22 @@ function initializeDisableVoteSubmission() {
});
}
let loggingPreferenceRevision = 0;
function updateDisableLogging(value) {
loggingPreferenceRevision += 1;
extConfig.disableLogging = value ?? true;
initializeLogging();
}
function initializeDisableLogging() {
const revision = loggingPreferenceRevision;
getBrowser().storage.sync.get(["disableLogging"], (res) => {
if (revision !== loggingPreferenceRevision) return;
if (res.disableLogging === undefined) {
getBrowser().storage.sync.set({ disableLogging: true });
extConfig.disableLogging = true;
} else {
extConfig.disableLogging = res.disableLogging;
}
// Initialize console methods based on logging config
initializeLogging();
updateDisableLogging(res.disableLogging);
});
}
@@ -688,7 +738,7 @@ function initializeHideClutterButtons() {
const storedValue = res[HIDE_CLUTTER_BUTTONS_STORAGE_KEY];
const normalized = normalizeHideClutterButtons(storedValue);
extConfig.hideClutterButtons = publishHideClutterButtons(normalized);
if (storedValue === undefined) {
if (storedValue === undefined && !isPrivateBrowsingContext()) {
storage.set({ [HIDE_CLUTTER_BUTTONS_STORAGE_KEY]: false });
}
resolve(normalized);
@@ -723,4 +773,5 @@ export {
clearRenderedVoteState,
persistSyntheticShortsDislikeState,
initializeHideClutterButtons,
updateDisableLogging,
};
+221
View File
@@ -82,12 +82,15 @@ import {
clearRenderedVoteState,
extConfig,
getLikeCountFromButton,
initExtConfig,
initializeHideClutterButtons,
initializeSelectors,
updateDisableLogging,
restoreCurrentState,
setDislikes,
setState,
storedData,
persistSyntheticShortsDislikeState,
__RewireAPI__ as stateRewire,
} from "./state";
@@ -127,6 +130,8 @@ afterEach(() => {
stateRewire.__ResetDependency__("nativeLikeCounts");
stateRewire.__ResetDependency__("isMobile");
stateRewire.__ResetDependency__("syntheticDislikeStore");
stateRewire.__ResetDependency__("isPrivateBrowsingContext");
stateRewire.__ResetDependency__("initializeLogging");
});
afterAll(() => {
@@ -135,6 +140,49 @@ afterAll(() => {
delete globalThis.__mockControlsUnavailable;
});
test.each([true, false, undefined])("initializes the logging preference (%s)", (value) => {
const initializeLogging = jest.fn();
stateRewire.__set__("initializeLogging", initializeLogging);
const set = jest.fn();
globalThis.__mockBrowser = {
storage: { sync: { get: (_keys, callback) => callback({ disableLogging: value }), set } },
};
stateRewire.__get__("initializeDisableLogging")();
expect(extConfig.disableLogging).toBe(value ?? true);
expect(initializeLogging).toHaveBeenCalledTimes(1);
if (value === undefined) expect(set).toHaveBeenCalledWith({ disableLogging: true });
else expect(set).not.toHaveBeenCalled();
});
test.each([true, false, undefined])("a late logging preference read cannot override a live update (%s)", (value) => {
const initializeLogging = jest.fn();
stateRewire.__set__("initializeLogging", initializeLogging);
let finishRead;
const set = jest.fn();
globalThis.__mockBrowser = {
storage: { sync: { get: (_keys, callback) => (finishRead = callback), set } },
};
stateRewire.__get__("initializeDisableLogging")();
updateDisableLogging(false);
finishRead({ disableLogging: value });
expect(extConfig.disableLogging).toBe(false);
expect(initializeLogging).toHaveBeenCalledTimes(1);
expect(set).not.toHaveBeenCalled();
});
test("removing the logging preference restores the default suppression", () => {
const initializeLogging = jest.fn();
stateRewire.__set__("initializeLogging", initializeLogging);
updateDisableLogging(false);
updateDisableLogging(undefined);
expect(extConfig.disableLogging).toBe(true);
expect(initializeLogging).toHaveBeenCalledTimes(2);
});
test("hide-clutter initialization persists and publishes the default-off value", async () => {
const set = jest.fn();
globalThis.__mockBrowser = {
@@ -535,3 +583,176 @@ test("falls back to bundled selectors when the remote selector request never set
jest.useRealTimers();
}
});
describe("private count display", () => {
beforeEach(() => {
stateRewire.__set__("isPrivateBrowsingContext", () => true);
});
test.each([false, true])("reads only display preferences without default writes (saved=%s)", async (saved) => {
const initializeLogging = jest.fn();
stateRewire.__set__("initializeLogging", initializeLogging);
const preferences = saved
? {
coloredThumbs: true,
coloredBar: true,
colorTheme: "custom",
customColors: { light: { like: "#123456", dislike: "#654321" } },
numberDisplayFormat: "standard",
showTooltipPercentage: true,
tooltipPercentageMode: "percentage",
numberDisplayReformatLikes: true,
hideClutterButtons: true,
disableLogging: false,
patreonSessionToken: "synthetic-normal-session",
}
: {};
Object.assign(extConfig, {
coloredThumbs: false,
coloredBar: false,
colorTheme: "classic",
numberDisplayFormat: "compactShort",
showTooltipPercentage: false,
tooltipPercentageMode: "dash_like",
numberDisplayReformatLikes: false,
hideClutterButtons: false,
});
const storage = {
sync: {
get: jest.fn((_keys, callback) => callback(preferences)),
set: jest.fn(),
},
local: { get: jest.fn(), set: jest.fn(), remove: jest.fn() },
onChanged: { addListener: jest.fn(), removeListener: jest.fn() },
};
globalThis.__mockBrowser = { storage };
fetch.mockResolvedValueOnce({ ok: true, json: async () => ({}) });
const consoleLog = console.log;
const consoleDebug = console.debug;
await initExtConfig();
expect(storage.sync.get).toHaveBeenCalledTimes(1);
expect(storage.sync.get).toHaveBeenCalledWith(
[
"coloredThumbs",
"coloredBar",
"colorTheme",
"customColors",
"numberDisplayFormat",
"showTooltipPercentage",
"tooltipPercentageMode",
"numberDisplayReformatLikes",
"hideClutterButtons",
],
expect.any(Function),
);
expect(storage.sync.set).not.toHaveBeenCalled();
expect(storage.local.get).not.toHaveBeenCalled();
expect(storage.local.set).not.toHaveBeenCalled();
expect(storage.local.remove).not.toHaveBeenCalled();
expect(storage.onChanged.addListener).not.toHaveBeenCalled();
expect(initializeLogging).toHaveBeenCalledTimes(1);
expect(console.log).toBe(consoleLog);
expect(console.debug).toBe(consoleDebug);
expect(extConfig.coloredBar).toBe(saved);
expect(extConfig.numberDisplayFormat).toBe(saved ? "standard" : "compactShort");
expect(extConfig.customColors.light.like).toBe(saved ? "#123456" : "#007600");
expect(extConfig.disableLogging).toBe(true);
expect(extConfig).not.toHaveProperty("patreonSessionToken");
expect(fetch).toHaveBeenCalledWith(
"https://example.test/configs/selectors",
expect.objectContaining({ method: "GET", credentials: "omit", cache: "no-store" }),
);
});
test("private initialization suppresses its own count diagnostics without replacing console methods", async () => {
const { logger, setLoggingDisabled } = jest.requireActual("./logging");
const log = jest.spyOn(console, "log").mockImplementation(() => {});
const debug = jest.spyOn(console, "debug").mockImplementation(() => {});
const ownLog = jest.spyOn(logger, "log");
const initializeLogging = jest.fn(() => setLoggingDisabled(extConfig.disableLogging));
stateRewire.__set__("initializeLogging", initializeLogging);
const storage = {
sync: {
get: jest.fn((_keys, callback) => callback({ disableLogging: false })),
set: jest.fn(),
},
};
globalThis.__mockBrowser = { storage };
fetch
.mockResolvedValueOnce({ ok: true, json: async () => ({}) })
.mockResolvedValueOnce({ ok: true, json: async () => ({ dislikes: 65, likes: 100 }) });
extConfig.disableLogging = false;
setLoggingDisabled(false);
try {
await initExtConfig();
await setState(storedData);
logger.debug("Private count diagnostic");
expect(initializeLogging).toHaveBeenCalledTimes(1);
expect(extConfig.disableLogging).toBe(true);
expect(ownLog).toHaveBeenCalledWith("Video is loaded. Adding buttons...");
expect(log).not.toHaveBeenCalled();
expect(debug).not.toHaveBeenCalled();
expect(console.log).toBe(log);
expect(console.debug).toBe(debug);
expect(storage.sync.get.mock.calls[0][0]).not.toContain("disableLogging");
expect(storage.sync.set).not.toHaveBeenCalled();
} finally {
setLoggingDisabled(false);
ownLog.mockRestore();
log.mockRestore();
debug.mockRestore();
}
});
test("never restores or writes synthetic Shorts history, including direct persistence calls", async () => {
const store = { isDisliked: jest.fn(async () => true), setDisliked: jest.fn(async () => undefined) };
stateRewire.__set__("syntheticDislikeStore", store);
stateRewire.__set__("isMobile", () => false);
getDislikeButton().synthetic = true;
fetch.mockResolvedValueOnce({ ok: true, json: async () => ({ dislikes: 65, likes: 100 }) });
await setState(storedData);
await persistSyntheticShortsDislikeState("abcdefghijk", true);
await persistSyntheticShortsDislikeState("abcdefghijk", false);
expect(store.isDisliked).not.toHaveBeenCalled();
expect(store.setDisliked).not.toHaveBeenCalled();
expect(storedData).toMatchObject({ dislikes: 65, previousState: "NEUTRAL_STATE", videoId: "abcdefghijk" });
expect(setSyntheticShortsDislikePressed).toHaveBeenLastCalledWith(false, getDislikeButton());
});
test("omits private response and count payloads from diagnostics", async () => {
const payload = { dislikes: 654321, likes: 100, videoId: "private-test-video", marker: "private-count-payload" };
const log = jest.spyOn(console, "log").mockImplementation(() => {});
fetch.mockResolvedValueOnce({ ok: true, json: async () => payload });
try {
await setState(storedData);
expect(JSON.stringify(log.mock.calls)).not.toMatch(/654321|private-test-video|private-count-payload/);
} finally {
log.mockRestore();
}
});
test("does not save a missing clutter preference when initialized directly", async () => {
const storage = { get: jest.fn((_keys, callback) => callback({})), set: jest.fn() };
globalThis.__mockBrowser = { storage: { sync: storage } };
await initializeHideClutterButtons();
expect(storage.set).not.toHaveBeenCalled();
});
test("discards a late private selector response after page exit", async () => {
const pending = deferred();
fetch.mockReturnValueOnce(pending.promise);
const initialization = initializeSelectors();
const signal = fetch.mock.calls[0][1].signal;
window.dispatchEvent(new Event("pagehide"));
expect(signal.aborted).toBe(true);
pending.resolve({ ok: true, json: async () => ({ privateSelectorMarker: true }) });
await expect(initialization).resolves.toBe(false);
expect(extConfig.selectors).not.toHaveProperty("privateSelectorMarker");
});
});
+4 -17
View File
@@ -1,3 +1,4 @@
import { logger, setLoggingDisabled } from "./logging";
import { extConfig, isShorts } from "./state";
import { getThemeColor, isYouTubeDarkTheme } from "../../common/color-themes";
@@ -33,7 +34,7 @@ function getNumberFormatter(optionSelect) {
?.getAttribute("href"),
)?.searchParams?.get("locale");
} catch {
console.log("Cannot find browser locale. Use en as default for number formatting.");
logger.log("Cannot find browser locale. Use en as default for number formatting.");
userLocales = "en";
}
}
@@ -91,7 +92,7 @@ function getBrowser() {
} else if (typeof browser !== "undefined" && typeof browser.runtime !== "undefined") {
return browser;
} else {
console.log("browser is not supported");
logger.log("browser is not supported");
return false;
}
}
@@ -242,22 +243,8 @@ function isVideoLoaded() {
return querySelector(videoLoadedSelectors.map((selector) => selector.replace("{videoId}", videoId))) !== undefined;
}
const originalConsole = {
log: console.log.bind(console),
debug: console.debug.bind(console),
info: console.info.bind(console),
warn: console.warn.bind(console),
error: console.error.bind(console),
};
function initializeLogging() {
if (extConfig.disableLogging) {
console.log = () => {};
console.debug = () => {};
} else {
console.log = originalConsole.log;
console.debug = originalConsole.debug;
}
setLoggingDisabled(extConfig.disableLogging);
}
function getColorFromTheme(voteIsLike) {
+16 -21
View File
@@ -45,6 +45,7 @@ jest.mock("./state", () => {
});
import { extConfig, isShorts } from "./state";
import { logger, setLoggingDisabled } from "./logging";
import {
numberFormat,
getNumberFormatter,
@@ -90,6 +91,7 @@ function resetGlobals() {
jest.restoreAllMocks();
jest.clearAllMocks();
extConfig.disableLogging = false;
setLoggingDisabled(false);
extConfig.colorTheme = "classic";
extConfig.numberDisplayFormat = "compactShort";
isShorts.mockReturnValue(false);
@@ -448,28 +450,21 @@ describe("utils", () => {
});
describe("initializeLogging", () => {
it("disables console output when configured", () => {
extConfig.disableLogging = true;
const originalLog = console.log;
const originalDebug = console.debug;
it("applies the preference to extension logging without replacing console methods", () => {
const log = jest.spyOn(console, "log").mockImplementation(() => {});
const debug = jest.spyOn(console, "debug").mockImplementation(() => {});
for (const disabled of [true, false, true]) {
log.mockClear();
debug.mockClear();
extConfig.disableLogging = disabled;
initializeLogging();
expect(console.log).not.toBe(originalLog);
expect(console.debug).not.toBe(originalDebug);
});
it("restores console output when logging is enabled", () => {
const stubLog = jest.fn();
const stubDebug = jest.fn();
console.log = stubLog;
console.debug = stubDebug;
extConfig.disableLogging = false;
initializeLogging();
expect(console.log).not.toBe(stubLog);
expect(console.debug).not.toBe(stubDebug);
logger.log("extension log");
logger.debug("extension debug");
expect(console.log).toBe(log);
expect(console.debug).toBe(debug);
expect(log).toHaveBeenCalledTimes(disabled ? 0 : 1);
expect(debug).toHaveBeenCalledTimes(disabled ? 0 : 1);
}
});
});
@@ -1,5 +1,6 @@
import { getApiEndpoint } from "./config";
import { fetchWithCoreConsent } from "./core-data-consent";
import { isPrivateBrowsingContext } from "./private-browsing";
const WITHOUT_LIKE_COUNT = "without-like-count";
const EXTENSION_ID_PATTERN = /^[a-p]{32}$/;
@@ -100,6 +101,7 @@ function requestVoteData(videoId, { fetchImpl = globalThis.fetch, likeCount = nu
Accept: createVoteDataAcceptHeader(),
},
signal,
...(isPrivateBrowsingContext() ? { credentials: "omit", cache: "no-store" } : {}),
},
fetchImpl,
);
@@ -2,6 +2,12 @@ jest.mock("./config", () => ({
getApiEndpoint: (pathname) => `https://api.test${pathname}`,
}));
jest.mock("./private-browsing", () => ({
...jest.requireActual("./private-browsing"),
isPrivateBrowsingContext: jest.fn(),
}));
import { isPrivateBrowsingContext } from "./private-browsing";
import {
cancelObsoleteVoteDataRequests,
clearVoteDataRequestCache,
@@ -24,10 +30,23 @@ function response(payload = { dislikes: 12, likes: 34 }) {
}
beforeEach(() => {
isPrivateBrowsingContext.mockReturnValue(false);
clearVoteDataRequestCache();
global.fetch = jest.fn().mockResolvedValue(response());
});
test("private count requests omit credentials and HTTP cache use without account headers", async () => {
isPrivateBrowsingContext.mockReturnValue(true);
await requestVoteData(VIDEO_A);
expect(fetch).toHaveBeenCalledWith(`https://api.test/votes?videoId=${VIDEO_A}`, {
method: "GET",
headers: { Accept: "application/json" },
credentials: "omit",
cache: "no-store",
signal: expect.any(AbortSignal),
});
});
afterAll(() => {
delete global.fetch;
});
@@ -0,0 +1,165 @@
const path = require("node:path");
const { chromium, test, expect } = require("@playwright/test");
const { VIDEO_A } = require("../../UserScript/e2e/harness");
const { HermeticExtensionArtifactAdapter, startHermeticApiServer } = require("../hermetic-artifact-smoke");
const EXTENSION_ARTIFACT = path.resolve(
process.env.RYD_EXTENSION_ARTIFACT || path.join(__dirname, "../../combined/dist/chrome"),
);
async function assertSettingsLayout(page, direction, testInfo) {
await page.locator("#advancedToggle").click();
await expect(page.locator("#advancedSettings")).toHaveCSS("opacity", "1");
const layout = await page.evaluate(() => {
const rect = (element) => {
const { left, right, top, bottom } = element.getBoundingClientRect();
return { left, right, top, bottom };
};
const panel = document.getElementById("advancedSettings");
const panelLeft = panel.getBoundingClientRect().left + panel.clientLeft;
return {
clipLeft: panelLeft,
clipRight: panelLeft + panel.clientWidth,
toggle: rect(document.getElementById("advancedToggle")),
switches: [...panel.querySelectorAll(".switchLabel")].map((label) => ({
label: rect(label),
slider: rect(label.closest(".switch").querySelector(".slider")),
})),
};
});
expect(layout.switches.length).toBeGreaterThan(0);
for (const { label, slider } of layout.switches) {
if (direction === "rtl") expect(label.right).toBeLessThanOrEqual(slider.left + 0.5);
else expect(label.left).toBeGreaterThanOrEqual(slider.right - 0.5);
expect(label.left).toBeGreaterThanOrEqual(layout.clipLeft - 0.5);
expect(label.right).toBeLessThanOrEqual(layout.clipRight + 0.5);
const toggleOverlapsSlider =
layout.toggle.left < slider.right &&
layout.toggle.right > slider.left &&
layout.toggle.top < slider.bottom &&
layout.toggle.bottom > slider.top;
expect(toggleOverlapsSlider).toBe(false);
}
await page.screenshot({ path: testInfo.outputPath("settings-375.png"), fullPage: true });
const hoverTips = page.locator(".switch[data-hover], .label-with-hover-tip[data-hover]");
expect(await hoverTips.count()).toBeGreaterThan(0);
for (const host of await hoverTips.all()) {
await page.mouse.move(0, 0);
await expect
.poll(() =>
hoverTips.evaluateAll((elements) =>
elements.every((element) => getComputedStyle(element, "::before").visibility === "hidden"),
),
)
.toBe(true);
await host.hover();
await expect
.poll(() => host.evaluate((element) => getComputedStyle(element, "::before").visibility))
.toBe("visible");
const bounds = await host.evaluate((element) => {
const host = element.getBoundingClientRect();
const tip = getComputedStyle(element, "::before");
const width =
parseFloat(tip.width) +
(tip.boxSizing === "border-box"
? 0
: parseFloat(tip.paddingLeft) +
parseFloat(tip.paddingRight) +
parseFloat(tip.borderLeftWidth) +
parseFloat(tip.borderRightWidth));
const left = tip.left === "auto" ? host.right - parseFloat(tip.right) - width : host.left + parseFloat(tip.left);
return { left, right: left + width };
});
expect(bounds.left).toBeGreaterThanOrEqual(layout.clipLeft - 0.5);
expect(bounds.right).toBeLessThanOrEqual(layout.clipRight + 0.5);
}
await page.mouse.move(0, 0);
await expect
.poll(() =>
hoverTips.evaluateAll((elements) =>
elements.every((element) => getComputedStyle(element, "::before").visibility === "hidden"),
),
)
.toBe(true);
await page.locator("#hide_clutter_buttons_help").hover();
const help = page.locator("#hide_clutter_buttons_tooltip");
await expect(help).toBeVisible();
const helpBounds = await help.boundingBox();
expect(helpBounds.x).toBeGreaterThanOrEqual(layout.clipLeft - 0.5);
expect(helpBounds.x + helpBounds.width).toBeLessThanOrEqual(layout.clipRight + 0.5);
await page.screenshot({ path: testInfo.outputPath("settings-help-375.png"), fullPage: true });
}
for (const { locale, language, direction } of [
{ locale: "fa-IR", language: "fa", direction: "rtl" },
{ locale: "en-US", language: "en", direction: "ltr" },
]) {
test(`built popup and release page follow ${direction} locale metadata for ${locale}`, async ({}, testInfo) => {
const apiServer = await startHermeticApiServer();
const browserType = {
launchPersistentContext: (profile, options) =>
chromium.launchPersistentContext(profile, { ...options, args: [...options.args, `--lang=${language}`] }),
};
const adapter = new HermeticExtensionArtifactAdapter({
apiServer,
artifactDirectory: EXTENSION_ARTIFACT,
browserType,
contextOptions: { locale },
});
try {
await adapter.start();
await adapter.context.route("https://fonts.googleapis.com/**", (route) =>
route.fulfill({ body: "", contentType: "text/css", status: 200 }),
);
await adapter.context.route("https://fonts.gstatic.com/**", (route) =>
route.fulfill({ body: "", contentType: "font/woff2", status: 200 }),
);
await adapter.context.route("https://raw.githubusercontent.com/**", (route) =>
route.fulfill({ body: JSON.stringify({ version: "4.0.5" }), contentType: "application/json", status: 200 }),
);
expect(await adapter.worker.evaluate(() => chrome.i18n.getMessage("@@bidi_dir"))).toBe(direction);
await adapter.openWatch(VIDEO_A);
await adapter.waitForWatchResult(VIDEO_A);
const hostDirection = await adapter.page.evaluate(() => ({
attribute: document.documentElement.dir,
computed: getComputedStyle(document.documentElement).direction,
}));
const page = await adapter.context.newPage();
const pageErrors = [];
page.on("pageerror", (error) => pageErrors.push(error.message));
page.on("console", (message) => {
if (message.type() === "error") pageErrors.push(message.text());
});
for (const [name, url, widths] of [
["popup", "popup.html", [375]],
["release", "changelog/4/changelog_4.1.html", [375, 1280]],
]) {
for (const width of widths) {
await page.setViewportSize({ width, height: 1000 });
await page.goto(`chrome-extension://${adapter.extensionId}/${url}`, { waitUntil: "load" });
await expect(page.locator("html")).toHaveAttribute("dir", direction);
await expect(page.locator("html")).toHaveCSS("direction", direction);
expect(await page.evaluate(() => document.documentElement.innerHTML.includes("__MSG_"))).toBe(false);
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
await page.screenshot({ path: testInfo.outputPath(`${name}-${width}.png`), fullPage: true });
if (name === "popup") await assertSettingsLayout(page, direction, testInfo);
}
}
expect(
await adapter.page.evaluate(() => ({
attribute: document.documentElement.dir,
computed: getComputedStyle(document.documentElement).direction,
})),
).toEqual(hostDirection);
await adapter.assertNoPageSignals(testInfo.title);
expect(pageErrors).toEqual([]);
expect(adapter.backend.blockedRequests).toEqual([]);
expect(apiServer.unexpectedRequests).toEqual([]);
} finally {
await adapter.close().catch(() => {});
await apiServer.close().catch(() => {});
}
});
}
@@ -0,0 +1,181 @@
const { test, expect } = require("@playwright/test");
const { VIDEO_A } = require("../../UserScript/e2e/harness");
const { HermeticExtensionArtifactAdapter, startHermeticApiServer } = require("../hermetic-artifact-smoke");
async function applyTheme(page, dark, nativeTokens) {
await page.evaluate(
({ dark, nativeTokens }) => {
const root = document.documentElement;
root.toggleAttribute("dark", dark);
for (const [name, value] of Object.entries({
"--yt-spec-base-background": dark ? "#151923" : "#fff8f0",
"--yt-spec-text-primary": dark ? "#e4eaff" : "#302010",
"--yt-spec-text-secondary": dark ? "#abbbd2" : "#705030",
})) {
root.style.setProperty(name, nativeTokens ? value : "initial");
}
document.body.style.backgroundColor = dark ? "#0f0f0f" : "#fff";
},
{ dark, nativeTokens },
);
}
for (const nativeTokens of [false, true]) {
test(`premium analytics follows the host theme with native color tokens ${nativeTokens}`, async () => {
const apiServer = await startHermeticApiServer();
const adapter = new HermeticExtensionArtifactAdapter({
apiServer,
contextOptions: { colorScheme: "dark" },
});
let analyticsRequests = 0;
const analyticsQueries = [];
try {
await adapter.start();
await adapter.context.addInitScript(() => {
if (location.hostname !== "www.youtube.com") return;
document.addEventListener("DOMContentLoaded", () => {
const secondary = document.createElement("aside");
secondary.id = "secondary";
secondary.style.cssText = "width: 520px; margin: 24px;";
const inner = document.createElement("div");
inner.id = "secondary-inner";
secondary.append(inner);
document.body.append(secondary);
});
});
await adapter.context.route(
(url) => url.pathname.startsWith("/api/patreon/analytics/video/"),
async (route) => {
analyticsRequests++;
const query = new URL(route.request().url()).searchParams;
analyticsQueries.push(Object.fromEntries(query));
const selectedStart = query.get("selectedRangeStartUtc") || "2026-01-01T00:00:00Z";
const selectedEnd =
query.get("selectedRangeEndUtc") ||
new Date(Date.parse(selectedStart) + Number(query.get("rangeDays") || 30) * 86400000).toISOString();
await route.fulfill({
status: 200,
contentType: "application/json",
headers: { "access-control-allow-origin": "*" },
body: JSON.stringify({
timeSeries: {
bucket: "day",
totalRangeStartUtc: "2026-01-01T00:00:00Z",
totalRangeEndUtc: "2026-04-01T00:00:00Z",
selectedRangeStartUtc: selectedStart,
selectedRangeEndUtc: selectedEnd,
points: [
{ timestampUtc: selectedStart, likes: 10, dislikes: 2 },
{ timestampUtc: selectedEnd, likes: 20, dislikes: 3 },
],
},
geo: { countries: [], subdivisions: [], topLikes: [], topDislikes: [] },
summary: { totalLikes: 30, totalDislikes: 5, uniqueIps: 20, countriesRepresented: 0 },
}),
});
},
);
await adapter.context.route(
(url) => url.pathname === "/api/discussion/me",
(route) => route.fulfill({ status: 401, contentType: "application/json", body: '{"error":"invalid_session"}' }),
);
await adapter.worker.evaluate(async () => {
await chrome.storage.sync.set({
patreonAuthenticated: true,
patreonSessionToken: "synthetic-theme-session",
patreonUser: { fullName: "Synthetic member", hasActiveMembership: true, membershipTier: "premium" },
});
});
await adapter.openWatch(VIDEO_A);
const panel = adapter.page.locator(".ryd-premium-analytics");
await expect(panel).toBeVisible();
await expect(panel).toHaveAttribute("aria-busy", "false");
await expect(panel.locator("canvas").first()).toBeVisible();
const controlLayout = () =>
panel.evaluate((element) => ({
panel: element.getBoundingClientRect().toJSON(),
buttons: [...element.querySelectorAll(".ryd-range, .ryd-range-anchor")].map((button) =>
button.getBoundingClientRect().toJSON(),
),
}));
const normalLayout = await controlLayout();
await adapter.page.locator("#secondary").evaluate((element) => (element.style.width = "332px"));
const narrowLayout = await controlLayout();
expect(narrowLayout.panel.width).toBe(332);
expect(narrowLayout.buttons).toHaveLength(6);
for (const button of narrowLayout.buttons) {
expect(button.left).toBeGreaterThanOrEqual(narrowLayout.panel.left);
expect(button.right).toBeLessThanOrEqual(narrowLayout.panel.right);
}
await adapter.page.locator("#secondary").evaluate((element) => (element.style.width = "520px"));
const restoredLayout = await controlLayout();
restoredLayout.buttons.forEach((button, index) => {
expect(button.left).toBeCloseTo(normalLayout.buttons[index].left, 1);
expect(button.top).toBeCloseTo(normalLayout.buttons[index].top, 1);
expect(button.width).toBeCloseTo(normalLayout.buttons[index].width, 1);
expect(button.height).toBeCloseTo(normalLayout.buttons[index].height, 1);
});
for (const dark of [false, true, false]) {
const requestsBefore = analyticsRequests;
await applyTheme(adapter.page, dark, nativeTokens);
const background = nativeTokens
? dark
? "rgb(21, 25, 35)"
: "rgb(255, 248, 240)"
: dark
? "rgb(32, 32, 32)"
: "rgb(255, 255, 255)";
const foreground = nativeTokens
? dark
? "rgb(228, 234, 255)"
: "rgb(48, 32, 16)"
: dark
? "rgb(255, 255, 255)"
: "rgb(15, 15, 15)";
await expect(panel).toHaveCSS("background-color", background);
await expect(panel).toHaveCSS("color", foreground);
await expect(panel.locator(".ryd-analytics__totals-value").first()).toHaveCSS("color", foreground);
await expect(panel.locator(".ryd-range").first()).toHaveCSS(
"color",
dark ? (nativeTokens ? "rgb(171, 187, 210)" : "rgb(187, 187, 187)") : "rgb(31, 41, 55)",
);
await adapter.page.waitForTimeout(500);
expect(analyticsRequests).toBe(requestsBefore);
for (const chart of ["activity", "lists", "map"]) {
const expand = panel.locator(`.ryd-analytics__section-expand[data-chart="${chart}"]`);
await expand.click();
await expect(panel.locator(`.ryd-analytics__section[data-chart="${chart}"]`)).toHaveCSS(
"background-color",
background,
);
await expand.click();
}
}
await adapter.page.locator('.ryd-range[data-range="7"]').click();
await expect(adapter.page.locator('.ryd-range[data-range="7"]')).toHaveClass(/is-active/);
await expect.poll(() => analyticsRequests).toBeGreaterThan(1);
await adapter.page.locator('.ryd-range[data-range="30"]').click();
await expect(panel).toHaveAttribute("aria-busy", "false");
const chart = await panel.locator("#ryd-analytics-activity").boundingBox();
const plotWidth = chart.width - 60;
const sliderX = chart.x + 40 + plotWidth / 6;
const sliderY = chart.y + chart.height - 29;
await adapter.page.mouse.move(sliderX, sliderY);
await adapter.page.mouse.down();
await adapter.page.mouse.move(sliderX + plotWidth / 6, sliderY, { steps: 10 });
await adapter.page.mouse.up();
await expect(panel.locator(".ryd-analytics__window-hint")).toHaveText("Custom selection");
await expect.poll(() => analyticsQueries.at(-1)).toHaveProperty("selectedRangeStartUtc");
await adapter.page.locator('.ryd-range[data-range="30"]').click();
await expect(panel.locator('.ryd-range[data-range="30"]')).toHaveClass(/is-active/);
await expect(panel.locator(".ryd-analytics__window-hint")).toHaveText("First 30 days");
await expect.poll(() => analyticsQueries.at(-1)).toMatchObject({ rangeDays: "30", rangeAnchor: "first" });
expect(analyticsQueries.at(-1)).not.toHaveProperty("selectedRangeStartUtc");
expect(adapter.backend.blockedRequests).toEqual([]);
expect(apiServer.unexpectedRequests).toEqual([]);
} finally {
await adapter.close();
await apiServer.close();
}
});
}
+1 -1
View File
@@ -466,7 +466,7 @@ function prepareHermeticExtensionArtifact(sourceDirectory, apiOrigin) {
"ryd.background.js still contains the production API origin after transformation.",
);
const changelogListener =
/api\.runtime\.onInstalled\.addListener\(\(details\) => \{\r?\n maybeShowChangelog\(details\);\r?\n\}\);/g;
/api\.runtime\.onInstalled\.addListener\(\(details\) => \{\r?\n (?:maybeShowChangelog\(details\);|void handleInstallation\(details\);)\r?\n\}\);/g;
if ((transformedBackground.match(changelogListener) ?? []).length !== 1) {
removeOwnedTemporaryDirectory(temporaryRoot, "ryd-mv3-e2e-");
throw new Error("ryd.background.js must have exactly one recognized first-install changelog listener to suppress.");
+11 -6
View File
@@ -49,7 +49,7 @@ test("the extension adapter honors the selected artifact even when a scenario us
}
});
function createExtensionFixture(lineEnding = "\n") {
function createExtensionFixture(lineEnding = "\n", installedHandler = "maybeShowChangelog(details);") {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "ryd-mv3-source-fixture-"));
temporaryDirectories.push(directory);
fs.writeFileSync(
@@ -66,7 +66,7 @@ function createExtensionFixture(lineEnding = "\n") {
);
fs.writeFileSync(
path.join(directory, "ryd.background.js"),
`fetch("${PRODUCTION_API_ORIGIN}/register")\napi.runtime.onInstalled.addListener((details) => {\n maybeShowChangelog(details);\n});`.replaceAll(
`fetch("${PRODUCTION_API_ORIGIN}/register")\napi.runtime.onInstalled.addListener((details) => {\n ${installedHandler}\n});`.replaceAll(
"\n",
lineEnding,
),
@@ -381,10 +381,15 @@ test("the routed fake backend records a valid preflight and blocks an unknown on
]);
});
test.each(["\n", "\r\n"])(
"redirects MV3 background traffic with %j line endings and preserves routed traffic",
(lineEnding) => {
const sourceDirectory = createExtensionFixture(lineEnding);
test.each([
["\n", "maybeShowChangelog(details);"],
["\r\n", "maybeShowChangelog(details);"],
["\n", "void handleInstallation(details);"],
["\r\n", "void handleInstallation(details);"],
])(
"redirects MV3 background traffic with %j line endings and %s while preserving routed traffic",
(lineEnding, installedHandler) => {
const sourceDirectory = createExtensionFixture(lineEnding, installedHandler);
const prepared = prepareHermeticExtensionArtifact(sourceDirectory, "http://127.0.0.1:43127");
temporaryDirectories.push(prepared.temporaryRoot);
+2 -2
View File
@@ -4,7 +4,7 @@ This source package is provided for Mozilla Add-ons review. The extension is bui
## Build environment
Build the 4.0.6 release with this exact toolchain:
Build the 4.1.0 release with this exact toolchain:
- Windows 11 (PowerShell); the build script also supports Linux and macOS
- Node.js 22.17.0
@@ -49,7 +49,7 @@ The script performs all required technical build steps:
1. Confirms Node.js 22.17.0 and npm 10.8.2 are in use.
2. Installs the locked dependencies with `npm ci`.
3. Builds the extension with the release-specific `npm run build-for-amo` hook.
4. Verifies the generated Firefox manifest is version 4.0.6, retains the published AMO add-on ID, and declares the expected desktop data-collection permissions and minimum Firefox version.
4. Verifies the generated Firefox manifest is version 4.1.0, retains the published AMO add-on ID, and declares the expected desktop data-collection permissions and minimum Firefox version.
5. Verifies `menu-fixer.js` and the other required Firefox files were generated.
6. Verifies `ryd.content-script.js` is below 5 MB.
7. Verifies every generated JavaScript file has no source-map reference.