Files
return-youtube-dislike/Extensions/UserScript/e2e/userscript-dislike-text-layout.e2e.js
T

264 lines
12 KiB
JavaScript

const { test, expect } = require("@playwright/test");
const {
CREDENTIAL_KEY,
VIDEO_A,
VIDEO_B,
createFakeBackend,
injectGeneratedUserscript,
installGmEnvironment,
installHermeticRoutes,
openWatchFixture,
} = require("./harness");
// This fixture uses the native shape classes to model icon-only and text-button spacing.
function installNativeWatchTextLayout({ shape = "current", direction = "ltr" } = {}) {
document.documentElement.dir = direction;
const current = shape === "current";
const shapeClasses = {
host: current ? "ytSpecButtonShapeNextHost" : "yt-spec-button-shape-next",
iconOnly: current ? "ytSpecButtonShapeNextIconButton" : "yt-spec-button-shape-next--icon-button",
iconLeading: current ? "ytSpecButtonShapeNextIconLeading" : "yt-spec-button-shape-next--icon-leading",
icon: current ? "ytSpecButtonShapeNextIcon" : "yt-spec-button-shape-next__icon",
text: current ? "ytSpecButtonShapeNextButtonTextContent" : "yt-spec-button-shape-next__button-text-content",
};
if (!document.getElementById("fixture-native-watch-text-style")) {
const style = document.createElement("style");
style.id = "fixture-native-watch-text-style";
style.textContent = `
#fixture-watch #top-level-buttons-computed,
#fixture-watch [data-fixture-smartimation-content] { width: max-content; }
#fixture-watch [data-ryd-role="like"],
#fixture-watch [data-ryd-role="dislike"] { width: auto; flex: 0 0 auto; }
#fixture-watch [data-ryd-role] button {
display: inline-flex; position: relative; align-items: center; justify-content: center;
gap: 0; width: auto; min-width: 0; padding: 0 16px; height: 36px;
}
#fixture-watch [data-ryd-role] button:is(.ytSpecButtonShapeNextIconButton, .yt-spec-button-shape-next--icon-button) {
width: 40px; padding: 0;
}
#fixture-watch [data-ryd-role] :is(.ytSpecButtonShapeNextIcon, .yt-spec-button-shape-next__icon) {
display: flex; flex: 0 0 20px; width: 20px; height: 20px; margin: 0;
}
#fixture-watch [data-ryd-role] button:is(.ytSpecButtonShapeNextIconLeading, .yt-spec-button-shape-next--icon-leading)
> :is(.ytSpecButtonShapeNextIcon, .yt-spec-button-shape-next__icon) {
margin-inline-start: -6px; margin-inline-end: 6px;
}
#fixture-watch [data-ryd-role] :is(.ytSpecButtonShapeNextButtonTextContent, .yt-spec-button-shape-next__button-text-content) {
display: block; line-height: 20px; white-space: nowrap;
}
`;
document.head.appendChild(style);
}
for (const role of ["like", "dislike"]) {
const host = document.querySelector(`#fixture-watch [data-ryd-role="${role}"]`);
const button = host.querySelector("button");
const svg = button.querySelector("svg");
let icon = svg.parentElement;
if (icon === button) {
icon = document.createElement("span");
icon.setAttribute("aria-hidden", "true");
svg.replaceWith(icon);
icon.appendChild(svg);
}
icon.className = shapeClasses.icon;
button.className = `${shapeClasses.host} ${role === "like" ? shapeClasses.iconLeading : shapeClasses.iconOnly}`;
if (role === "like") {
const text = button.querySelector(".ytSpecButtonShapeNextButtonTextContent, #text");
text.textContent = text.textContent.trim();
text.className = shapeClasses.text;
text.id = "text";
text.setAttribute("role", "text");
} else {
for (const text of button.querySelectorAll("#text")) text.remove();
}
}
globalThis.__nativeWatchShape = shapeClasses;
}
async function expectNativeCountLayout(page) {
const layout = await page.evaluate(() => {
const rect = (element) => {
const bounds = element.getBoundingClientRect();
return { left: bounds.left, right: bounds.right, top: bounds.top, bottom: bounds.bottom, width: bounds.width };
};
const direction = document.documentElement.dir;
return ["like", "dislike"].map((role) => {
const host = document.querySelector(`#fixture-watch [data-ryd-role="${role}"]`);
const button = host.querySelector("button");
const icon = rect(button.querySelector("svg"));
const text = button.querySelector("#text");
const count = rect(text);
const bounds = rect(button);
return {
button: bounds,
icon,
count,
gap: direction === "rtl" ? icon.left - count.right : count.left - icon.right,
startPadding: direction === "rtl" ? bounds.right - icon.right : icon.left - bounds.left,
endPadding: direction === "rtl" ? count.left - bounds.left : bounds.right - count.right,
textFont: getComputedStyle(text).font,
textTag: text.tagName,
nativeTextClass: text.classList.contains(globalThis.__nativeWatchShape.text),
countContainers: host.querySelectorAll("#text").length,
};
});
});
const [like, dislike] = layout;
expect(dislike.textTag).toBe("DIV");
expect(dislike.nativeTextClass).toBe(true);
expect(dislike.countContainers).toBe(1);
expect(dislike.count.width).toBeGreaterThan(0);
expect(dislike.gap).toBeGreaterThan(0);
expect(dislike.gap).toBeCloseTo(like.gap, 1);
expect(dislike.startPadding).toBeCloseTo(like.startPadding, 1);
expect(dislike.endPadding).toBeCloseTo(like.endPadding, 1);
expect(dislike.textFont).toBe(like.textFont);
expect(dislike.count.left).toBeGreaterThanOrEqual(dislike.button.left);
expect(dislike.count.right).toBeLessThanOrEqual(dislike.button.right);
expect(Math.abs(dislike.icon.top + dislike.icon.bottom - dislike.count.top - dislike.count.bottom)).toBeLessThan(1);
}
for (const shape of ["current", "older"]) {
for (const direction of ["ltr", "rtl"]) {
test(`${shape} watch fallback matches native text layout in ${direction} across updates and navigation`, async ({
context,
page,
}) => {
const pageErrors = [];
page.on("pageerror", (error) => pageErrors.push(error.message));
const backend = createFakeBackend({
countsByVideo: { [VIDEO_A]: { likes: 100, dislikes: 25 }, [VIDEO_B]: { likes: 200, dislikes: 37 } },
fixture: { nativeDislikeText: false },
});
await installGmEnvironment(context, {
[CREDENTIAL_KEY]: { registrationConfirmed: true, userId: "ExistingUserscriptCredential00000001" },
});
await installHermeticRoutes(context, backend);
await openWatchFixture(page, VIDEO_A);
await page.evaluate(installNativeWatchTextLayout, { shape, direction });
await page.evaluate(() => {
const host = document.querySelector('#fixture-watch [data-ryd-role="dislike"]');
globalThis.__nativeWatchOriginal = {
button: host.querySelector("button"),
icon: host.querySelector("svg"),
feedback: host.querySelector("[data-fixture-touch-feedback]"),
};
});
await injectGeneratedUserscript(page);
const dislike = page.locator('#fixture-watch [data-ryd-role="dislike"]');
await expect(dislike.locator("#text")).toHaveText("25");
await expectNativeCountLayout(page);
expect(
await page.evaluate(() => {
const host = document.querySelector('#fixture-watch [data-ryd-role="dislike"]');
const original = globalThis.__nativeWatchOriginal;
globalThis.__nativeWatchFirstCount = host.querySelector("#text");
return (
host.querySelector("button") === original.button &&
host.querySelector("svg") === original.icon &&
host.querySelector("[data-fixture-touch-feedback]") === original.feedback
);
}),
).toBe(true);
await expect(dislike.locator("button")).toHaveAttribute("aria-label", "Dislike this video");
await expect(dislike.locator("button")).toHaveAttribute("aria-pressed", "false");
await dislike.locator("#text").click();
await expect(dislike.locator("#text")).toHaveText("26");
await expect.poll(() => backend.requestsFor("POST", "/interact/confirmVote").length).toBe(1);
await expectNativeCountLayout(page);
expect(
await page.evaluate(
() =>
document.querySelector('#fixture-watch [data-ryd-role="dislike"] #text') ===
globalThis.__nativeWatchFirstCount,
),
).toBe(true);
await page.evaluate(() => {
const host = document.querySelector('#fixture-watch [data-ryd-role="dislike"]');
const replacement = host.cloneNode(true);
replacement.querySelector("#text").remove();
const button = replacement.querySelector("button");
button.className = `${globalThis.__nativeWatchShape.host} ${globalThis.__nativeWatchShape.iconOnly}`;
host.replaceWith(replacement);
});
await expect(dislike.locator("#text")).toHaveText("26");
await expectNativeCountLayout(page);
expect(backend.requestsFor("GET", "/votes")).toHaveLength(1);
await page.evaluate(({ videoId }) => globalThis.__youtubeFixture.navigate(videoId), { videoId: VIDEO_B });
await page.evaluate(installNativeWatchTextLayout, { shape, direction });
await expect(dislike.locator("#text")).toHaveText("37");
await expectNativeCountLayout(page);
await expect(dislike.locator("button")).toHaveAttribute("aria-pressed", "false");
expect(backend.requestsFor("GET", "/votes").map(({ query }) => query.videoId)).toEqual([VIDEO_A, VIDEO_B]);
expect(
backend.requestsFor("POST", "/interact/vote").map(({ body }) => ({ videoId: body.videoId, value: body.value })),
).toEqual([{ videoId: VIDEO_A, value: -1 }]);
expect(backend.blockedRequests).toEqual([]);
expect(pageErrors).toEqual([]);
});
}
test(`${shape} class-only native count clears during navigation and reuses its node for the next video`, async ({
context,
page,
}) => {
const backend = createFakeBackend({
countsByVideo: { [VIDEO_A]: { likes: 100, dislikes: 25 }, [VIDEO_B]: { likes: 200, dislikes: 37 } },
fixture: { nativeDislikeText: false },
});
await installGmEnvironment(context, {
[CREDENTIAL_KEY]: { registrationConfirmed: true, userId: "ExistingUserscriptCredential00000001" },
});
await installHermeticRoutes(context, backend);
await openWatchFixture(page, VIDEO_A);
await page.evaluate(installNativeWatchTextLayout, { shape });
await page.evaluate(() => {
const button = document.querySelector('#fixture-watch [data-ryd-role="dislike"] button');
const count = document.createElement("div");
count.className = globalThis.__nativeWatchShape.text;
count.setAttribute("data-fixture-native-count", "true");
button.className = `${globalThis.__nativeWatchShape.host} ${globalThis.__nativeWatchShape.iconLeading}`;
button.appendChild(count);
globalThis.__nativeWatchExistingCount = count;
});
await injectGeneratedUserscript(page);
const count = page.locator("[data-fixture-native-count]");
await expect(count).toHaveText("25");
await expect(count).not.toHaveAttribute("id");
await expect(count).not.toHaveAttribute("role");
const nextCounts = backend.defer("GET", "/votes");
await page.evaluate((videoId) => {
document.dispatchEvent(new Event("yt-navigate-start", { bubbles: true }));
history.pushState({}, "", `/watch?v=${videoId}`);
document.querySelector("#fixture-watch").setAttribute("video-id", videoId);
for (const role of ["like", "dislike"]) {
const button = document.querySelector(`#fixture-watch [data-ryd-role="${role}"] button`);
button.setAttribute("data-video-id", videoId);
button.setAttribute("aria-label", role === "like" ? "200 likes" : "Dislike the next video");
}
document.dispatchEvent(new Event("yt-navigate-finish", { bubbles: true }));
}, VIDEO_B);
await nextCounts.seen;
await expect(count).toHaveText("");
nextCounts.release({ body: { likes: 200, dislikes: 37 } });
await expect(count).toHaveText("37");
expect(
await page.evaluate(
() => document.querySelector("[data-fixture-native-count]") === globalThis.__nativeWatchExistingCount,
),
).toBe(true);
await expect(count).not.toHaveAttribute("id");
await expect(count).not.toHaveAttribute("role");
await expect(page.locator('#fixture-watch [data-ryd-role="dislike"] #text')).toHaveCount(0);
expect(backend.requestsFor("POST", "/interact/vote")).toHaveLength(0);
expect(backend.blockedRequests).toEqual([]);
});
}