mirror of
https://github.com/Anarios/return-youtube-dislike.git
synced 2026-10-27 23:31:06 +01:00
Fix ratio-bar sizing for inline YouTube controls (#1292)
Use rendered widths when inline reaction wrappers report non-numeric computed widths or zero client widths. Preserve existing block-control measurements in the extension and userscript, with generated-artifact browser regression coverage.
This commit is contained in:
@@ -2027,7 +2027,10 @@ function createRateBar(likes, dislikes) {
|
||||
rateBar = null;
|
||||
}
|
||||
|
||||
const widthPx = likeButton.clientWidth + (dislikeButton?.clientWidth ?? 52);
|
||||
// Inline view-model wrappers have no client width even when their children render.
|
||||
const widthPx =
|
||||
(likeButton.clientWidth || likeButton.getBoundingClientRect().width) +
|
||||
(dislikeButton ? dislikeButton.clientWidth || dislikeButton.getBoundingClientRect().width : 52);
|
||||
|
||||
const widthPercent = likes + dislikes > 0 ? (likes / (likes + dislikes)) * 100 : 50;
|
||||
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
const {
|
||||
CREDENTIAL_KEY,
|
||||
VIDEO_A,
|
||||
createFakeBackend,
|
||||
injectGeneratedUserscript,
|
||||
installGmEnvironment,
|
||||
installHermeticRoutes,
|
||||
openWatchFixture,
|
||||
} = require("./harness");
|
||||
const {
|
||||
WATCH_CONTROL_LAYOUTS,
|
||||
expectWatchControlWidths,
|
||||
installWatchControlLayout,
|
||||
widenWatchControls,
|
||||
} = require("../../e2e/watch-control-width-contract");
|
||||
const { createPageSignalCollector } = require("../../e2e/hermetic-artifact-smoke");
|
||||
|
||||
const COUNTS = { likes: 300, dislikes: 100 };
|
||||
const PROFILE = {
|
||||
container: "#return-youtube-dislike-bar-container",
|
||||
bar: "#return-youtube-dislike-bar",
|
||||
controlWidth: "clientWidth",
|
||||
};
|
||||
|
||||
for (const layout of WATCH_CONTROL_LAYOUTS) {
|
||||
test(`generated userscript measures ${layout.name} controls before and after a vote`, async ({ context, page }) => {
|
||||
const pageSignals = await createPageSignalCollector(page, "userscript");
|
||||
const backend = createFakeBackend({ countsByVideo: { [VIDEO_A]: COUNTS } });
|
||||
await installGmEnvironment(context, {
|
||||
[CREDENTIAL_KEY]: {
|
||||
userId: "ExistingUserscriptCredential000000000001",
|
||||
registrationConfirmed: true,
|
||||
},
|
||||
});
|
||||
await installHermeticRoutes(context, backend);
|
||||
await installWatchControlLayout(context, layout);
|
||||
await openWatchFixture(page, VIDEO_A);
|
||||
await injectGeneratedUserscript(page);
|
||||
|
||||
const dislikeText = page.locator('[data-ryd-role="dislike"] #text');
|
||||
await expect(dislikeText).toHaveText(String(COUNTS.dislikes));
|
||||
const before = await expectWatchControlWidths(page, PROFILE, { ...layout, ...COUNTS });
|
||||
await widenWatchControls(page);
|
||||
await page.locator('[data-ryd-role="dislike"] button').click();
|
||||
await expect(dislikeText).toHaveText(String(COUNTS.dislikes + 1));
|
||||
const after = await expectWatchControlWidths(page, PROFILE, {
|
||||
...layout,
|
||||
likes: COUNTS.likes,
|
||||
dislikes: COUNTS.dislikes + 1,
|
||||
});
|
||||
expect(after.wrapperWidth).toBeGreaterThan(before.wrapperWidth);
|
||||
await expect.poll(() => backend.requestsFor("POST", "/interact/confirmVote").length).toBe(1);
|
||||
expect(backend.requestsFor("POST", "/interact/vote")).toHaveLength(1);
|
||||
expect(backend.requestsFor("POST", "/interact/vote")[0].body).toMatchObject({ videoId: VIDEO_A, value: -1 });
|
||||
await expect.poll(() => backend.requestsFor("POST", "/interact/confirmVote")[0]?.responseBody).toBe(true);
|
||||
expect(backend.blockedRequests).toEqual([]);
|
||||
await pageSignals.assertClean("watch-control-width");
|
||||
});
|
||||
}
|
||||
@@ -1189,7 +1189,10 @@ function createRateBar(likes, dislikes) {
|
||||
rateBar = null;
|
||||
}
|
||||
|
||||
const widthPx = likeButton.clientWidth + (dislikeButton?.clientWidth ?? 52);
|
||||
// Inline view-model wrappers have no client width even when their children render.
|
||||
const widthPx =
|
||||
(likeButton.clientWidth || likeButton.getBoundingClientRect().width) +
|
||||
(dislikeButton ? dislikeButton.clientWidth || dislikeButton.getBoundingClientRect().width : 52);
|
||||
|
||||
const widthPercent = likes + dislikes > 0 ? (likes / (likes + dislikes)) * 100 : 50;
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { getButtons, getDislikeButton, getLikeButton } from "./buttons";
|
||||
import { extConfig, isMobile, isLikesDisabled, isNewDesign, isRoundedDesign, isShorts } from "./state";
|
||||
import { getColorFromTheme, getVideoId, isInViewport, querySelector } from "./utils";
|
||||
import { getColorFromTheme, getElementWidth, getVideoId, isInViewport, querySelector } from "./utils";
|
||||
|
||||
function closestConfigured(element, selectors) {
|
||||
for (const selector of Array.isArray(selectors) ? selectors : [selectors]) {
|
||||
@@ -101,9 +101,7 @@ function createRateBar(likes, dislikes) {
|
||||
}
|
||||
|
||||
const widthPx =
|
||||
parseFloat(window.getComputedStyle(getLikeButton()).width) +
|
||||
parseFloat(window.getComputedStyle(getDislikeButton()).width) +
|
||||
(isRoundedDesign() ? 0 : 8);
|
||||
getElementWidth(getLikeButton()) + getElementWidth(getDislikeButton()) + (isRoundedDesign() ? 0 : 8);
|
||||
|
||||
const widthPercent = likes + dislikes > 0 ? (likes / (likes + dislikes)) * 100 : 50;
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ jest.mock("./state", () => ({
|
||||
|
||||
jest.mock("./utils", () => ({
|
||||
getColorFromTheme: jest.fn(() => "red"),
|
||||
getElementWidth: jest.fn(() => 96),
|
||||
getVideoId: jest.fn(() => "BBBBBBBBBBB"),
|
||||
isInViewport: jest.fn(() => true),
|
||||
querySelector: jest.fn((selectors, element) => {
|
||||
|
||||
@@ -287,6 +287,18 @@ function getColorFromTheme(voteIsLike) {
|
||||
return colorString;
|
||||
}
|
||||
|
||||
function getElementWidth(element) {
|
||||
if (!element) return 0;
|
||||
// getComputedStyle().width resolves to "auto"/"" (→ NaN) for inline
|
||||
// elements such as YouTube's newer <like-button-view-model> /
|
||||
// <dislike-button-view-model> wrappers. Fall back to the rendered box width
|
||||
// so the ratio bar still gets a sensible size.
|
||||
const styleWidth = parseFloat(window.getComputedStyle(element).width);
|
||||
if (Number.isFinite(styleWidth)) return styleWidth;
|
||||
const rect = element.getBoundingClientRect?.();
|
||||
return rect ? rect.width : 0;
|
||||
}
|
||||
|
||||
function querySelector(selectors, element) {
|
||||
let result;
|
||||
for (const selector of Array.isArray(selectors) ? selectors : [selectors]) {
|
||||
@@ -332,6 +344,7 @@ export {
|
||||
isVideoLoaded,
|
||||
initializeLogging,
|
||||
getColorFromTheme,
|
||||
getElementWidth,
|
||||
localize,
|
||||
querySelector,
|
||||
querySelectorAll,
|
||||
|
||||
@@ -55,6 +55,7 @@ import {
|
||||
isVideoLoaded,
|
||||
initializeLogging,
|
||||
getColorFromTheme,
|
||||
getElementWidth,
|
||||
querySelector,
|
||||
querySelectorAll,
|
||||
createObserver,
|
||||
@@ -495,6 +496,27 @@ describe("utils", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("getElementWidth", () => {
|
||||
it("returns 0 for a missing element", () => {
|
||||
expect(getElementWidth(null)).toBe(0);
|
||||
});
|
||||
|
||||
it("uses the computed style width when it is a finite number", () => {
|
||||
const element = document.createElement("div");
|
||||
jest.spyOn(window, "getComputedStyle").mockReturnValue({ width: "42px" });
|
||||
|
||||
expect(getElementWidth(element)).toBe(42);
|
||||
});
|
||||
|
||||
it("falls back to the rendered box width when computed width is auto/NaN", () => {
|
||||
const element = document.createElement("div");
|
||||
jest.spyOn(window, "getComputedStyle").mockReturnValue({ width: "auto" });
|
||||
element.getBoundingClientRect = () => ({ width: 88 });
|
||||
|
||||
expect(getElementWidth(element)).toBe(88);
|
||||
});
|
||||
});
|
||||
|
||||
describe("querySelector", () => {
|
||||
it("returns the first matching element", () => {
|
||||
document.body.innerHTML = `
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
const path = require("node:path");
|
||||
const { test, expect } = require("@playwright/test");
|
||||
const { VIDEO_A } = require("../../UserScript/e2e/harness");
|
||||
const { HermeticExtensionArtifactAdapter, startHermeticApiServer } = require("../hermetic-artifact-smoke");
|
||||
const {
|
||||
WATCH_CONTROL_LAYOUTS,
|
||||
expectWatchControlWidths,
|
||||
installWatchControlLayout,
|
||||
widenWatchControls,
|
||||
} = require("../watch-control-width-contract");
|
||||
|
||||
const EXTENSION_ARTIFACT = path.resolve(
|
||||
process.env.RYD_EXTENSION_ARTIFACT || path.join(__dirname, "../../combined/dist/chrome"),
|
||||
);
|
||||
const COUNTS = { likes: 300, dislikes: 100 };
|
||||
const PROFILE = { container: "#ryd-bar-container", bar: "#ryd-bar", controlWidth: "computedWidth" };
|
||||
|
||||
for (const layout of WATCH_CONTROL_LAYOUTS) {
|
||||
test(`generated extension measures ${layout.name} controls before and after a vote`, async () => {
|
||||
const apiServer = await startHermeticApiServer(COUNTS);
|
||||
const adapter = new HermeticExtensionArtifactAdapter({
|
||||
apiServer,
|
||||
artifactDirectory: EXTENSION_ARTIFACT,
|
||||
backendOptions: {
|
||||
countsByVideo: { [VIDEO_A]: COUNTS },
|
||||
fixture: { nativeDislikeText: false, roleAttribute: "data-fixture-role" },
|
||||
},
|
||||
});
|
||||
try {
|
||||
await adapter.start();
|
||||
await installWatchControlLayout(adapter.context, layout);
|
||||
await adapter.openWatch(VIDEO_A);
|
||||
|
||||
const dislikeText = adapter.page.locator('[data-fixture-role="dislike"] .ytSpecButtonShapeNextButtonTextContent');
|
||||
await expect(dislikeText).toHaveText(String(COUNTS.dislikes));
|
||||
const before = await expectWatchControlWidths(adapter.page, PROFILE, { ...layout, ...COUNTS });
|
||||
await widenWatchControls(adapter.page);
|
||||
await adapter.page.locator('[data-fixture-role="dislike"] button').click();
|
||||
await expect(dislikeText).toHaveText(String(COUNTS.dislikes + 1));
|
||||
const after = await expectWatchControlWidths(adapter.page, PROFILE, {
|
||||
...layout,
|
||||
likes: COUNTS.likes,
|
||||
dislikes: COUNTS.dislikes + 1,
|
||||
});
|
||||
expect(after.wrapperWidth).toBeGreaterThan(before.wrapperWidth);
|
||||
await expect
|
||||
.poll(() => apiServer.records.filter((record) => record.pathname === "/interact/confirmVote").length)
|
||||
.toBe(1);
|
||||
const interactions = apiServer.records.filter((record) =>
|
||||
["/interact/vote", "/interact/confirmVote"].includes(record.pathname),
|
||||
);
|
||||
expect(interactions.map(({ pathname }) => pathname)).toEqual(["/interact/vote", "/interact/confirmVote"]);
|
||||
expect(interactions[0].body).toMatchObject({ videoId: VIDEO_A, value: -1 });
|
||||
await expect.poll(() => interactions[1].responseBody).toBe(true);
|
||||
await adapter.assertNoPageSignals("watch-control-width");
|
||||
expect(adapter.backend.blockedRequests).toEqual([]);
|
||||
expect(apiServer.unexpectedRequests).toEqual([]);
|
||||
} finally {
|
||||
await adapter.close();
|
||||
await apiServer.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
const { expect } = require("@playwright/test");
|
||||
|
||||
const WATCH_CONTROL_LAYOUTS = [
|
||||
{ name: "inline", inline: true, padded: false },
|
||||
{ name: "block", inline: false, padded: false },
|
||||
{ name: "padded block", inline: false, padded: true },
|
||||
];
|
||||
|
||||
async function installWatchControlLayout(context, { inline, padded = false }) {
|
||||
await context.addInitScript(
|
||||
({ inlineControls, paddedControls }) => {
|
||||
if (location.hostname !== "www.youtube.com") return;
|
||||
document.addEventListener(
|
||||
"DOMContentLoaded",
|
||||
() => {
|
||||
const style = document.createElement("style");
|
||||
style.textContent = `
|
||||
:root {
|
||||
--fixture-like-width: ${inlineControls || paddedControls ? "103.5px" : "96px"};
|
||||
--fixture-dislike-width: ${inlineControls || paddedControls ? "91.25px" : "96px"};
|
||||
}
|
||||
#top-level-buttons-computed,
|
||||
[data-fixture-smartimation-content] { width: 320px; }
|
||||
[data-fixture-smartimation-content] {
|
||||
display: ${inlineControls ? "block" : "flex"};
|
||||
font-size: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
#top-level-buttons-computed like-button-view-model,
|
||||
#top-level-buttons-computed dislike-button-view-model {
|
||||
display: ${inlineControls ? "inline" : "block"};
|
||||
flex: 0 0 auto;
|
||||
font-size: 14px;
|
||||
height: auto;
|
||||
${paddedControls ? "box-sizing: content-box; padding: 0 7px; border: 3px solid transparent;" : ""}
|
||||
}
|
||||
#top-level-buttons-computed like-button-view-model { width: ${inlineControls ? "auto" : "var(--fixture-like-width)"}; }
|
||||
#top-level-buttons-computed dislike-button-view-model { width: ${inlineControls ? "auto" : "var(--fixture-dislike-width)"}; }
|
||||
#top-level-buttons-computed like-button-view-model toggle-button-view-model,
|
||||
#top-level-buttons-computed dislike-button-view-model toggle-button-view-model,
|
||||
#top-level-buttons-computed like-button-view-model button-view-model,
|
||||
#top-level-buttons-computed dislike-button-view-model button-view-model { display: inline; }
|
||||
#top-level-buttons-computed like-button-view-model button {
|
||||
min-width: var(--fixture-like-width);
|
||||
width: var(--fixture-like-width);
|
||||
}
|
||||
#top-level-buttons-computed dislike-button-view-model button {
|
||||
min-width: var(--fixture-dislike-width);
|
||||
width: var(--fixture-dislike-width);
|
||||
}
|
||||
`;
|
||||
document.head.append(style);
|
||||
const marker = document.createElement("div");
|
||||
marker.id = "comment-teaser";
|
||||
marker.hidden = true;
|
||||
document.body.append(marker);
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
},
|
||||
{ inlineControls: inline, paddedControls: padded },
|
||||
);
|
||||
}
|
||||
|
||||
async function readWatchControlWidths(page, { container, bar }) {
|
||||
return page.evaluate(
|
||||
({ containerSelector, barSelector }) => {
|
||||
const required = (selector) => {
|
||||
const element = document.querySelector(selector);
|
||||
if (!element) throw new Error(`Missing width-contract element: ${selector}`);
|
||||
return element;
|
||||
};
|
||||
const control = (selector) => {
|
||||
const element = required(`[data-fixture-smartimation-content] ${selector}`);
|
||||
return {
|
||||
clientWidth: element.clientWidth,
|
||||
computedWidth: getComputedStyle(element).width,
|
||||
display: getComputedStyle(element).display,
|
||||
width: element.getBoundingClientRect().width,
|
||||
};
|
||||
};
|
||||
const wrapper = required(".ryd-tooltip");
|
||||
const track = required(containerSelector);
|
||||
const fill = required(barSelector);
|
||||
return {
|
||||
dislike: control("dislike-button-view-model"),
|
||||
fillWidth: fill.getBoundingClientRect().width,
|
||||
fillWidthStyle: fill.style.width,
|
||||
like: control("like-button-view-model"),
|
||||
trackHeight: track.getBoundingClientRect().height,
|
||||
trackWidth: track.getBoundingClientRect().width,
|
||||
wrapperWidth: wrapper.getBoundingClientRect().width,
|
||||
wrapperWidthStyle: wrapper.style.width,
|
||||
};
|
||||
},
|
||||
{ containerSelector: container, barSelector: bar },
|
||||
);
|
||||
}
|
||||
|
||||
async function expectWatchControlWidths(page, profile, { inline, padded = false, likes, dislikes }) {
|
||||
let geometry = await readWatchControlWidths(page, profile);
|
||||
for (const control of [geometry.like, geometry.dislike]) {
|
||||
expect(control.width).toBeGreaterThan(0);
|
||||
if (inline) {
|
||||
expect(control).toMatchObject({ clientWidth: 0, computedWidth: "auto", display: "inline" });
|
||||
} else {
|
||||
expect(control.display).toBe("block");
|
||||
expect(Number.parseFloat(control.computedWidth)).toBeGreaterThan(0);
|
||||
expect(control.clientWidth).toBeGreaterThan(0);
|
||||
if (padded) {
|
||||
expect(control.clientWidth).toBeGreaterThan(Number.parseFloat(control.computedWidth));
|
||||
expect(control.width).toBeGreaterThan(control.clientWidth);
|
||||
}
|
||||
}
|
||||
}
|
||||
const controlWidth = (control) => {
|
||||
if (inline) return control.width;
|
||||
return profile.controlWidth === "clientWidth" ? control.clientWidth : Number.parseFloat(control.computedWidth);
|
||||
};
|
||||
const expectedControlWidth = (snapshot) => controlWidth(snapshot.like) + controlWidth(snapshot.dislike);
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const snapshot = await readWatchControlWidths(page, profile);
|
||||
return Math.abs(snapshot.wrapperWidth - expectedControlWidth(snapshot));
|
||||
})
|
||||
.toBeLessThan(0.1);
|
||||
|
||||
geometry = await readWatchControlWidths(page, profile);
|
||||
const expectedWidth = expectedControlWidth(geometry);
|
||||
expect(Number.parseFloat(geometry.wrapperWidthStyle)).toBeCloseTo(expectedWidth, 1);
|
||||
expect(geometry.wrapperWidth).toBeCloseTo(expectedWidth, 1);
|
||||
expect(geometry.trackWidth).toBeCloseTo(expectedWidth, 1);
|
||||
expect(geometry.trackHeight).toBeGreaterThan(0);
|
||||
expect(Number.isFinite(Number.parseFloat(geometry.fillWidthStyle))).toBe(true);
|
||||
expect(geometry.fillWidth).toBeGreaterThan(0);
|
||||
expect(geometry.fillWidth / geometry.trackWidth).toBeCloseTo(likes / (likes + dislikes), 2);
|
||||
return geometry;
|
||||
}
|
||||
|
||||
async function widenWatchControls(page) {
|
||||
await page.evaluate(() => {
|
||||
document.documentElement.style.setProperty("--fixture-like-width", "124px");
|
||||
document.documentElement.style.setProperty("--fixture-dislike-width", "108px");
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
WATCH_CONTROL_LAYOUTS,
|
||||
expectWatchControlWidths,
|
||||
installWatchControlLayout,
|
||||
widenWatchControls,
|
||||
};
|
||||
Reference in New Issue
Block a user