mirror of
https://github.com/Anarios/return-youtube-dislike.git
synced 2026-10-28 07:44:31 +01:00
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:
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,14 +40,19 @@ 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");
|
||||
}
|
||||
|
||||
continueButton.addEventListener("click", () => {
|
||||
if (!privateBrowsing) {
|
||||
continueButton.addEventListener("click", () => {
|
||||
if (!canRequestPermissions) {
|
||||
continueButton.disabled = true;
|
||||
void consentApi()
|
||||
@@ -72,5 +90,6 @@ continueButton.addEventListener("click", () => {
|
||||
declineButton.disabled = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
declineButton.addEventListener("click", dismiss);
|
||||
|
||||
@@ -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.",
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"default_locale": "en",
|
||||
"version": "__RYD_VERSION__",
|
||||
"manifest_version": 2,
|
||||
"incognito": "not_allowed",
|
||||
"incognito": "spanning",
|
||||
"background": {
|
||||
"scripts": ["ryd.background.js"]
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
})();
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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,20 +59,35 @@ function localizeHtmlPage() {
|
||||
}
|
||||
|
||||
localizeHtmlPage();
|
||||
const coreConsentReady = waitForCoreDataConsent({ alwaysExplain: true });
|
||||
coreConsentReady.then(() => initDiscussionPopup());
|
||||
onCoreDataPermissionChanged((granted) => {
|
||||
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 });
|
||||
});
|
||||
});
|
||||
|
||||
/* Links */
|
||||
createLink(config.links.website, "link_website");
|
||||
createLink(config.links.github, "link_github");
|
||||
createLink(config.links.discord, "link_discord");
|
||||
createLink(config.links.faq, "link_faq");
|
||||
createLink(config.links.donate, "link_donate");
|
||||
createLink(config.links.help, "link_help");
|
||||
createLink(config.links.changelog, "link_changelog");
|
||||
/* Links */
|
||||
createLink(config.links.website, "link_website");
|
||||
createLink(config.links.github, "link_github");
|
||||
createLink(config.links.discord, "link_discord");
|
||||
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,29 +95,30 @@ function createLink(url, id) {
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById("disable_vote_submission").addEventListener("click", (ev) => {
|
||||
if (!privateBrowsing) {
|
||||
document.getElementById("disable_vote_submission").addEventListener("click", (ev) => {
|
||||
chrome.storage.sync.set({ disableVoteSubmission: ev.target.checked });
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById("disable_logging").addEventListener("click", (ev) => {
|
||||
document.getElementById("disable_logging").addEventListener("click", (ev) => {
|
||||
chrome.storage.sync.set({ disableLogging: ev.target.checked });
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById("colored_thumbs").addEventListener("click", (ev) => {
|
||||
document.getElementById("colored_thumbs").addEventListener("click", (ev) => {
|
||||
chrome.storage.sync.set({ coloredThumbs: ev.target.checked });
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById("colored_bar").addEventListener("click", (ev) => {
|
||||
document.getElementById("colored_bar").addEventListener("click", (ev) => {
|
||||
chrome.storage.sync.set({ coloredBar: ev.target.checked });
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById("color_theme").addEventListener("change", (ev) => {
|
||||
document.getElementById("color_theme").addEventListener("change", (ev) => {
|
||||
colorThemeRevision++;
|
||||
handleColorThemeChangeEvent(ev.target.value);
|
||||
chrome.storage.sync.set({ colorTheme: ev.target.value });
|
||||
});
|
||||
});
|
||||
|
||||
for (const mode of ["light", "dark"]) {
|
||||
for (const mode of ["light", "dark"]) {
|
||||
for (const vote of ["like", "dislike"]) {
|
||||
const input = document.getElementById(`custom_color_${mode}_${vote}`);
|
||||
input.addEventListener("input", () => {
|
||||
@@ -117,31 +137,32 @@ for (const mode of ["light", "dark"]) {
|
||||
chrome.storage.sync.set({ customColors: colors });
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById("number_format").addEventListener("change", (ev) => {
|
||||
document.getElementById("number_format").addEventListener("change", (ev) => {
|
||||
chrome.storage.sync.set({ numberDisplayFormat: ev.target.value });
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById("show_tooltip_percentage").addEventListener("click", (ev) => {
|
||||
document.getElementById("show_tooltip_percentage").addEventListener("click", (ev) => {
|
||||
chrome.storage.sync.set({ showTooltipPercentage: ev.target.checked });
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById("tooltip_percentage_mode").addEventListener("change", (ev) => {
|
||||
document.getElementById("tooltip_percentage_mode").addEventListener("change", (ev) => {
|
||||
chrome.storage.sync.set({ tooltipPercentageMode: ev.target.value });
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById("number_reformat_likes").addEventListener("click", (ev) => {
|
||||
document.getElementById("number_reformat_likes").addEventListener("click", (ev) => {
|
||||
chrome.storage.sync.set({ numberDisplayReformatLikes: ev.target.checked });
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById("hide_premium_teaser").addEventListener("click", (ev) => {
|
||||
document.getElementById("hide_premium_teaser").addEventListener("click", (ev) => {
|
||||
chrome.storage.sync.set({ hidePremiumTeaser: ev.target.checked });
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById("hide_clutter_buttons").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,11 +515,12 @@ function initPatreonAuth() {
|
||||
});
|
||||
}
|
||||
|
||||
coreConsentReady.then(() => initPatreonAuth());
|
||||
if (!privateBrowsing) {
|
||||
coreConsentReady.then(() => initPatreonAuth());
|
||||
|
||||
/* Advanced Toggle */
|
||||
const advancedToggle = document.getElementById("advancedToggle");
|
||||
advancedToggle.addEventListener("click", () => {
|
||||
/* Advanced Toggle */
|
||||
const advancedToggle = document.getElementById("advancedToggle");
|
||||
advancedToggle.addEventListener("click", () => {
|
||||
const adv = document.getElementById("advancedSettings");
|
||||
if (config.advanced) {
|
||||
adv.style.transform = "scale(1.1)";
|
||||
@@ -505,10 +534,11 @@ advancedToggle.addEventListener("click", () => {
|
||||
advancedToggle.innerHTML = config.hideAdvancedMessage;
|
||||
}
|
||||
config.advanced = !config.advanced;
|
||||
});
|
||||
});
|
||||
|
||||
chrome.storage.onChanged.addListener(storageChangeHandler);
|
||||
initConfig();
|
||||
chrome.storage.onChanged.addListener(storageChangeHandler);
|
||||
initConfig();
|
||||
}
|
||||
|
||||
function initConfig() {
|
||||
initializeDisableVoteSubmission();
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
getBrowser().storage.onChanged.addListener(storageChangeHandler);
|
||||
isStorageListenerRegistered = true;
|
||||
if (!privateBrowsingContext) {
|
||||
getBrowser().storage.onChanged.addListener(storageChangeHandler);
|
||||
isStorageListenerRegistered = true;
|
||||
}
|
||||
await initExtConfig();
|
||||
observeColorTheme();
|
||||
await pageWorldHelpersReady;
|
||||
initPatreonFeatures();
|
||||
initDiscussion();
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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,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();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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.");
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user