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:
Yaldar
2026-10-17 21:10:52 +02:00
committed by GitHub
parent d937bab835
commit fe057b3aa9
9 changed files with 321 additions and 6 deletions
@@ -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;
+2 -4
View File
@@ -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;
+1
View File
@@ -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) => {
+13
View File
@@ -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,
+22
View File
@@ -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,
};