Fix Shorts support and add userscript voting

Harden Shorts controls, reaction state, and navigation; share voting logic
with the extension; improve ratio-bar rendering; and add comprehensive Jest,
Playwright, artifact, and live-browser regression coverage.
This commit is contained in:
Anarios
2026-09-01 23:41:28 +02:00
parent 4e6dfd1977
commit 3657de9b2c
74 changed files with 23328 additions and 588 deletions
@@ -0,0 +1,645 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Userscript navigation browser-test fixture</title>
<style>
html,
body {
margin: 0;
min-height: 768px;
width: 100%;
}
[hidden] {
display: none !important;
}
#fixture-account,
#fixture-navigation,
#fixture-page {
display: block;
}
#fixture-navigation a,
#fixture-page > a {
display: inline-block;
margin: 4px;
min-height: 24px;
min-width: 96px;
}
ytd-watch-flexy,
ytd-menu-renderer,
segmented-like-dislike-button-view-model,
like-button-view-model,
dislike-button-view-model,
ytd-shorts,
ytd-reel-video-renderer,
ytm-reel-video-renderer,
ytm-reel-player-overlay-renderer,
reel-action-bar-view-model,
ytm-like-button-renderer {
display: block;
}
#top-row,
ytd-menu-renderer.ytd-watch-metadata > div {
width: min(320px, 100%);
}
segmented-like-dislike-button-view-model {
display: flex;
gap: 8px;
min-height: 48px;
width: 320px;
}
segmented-like-dislike-button-view-model button {
min-height: 36px;
min-width: 96px;
}
ytd-reel-video-renderer {
display: block;
min-height: 640px;
width: 420px;
}
ytd-reel-video-renderer[hidden] {
display: none;
}
ytm-reel-video-renderer[hidden] {
display: none;
}
.slim-video-action-bar-actions,
.segmented-buttons {
display: flex;
gap: 8px;
}
ytm-like-button-renderer button {
min-height: 36px;
min-width: 96px;
}
reel-action-bar-view-model {
align-items: flex-start;
display: flex;
flex-direction: column;
min-height: 320px;
width: 48px;
}
.ytwReelActionBarViewModelHostDesktopActionButton,
reel-action-bar-view-model > button-view-model {
box-sizing: content-box;
display: block;
height: 70px;
margin: 0;
padding: 0 0 8px;
width: 48px;
}
.ytSpecButtonShapeWithLabelHost {
align-items: center;
display: flex;
flex-direction: column;
height: 70px;
margin: 0;
padding: 0;
width: 48px;
}
reel-action-bar-view-model button {
align-items: center;
border: 0;
box-sizing: border-box;
display: flex;
height: 48px;
justify-content: center;
margin: 0;
min-height: 0;
min-width: 0;
padding: 12px;
width: 48px;
}
.ytSpecButtonShapeNextIcon,
.ytSpecButtonShapeNextIcon svg {
display: block;
height: 24px;
width: 24px;
}
.ytSpecButtonShapeWithLabelLabel {
align-items: center;
display: flex;
font:
12px/18px Arial,
sans-serif;
height: 22px;
justify-content: center;
width: 48px;
}
</style>
</head>
<body>
<div id="fixture-account"><button id="avatar-btn" aria-label="Account menu">Account</button></div>
<nav id="fixture-navigation" aria-label="Fixture navigation"></nav>
<main id="fixture-page"></main>
<script>
(() => {
const firstVideoId = "__VIDEO_ID__";
const secondVideoId = "__SECOND_VIDEO_ID__";
const marker = "rydNavigationFixture=1";
const navigation = document.getElementById("fixture-navigation");
const page = document.getElementById("fixture-page");
const isMobile = location.hostname === "m.youtube.com";
let pendingDelayedRender = null;
if (!customElements.get("like-button-view-model")) {
customElements.define(
"like-button-view-model",
class extends HTMLElement {
get className() {
return undefined;
}
},
);
}
const hrefFor = (pageKind, videoId) => {
if (pageKind === "shorts") return `/shorts/${videoId}?${marker}`;
if (pageKind === "watch") return `/watch?v=${videoId}&${marker}`;
return `/@FixtureChannel?${marker}`;
};
const linkMarkup = (id, label, pageKind, videoId, options = {}) => `
<a
id="${id}"
href="${hrefFor(pageKind, videoId)}"
data-fixture-page-kind="${pageKind}"
data-fixture-video-id="${videoId || ""}"
data-fixture-control-delay="${options.controlDelay || 0}"
>${label}</a>
`;
function channelMarkup() {
const decoyControls = isMobile
? `
<div class="slim-video-action-bar-actions" data-fixture-decoy-controls>
<div class="segmented-buttons" data-ryd-role="buttons">
<like-button-view-model class="style-text" data-ryd-role="like">
<button type="button" aria-label="Channel preview likes" aria-pressed="false">
<span id="text" role="text">100</span>
</button>
</like-button-view-model>
<dislike-button-view-model class="style-text" data-ryd-role="dislike">
<button type="button" aria-label="Channel preview dislike" aria-pressed="false">
<span id="text" role="text"></span>
</button>
</dislike-button-view-model>
</div>
</div>
`
: `
<div id="menu-container" style="display: none"></div>
<ytd-menu-renderer class="ytd-watch-metadata" data-fixture-decoy-controls>
<div id="top-level-buttons-computed">
<segmented-like-dislike-button-view-model data-ryd-role="buttons">
<like-button-view-model class="style-text" data-ryd-role="like">
<button type="button" aria-label="Channel preview likes" aria-pressed="false">
<span id="text" role="text">100</span>
</button>
</like-button-view-model>
<dislike-button-view-model class="style-text" data-ryd-role="dislike">
<button type="button" aria-label="Channel preview dislike" aria-pressed="false">
<span id="text" role="text"></span>
</button>
</dislike-button-view-model>
</segmented-like-dislike-button-view-model>
</div>
</ytd-menu-renderer>
`;
return `
<section data-fixture-page-kind="channel">
<h1>Fixture channel</h1>
${decoyControls}
${linkMarkup("channel-short", "Open Short", "shorts", firstVideoId, { controlDelay: 600 })}
${linkMarkup("channel-watch", "Open video", "watch", firstVideoId, { controlDelay: 200 })}
</section>
`;
}
function watchMarkup(videoId) {
const nextVideoId = videoId === firstVideoId ? secondVideoId : firstVideoId;
return `
<section data-fixture-page-kind="watch" data-fixture-video-id="${videoId}">
<div id="menu-container" style="display: none"></div>
<div id="player" loading="false"></div>
<ytd-watch-flexy video-id="${videoId}"></ytd-watch-flexy>
<div id="top-row">
<ytd-menu-renderer class="ytd-watch-metadata">
<div id="top-level-buttons-computed">
<segmented-like-dislike-button-view-model
data-ryd-role="buttons"
data-fixture-control-video-id="${videoId}"
>
<like-button-view-model class="style-text" data-ryd-role="like">
<button type="button" aria-label="100 likes" aria-pressed="false">
<span id="text" role="text">100</span>
</button>
</like-button-view-model>
<dislike-button-view-model class="style-text" data-ryd-role="dislike">
<button type="button" aria-label="Dislike this video" aria-pressed="false">
<span id="text" role="text"></span>
</button>
</dislike-button-view-model>
</segmented-like-dislike-button-view-model>
</div>
</ytd-menu-renderer>
</div>
${linkMarkup("watch-to-short", "Open Short", "shorts", nextVideoId, { controlDelay: 200 })}
${linkMarkup("watch-related", "Open related video", "watch", nextVideoId)}
${linkMarkup("watch-next", "Play next video", "watch", nextVideoId)}
<video id="fixture-media" data-fixture-autoplay-kind="watch" data-fixture-autoplay-id="${nextVideoId}"></video>
</section>
`;
}
function shortRendererMarkup(videoId, active) {
return `
<ytd-reel-video-renderer video-id="${videoId}" ${active ? "is-active" : "hidden"}>
<a href="/shorts/${videoId}" aria-label="Short ${videoId}"></a>
<reel-action-bar-view-model data-ryd-role="buttons">
<like-button-view-model
class="ytLikeButtonViewModelHost ytwReelActionBarViewModelHostDesktopActionButton"
data-ryd-role="like"
>
<label class="ytSpecButtonShapeWithLabelHost">
<button class="ytSpecButtonShapeNextHost" type="button" aria-label="100 likes" aria-pressed="false">
<span class="ytSpecButtonShapeNextIcon" aria-hidden="true">
<svg viewBox="0 0 24 24"><path d="M12 21"></path></svg>
</span>
</button>
<div class="ytSpecButtonShapeWithLabelLabel"><span id="text" role="text">100</span></div>
</label>
</like-button-view-model>
<button-view-model data-fixture-control="comments">
<button type="button" aria-label="View comments"><span role="text">12</span></button>
</button-view-model>
<button-view-model data-fixture-control="share">
<button type="button" aria-label="Share">Share</button>
</button-view-model>
<button-view-model data-fixture-control="remix">
<button type="button" aria-label="Remix">Remix</button>
</button-view-model>
</reel-action-bar-view-model>
</ytd-reel-video-renderer>
`;
}
function mobileShortRendererMarkup(videoId, active) {
return `
<ytm-reel-video-renderer
data-fixture-mobile-short="${videoId}"
video-id="${videoId}"
${active ? "is-active" : "hidden"}
>
<a href="/shorts/${videoId}" aria-label="Short ${videoId}"></a>
<ytm-reel-player-overlay-renderer>
<div id="like-button">
<ytm-like-button-renderer data-ryd-role="buttons">
<like-button-view-model class="style-text" data-ryd-role="like">
<button type="button" aria-label="100 likes" aria-pressed="false">
<span id="text" class="button-renderer-text" role="text">100</span>
</button>
</like-button-view-model>
<dislike-button-view-model class="style-text" data-ryd-role="dislike">
<button type="button" aria-label="Dislike this video" aria-pressed="false">
<span id="text" class="button-renderer-text" role="text"></span>
</button>
</dislike-button-view-model>
</ytm-like-button-renderer>
</div>
</ytm-reel-player-overlay-renderer>
</ytm-reel-video-renderer>
`;
}
function shortsMarkup(videoId) {
const nextVideoId = videoId === firstVideoId ? secondVideoId : firstVideoId;
return `
<section data-fixture-page-kind="shorts" data-fixture-video-id="${videoId}">
${
isMobile
? `<div id="fixture-shorts-reels">
${mobileShortRendererMarkup(videoId, true)}
${mobileShortRendererMarkup(nextVideoId, false)}
</div>`
: `<ytd-shorts>
<div id="fixture-shorts-reels">
${shortRendererMarkup(videoId, true)}
${shortRendererMarkup(nextVideoId, false)}
</div>
</ytd-shorts>`
}
${linkMarkup("short-to-watch", "Open video", "watch", nextVideoId, { controlDelay: 200 })}
${linkMarkup("short-next", "Play next Short", "shorts", nextVideoId)}
<video id="fixture-media" data-fixture-autoplay-kind="shorts" data-fixture-autoplay-id="${nextVideoId}"></video>
</section>
`;
}
function pageMarkup(pageKind, videoId) {
if (pageKind === "shorts") return shortsMarkup(videoId);
if (pageKind === "watch") return watchMarkup(videoId);
return channelMarkup();
}
function dispatchNavigation() {
document.dispatchEvent(new Event("yt-navigate-finish", { bubbles: true }));
}
function render(pageKind, videoId) {
page.innerHTML = pageMarkup(pageKind, videoId);
navigation.innerHTML =
pageKind === "channel"
? ""
: linkMarkup("return-to-channel", "Return to channel", "channel", "", { controlDelay: 20 });
}
function navigate(pageKind, videoId, { controlDelay = 0, dispatchEvent = true } = {}) {
history.pushState({}, "", hrefFor(pageKind, videoId));
if (controlDelay > 0) {
page.dataset.fixtureTransitionPending = pageKind;
if (dispatchEvent) dispatchNavigation();
setTimeout(() => {
delete page.dataset.fixtureTransitionPending;
render(pageKind, videoId);
}, controlDelay);
return;
}
render(pageKind, videoId);
if (dispatchEvent) dispatchNavigation();
}
function recycleShort(videoId) {
const section = page.querySelector('[data-fixture-page-kind="shorts"]');
const activeRenderer = section?.querySelector(
isMobile ? "ytm-reel-video-renderer[is-active]" : "ytd-reel-video-renderer[is-active]",
);
const nextRenderer = section?.querySelector(
isMobile
? `ytm-reel-video-renderer[video-id="${videoId}"]`
: `ytd-reel-video-renderer[video-id="${videoId}"]`,
);
if (!section || !activeRenderer || !nextRenderer) return;
history.pushState({}, "", hrefFor("shorts", videoId));
section.dataset.fixtureVideoId = videoId;
activeRenderer.removeAttribute("is-active");
activeRenderer.hidden = true;
nextRenderer.hidden = false;
nextRenderer.setAttribute("is-active", "");
const nextLink = section.querySelector("#short-next");
nextLink?.setAttribute("data-fixture-video-id", firstVideoId);
nextLink?.setAttribute("href", hrefFor("shorts", firstVideoId));
section.querySelector("#fixture-media")?.setAttribute("data-fixture-autoplay-id", firstVideoId);
}
function churnInactiveDesktopShortIdentity(sequence) {
const renderer = page.querySelector("ytd-reel-video-renderer:not([is-active])");
if (!renderer) return;
const churnVideoId = sequence % 2 === 0 ? secondVideoId : "CCCCCCCCCCC";
renderer.setAttribute("video-id", churnVideoId);
renderer.querySelector('a[href*="/shorts/"]')?.setAttribute("href", `/shorts/${churnVideoId}`);
}
function queueDelayedRender(pageKind, videoId) {
page.dataset.fixtureTransitionPending = pageKind;
pendingDelayedRender = () => {
delete page.dataset.fixtureTransitionPending;
render(pageKind, videoId);
};
}
function finishDelayedNavigation() {
if (!pendingDelayedRender) throw new Error("No delayed fixture navigation is pending");
const finish = pendingDelayedRender;
pendingDelayedRender = null;
finish();
}
function navigateDelayedShort(videoId) {
const rendererTag = isMobile ? "ytm-reel-video-renderer" : "ytd-reel-video-renderer";
const activeRenderer = page.querySelector(`${rendererTag}[is-active]`);
for (const renderer of page.querySelectorAll(`${rendererTag}:not([is-active])`)) {
renderer.remove();
}
if (!activeRenderer) return;
activeRenderer.querySelector('a[href*="/shorts/"]')?.setAttribute("href", `/shorts/${videoId}`);
history.pushState({}, "", hrefFor("shorts", videoId));
queueDelayedRender("shorts", videoId);
dispatchNavigation();
}
function anonymizeActiveDesktopShort() {
const activeRenderer = page.querySelector("ytd-reel-video-renderer[is-active]");
if (!activeRenderer) return;
activeRenderer.removeAttribute("video-id");
activeRenderer.querySelector('a[href*="/shorts/"]')?.removeAttribute("href");
}
function navigateDelayedAnonymousShort(videoId) {
const activeRenderer = page.querySelector("ytd-reel-video-renderer[is-active]");
for (const renderer of page.querySelectorAll("ytd-reel-video-renderer:not([is-active])")) {
renderer.remove();
}
if (!activeRenderer) return;
anonymizeActiveDesktopShort();
history.pushState({}, "", hrefFor("shorts", videoId));
queueDelayedRender("shorts", videoId);
dispatchNavigation();
}
function navigateDelayedWatch(videoId) {
const watch = page.querySelector("ytd-watch-flexy");
if (!watch) return;
history.pushState({}, "", hrefFor("watch", videoId));
watch.setAttribute("video-id", videoId);
queueDelayedRender("watch", videoId);
dispatchNavigation();
}
function beginSameNodeWatchNavigation(videoId) {
const watch = page.querySelector("ytd-watch-flexy");
if (!watch) return;
document.dispatchEvent(new Event("yt-navigate-start", { bubbles: true }));
history.pushState({}, "", hrefFor("watch", videoId));
watch.setAttribute("video-id", videoId);
}
function finishSameNodeWatchNavigation() {
dispatchNavigation();
}
function mutateOutgoingWatchDescendant() {
const dislike = page.querySelector('[data-fixture-page-kind="watch"] [data-ryd-role="dislike"]');
if (!dislike) return;
const countMarker = document.createElement("span");
countMarker.hidden = true;
countMarker.setAttribute("data-fixture-irrelevant-watch-count-mutation", "true");
dislike.querySelector("#text")?.appendChild(countMarker);
const tooltipMarker = document.createElement("span");
tooltipMarker.hidden = true;
tooltipMarker.setAttribute("data-fixture-irrelevant-watch-tooltip-mutation", "true");
page.querySelector(".ryd-tooltip")?.appendChild(tooltipMarker);
}
function replaceDelayedWatchControl(role) {
const control = page.querySelector(`[data-fixture-page-kind="watch"] [data-ryd-role="${role}"]`);
if (!control) return;
const replacement = control.cloneNode(true);
replacement.setAttribute("data-fixture-watch-replacement", role);
replacement.classList.remove("style-default-active");
replacement.classList.add("style-text");
replacement.querySelector("button")?.setAttribute("aria-pressed", "false");
if (role === "dislike") {
const count = replacement.querySelector("#text");
if (count) count.textContent = "";
}
control.replaceWith(replacement);
}
function replaceCurrentWatchActions({ retainOutgoing = false } = {}) {
const section = page.querySelector('[data-fixture-page-kind="watch"]');
const currentActions = section?.querySelector("#top-level-buttons-computed");
const videoId = section?.dataset.fixtureVideoId;
if (!section || !currentActions || !videoId) return false;
const replacement = currentActions.cloneNode(true);
replacement.setAttribute("data-fixture-watch-actions-replacement", videoId);
replacement.querySelectorAll(".ryd-tooltip").forEach((tooltip) => tooltip.remove());
const controls = replacement.querySelector('[data-ryd-role="buttons"]');
controls?.setAttribute("data-fixture-control-video-id", videoId);
for (const role of ["like", "dislike"]) {
const control = controls?.querySelector(`[data-ryd-role="${role}"]`);
control?.classList.remove("style-default-active");
control?.classList.add("style-text");
const button = control?.querySelector("button");
button?.setAttribute("aria-pressed", "false");
button?.removeAttribute("disabled");
button?.setAttribute("aria-disabled", "false");
}
const dislikeCount = controls?.querySelector('[data-ryd-role="dislike"] #text');
if (dislikeCount) dislikeCount.textContent = "";
currentActions.replaceWith(replacement);
globalThis.__fixtureReplacedWatchActions = currentActions;
if (retainOutgoing) {
const retained = document.createElement("div");
retained.hidden = true;
retained.setAttribute("data-fixture-retained-settling-watch-actions", videoId);
retained.appendChild(currentActions);
document.body.appendChild(retained);
}
return true;
}
function refreshReusedWatchControl(role, videoId) {
const control = page.querySelector(`[data-fixture-page-kind="watch"] [data-ryd-role="${role}"]`);
if (!control) return;
control.querySelector("button")?.setAttribute("aria-label", `${role} refreshed for ${videoId}`);
}
function driftWatchControlBeforeNavigation(role, videoId) {
const button = page.querySelector(`[data-fixture-page-kind="watch"] [data-ryd-role="${role}"] button`);
if (!button) return;
button.setAttribute("aria-label", `${role} drift while ${videoId}`);
button.setAttribute("title", `${role} title drift while ${videoId}`);
}
function replaceActiveMobileOverlay() {
const activeRenderer = page.querySelector("ytm-reel-video-renderer[is-active]");
const overlay = activeRenderer?.querySelector("ytm-reel-player-overlay-renderer");
if (!overlay) return;
const replacement = overlay.cloneNode(true);
replacement.setAttribute("data-fixture-replacement", "true");
const dislike = replacement.querySelector('[data-ryd-role="dislike"]');
dislike?.classList.remove("style-default-active");
dislike?.classList.add("style-text");
dislike?.querySelector("button")?.setAttribute("aria-pressed", "false");
const dislikeCount = dislike?.querySelector("#text");
if (dislikeCount) dislikeCount.textContent = "";
overlay.replaceWith(replacement);
}
function navigateToShortsDescendant(videoId) {
history.pushState({}, "", `/shorts/${videoId}/extra?${marker}`);
dispatchNavigation();
}
document.addEventListener("click", (event) => {
const link = event.target.closest("a[data-fixture-page-kind]");
if (!link) return;
event.preventDefault();
if (link.id === "short-next") {
recycleShort(link.dataset.fixtureVideoId);
dispatchNavigation();
return;
}
navigate(link.dataset.fixturePageKind, link.dataset.fixtureVideoId, {
controlDelay: Number(link.dataset.fixtureControlDelay || 0),
});
});
document.addEventListener(
"ended",
(event) => {
const media = event.target.closest("#fixture-media");
if (!media) return;
if (media.dataset.fixtureAutoplayKind === "shorts") {
recycleShort(media.dataset.fixtureAutoplayId);
return;
}
navigate(media.dataset.fixtureAutoplayKind, media.dataset.fixtureAutoplayId, {
dispatchEvent: false,
});
},
true,
);
window.__navigationFixture = {
anonymizeActiveDesktopShort,
beginSameNodeWatchNavigation,
churnInactiveDesktopShortIdentity,
dispatchEnded() {
page.querySelector("#fixture-media")?.dispatchEvent(new Event("ended"));
},
finishDelayedNavigation,
finishSameNodeWatchNavigation,
driftWatchControlBeforeNavigation,
navigate,
navigateDelayedAnonymousShort,
navigateDelayedShort,
navigateDelayedWatch,
navigateToShortsDescendant,
mutateOutgoingWatchDescendant,
recycleShort,
replaceActiveMobileOverlay,
replaceCurrentWatchActions,
replaceDelayedWatchControl,
refreshReusedWatchControl,
};
render("__INITIAL_PAGE_KIND__", "__VIDEO_ID__");
})();
</script>
</body>
</html>
@@ -0,0 +1,348 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Userscript Shorts browser-test fixture</title>
<style>
html,
body {
width: 100%;
min-height: 768px;
margin: 0;
}
#shorts-root,
[data-short-video],
ytd-reel-video-renderer,
reel-action-bar-view-model,
ytd-like-button-renderer,
ytm-like-button-renderer,
like-button-view-model,
dislike-button-view-model {
display: block;
}
[data-short-video][hidden] {
display: none;
}
ytd-like-button-renderer,
ytm-like-button-renderer {
width: 240px;
min-height: 96px;
}
ytd-reel-video-renderer {
display: block;
width: 420px;
min-height: 640px;
}
reel-action-bar-view-model {
align-items: flex-start;
display: flex;
flex-direction: column;
width: 48px;
min-height: 320px;
}
.ytwReelActionBarViewModelHostDesktopActionButton,
reel-action-bar-view-model > button-view-model {
box-sizing: content-box;
display: block;
height: 70px;
margin: 0;
padding: 0 0 8px;
width: 48px;
}
.ytSpecButtonShapeWithLabelHost {
align-items: center;
display: flex;
flex-direction: column;
height: 70px;
margin: 0;
padding: 0;
width: 48px;
}
reel-action-bar-view-model button {
align-items: center;
border: 0;
box-sizing: border-box;
display: flex;
height: 48px;
justify-content: center;
margin: 0;
min-height: 0;
min-width: 0;
padding: 12px;
width: 48px;
}
like-button-view-model .ytSpecButtonShapeNextIcon {
display: flex;
height: 24px;
width: 24px;
}
like-button-view-model .ytSpecButtonShapeNextIcon svg {
display: block;
height: 24px;
width: 24px;
}
.ytSpecButtonShapeWithLabelLabel {
align-items: center;
box-sizing: border-box;
display: flex;
font-family: Arial, sans-serif;
font-size: 12px;
height: 22px;
justify-content: center;
line-height: 18px;
width: 48px;
}
ytm-like-button-renderer button,
ytd-like-button-renderer button {
min-height: 36px;
min-width: 96px;
}
</style>
</head>
<body>
<div id="fixture-account"></div>
<div id="shorts-root"></div>
<script>
(() => {
const root = document.getElementById("shorts-root");
const isMobile = location.hostname === "m.youtube.com";
if (!customElements.get("like-button-view-model")) {
customElements.define(
"like-button-view-model",
class extends HTMLElement {
get className() {
return undefined;
}
},
);
}
document.getElementById("fixture-account").innerHTML = __SIGNED_IN__
? '<button id="avatar-btn" aria-label="Account menu">Account</button>'
: '<a id="sign-in" href="https://accounts.google.com/ServiceLogin">Sign in</a>';
function controlsMarkup(videoId) {
if (!isMobile) {
return `
<section data-short-video="${videoId}" hidden>
<ytd-reel-video-renderer video-id="${videoId}">
<a href="/shorts/${videoId}" aria-label="Short ${videoId}"></a>
<reel-action-bar-view-model data-ryd-role="buttons">
<like-button-view-model
class="ytLikeButtonViewModelHost ytwReelActionBarViewModelHostDesktopActionButton"
data-ryd-role="like"
>
<label class="ytSpecButtonShapeWithLabelHost">
<button class="ytSpecButtonShapeNextHost" type="button" aria-label="100 likes" aria-pressed="false">
<span class="ytSpecButtonShapeNextIcon" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M12 21"></path></svg></span>
</button>
<div class="ytSpecButtonShapeWithLabelLabel">
<span
id="text"
class="ytAttributedStringHost ytAttributedStringTextAlignmentCenter"
role="text"
>100</span>
</div>
</label>
</like-button-view-model>
<button-view-model data-fixture-control="comments">
<button type="button" aria-label="View comments"><span role="text">12</span></button>
</button-view-model>
<button-view-model data-fixture-control="share">
<button type="button" aria-label="Share"><span role="text">Share</span></button>
</button-view-model>
<button-view-model data-fixture-control="remix">
<button type="button" aria-label="Remix"><span role="text">Remix</span></button>
</button-view-model>
</reel-action-bar-view-model>
</ytd-reel-video-renderer>
</section>
`;
}
return `
<section data-short-video="${videoId}" hidden>
<div id="like-button">
<__SHORTS_RENDERER_TAG__ data-ryd-role="buttons">
<like-button-view-model class="style-text" data-ryd-role="like">
<button type="button" aria-label="100 likes" aria-pressed="false">
<span id="text" class="button-renderer-text" role="text">100</span>
</button>
</like-button-view-model>
<dislike-button-view-model class="style-text" data-ryd-role="dislike">
<button type="button" aria-label="Dislike this video" aria-pressed="false">
<span id="text" class="button-renderer-text" role="text"></span>
</button>
</dislike-button-view-model>
</__SHORTS_RENDERER_TAG__>
</div>
</section>
`;
}
function applyNativeState(section, state) {
const like = section.querySelector('[data-ryd-role="like"]');
const dislike = section.querySelector('[data-ryd-role="dislike"]');
if (isMobile) {
like.classList.toggle("style-default-active", state === "liked");
like.classList.toggle("style-text", state !== "liked");
dislike?.classList.toggle("style-default-active", state === "disliked");
dislike?.classList.toggle("style-text", state !== "disliked");
} else {
like.classList.toggle("style-default-active", state === "liked");
dislike?.classList.toggle("style-default-active", state === "disliked");
}
like.querySelector("button").setAttribute("aria-pressed", String(state === "liked"));
dislike?.querySelector("button")?.setAttribute("aria-pressed", String(state === "disliked"));
section.dataset.nativeState = state;
}
function ensureVideo(videoId) {
let section = root.querySelector(`[data-short-video="${videoId}"]`);
if (!section) {
root.insertAdjacentHTML("beforeend", controlsMarkup(videoId));
section = root.querySelector(`[data-short-video="${videoId}"]`);
applyNativeState(section, "neutral");
}
return section;
}
function activate(videoId, { dispatchNavigation = true, state = "neutral" } = {}) {
const active = ensureVideo(videoId);
for (const section of root.querySelectorAll("[data-short-video]")) {
section.hidden = section !== active;
}
applyNativeState(active, state);
history.pushState({}, "", `/shorts/${videoId}`);
if (dispatchNavigation) {
document.dispatchEvent(new Event("yt-navigate-finish", { bubbles: true }));
}
}
function activeSection() {
return root.querySelector("[data-short-video]:not([hidden])");
}
function removeSyntheticDislike() {
activeSection()?.querySelector("[data-ryd-synthetic-shorts-dislike]")?.remove();
}
function replaceActionBar() {
const section = activeSection();
const actionBar = section?.querySelector("reel-action-bar-view-model");
if (!actionBar) return;
const replacement = actionBar.cloneNode(true);
replacement.querySelector("[data-ryd-synthetic-shorts-dislike]")?.remove();
actionBar.replaceWith(replacement);
applyNativeState(section, section.dataset.nativeState || "neutral");
}
function installNativeDislike() {
const section = activeSection();
const actionBar = section?.querySelector("reel-action-bar-view-model");
if (!actionBar || actionBar.querySelector("dislike-button-view-model")) return;
const nativeDislike = document.createElement("dislike-button-view-model");
nativeDislike.className = "style-text";
nativeDislike.setAttribute("data-ryd-role", "dislike");
const button = document.createElement("button");
button.type = "button";
button.setAttribute("aria-label", "Dislike this video");
button.setAttribute("aria-pressed", "false");
const text = document.createElement("span");
text.id = "text";
text.setAttribute("role", "text");
button.appendChild(text);
nativeDislike.appendChild(button);
actionBar.insertBefore(nativeDislike, actionBar.children[1] ?? null);
}
function removeNativeDislike() {
activeSection()?.querySelector("dislike-button-view-model")?.remove();
}
function replaceInnerButton(role) {
const control = activeSection()?.querySelector(`[data-ryd-role="${role}"]`);
const button = control?.querySelector("button");
if (!button) return;
const replacement = button.cloneNode(true);
if (control.matches("[data-ryd-synthetic-shorts-dislike]")) {
replacement.disabled = true;
replacement.setAttribute("aria-disabled", "true");
}
button.replaceWith(replacement);
}
function recycleActiveRenderer(videoId, { state = "neutral" } = {}) {
const section = activeSection();
const renderer = section?.querySelector("ytd-reel-video-renderer");
if (!section || !renderer) return;
section.setAttribute("data-short-video", videoId);
renderer.setAttribute("video-id", videoId);
const link = renderer.querySelector('a[href*="/shorts/"]');
link?.setAttribute("href", `/shorts/${videoId}`);
applyNativeState(section, state);
history.pushState({}, "", `/shorts/${videoId}`);
document.dispatchEvent(new Event("yt-navigate-finish", { bubbles: true }));
}
function setClassOnlyLiked() {
const section = activeSection();
if (!section) return;
applyNativeState(section, "liked");
section.querySelector('[data-ryd-role="like"]').classList.add("style-default-active");
section.querySelector('[data-ryd-role="like"] button').setAttribute("aria-pressed", "false");
}
root.addEventListener(
"click",
(event) => {
const actionButton = event.target.closest("button");
if (!actionButton) return;
const section = event.target.closest("[data-short-video]");
if (!section) return;
const current = section.dataset.nativeState || "neutral";
if (actionButton.closest('[data-ryd-role="like"]')) {
applyNativeState(section, current === "liked" ? "neutral" : "liked");
}
if (
actionButton.closest('[data-ryd-role="dislike"]') &&
!actionButton.closest("[data-ryd-synthetic-shorts-dislike]")
) {
applyNativeState(section, current === "disliked" ? "neutral" : "disliked");
}
},
true,
);
window.__shortsFixture = {
activate,
activeSection,
ensureVideo,
installNativeDislike,
recycleActiveRenderer,
replaceInnerButton,
removeNativeDislike,
removeSyntheticDislike,
replaceActionBar,
setClassOnlyLiked,
};
activate("__VIDEO_ID__", { dispatchNavigation: false, state: "__INITIAL_STATE__" });
ensureVideo("__SECOND_VIDEO_ID__");
})();
</script>
</body>
</html>
@@ -0,0 +1,250 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Userscript browser-test fixture</title>
<style>
:root {
--yt-spec-10-percent-layer: rgba(255, 255, 255, 0.1);
--yt-spec-base-background: rgb(15, 15, 15);
--yt-spec-icon-disabled: rgba(255, 255, 255, 0.3);
--yt-spec-text-primary: rgb(241, 241, 241);
--yt-spec-text-secondary: rgb(170, 170, 170);
}
html,
body {
background: rgb(15, 15, 15);
width: 100%;
min-height: 768px;
margin: 0;
}
ytd-watch-flexy,
ytd-menu-renderer,
.slim-video-action-bar-actions,
.segmented-buttons,
segmented-like-dislike-button-view-model,
like-button-view-model,
dislike-button-view-model {
display: block;
}
ytd-menu-renderer.ytd-watch-metadata > div {
display: block;
width: min(320px, 100%);
min-height: 36px;
}
#top-row {
width: min(320px, 100%);
}
segmented-like-dislike-button-view-model {
display: flex;
gap: 0;
}
.segmented-buttons {
display: flex;
gap: 8px;
width: 320px;
min-height: 48px;
}
[data-fixture-shell][hidden] {
display: none;
}
[data-ryd-role="like"],
[data-ryd-role="dislike"] {
box-sizing: border-box;
flex: 0 0 96px;
height: 36px;
margin: 0;
padding: 0;
width: 96px;
}
[data-ryd-role="like"] button,
[data-ryd-role="dislike"] button {
align-items: center;
background: rgb(39, 39, 39);
border: 0;
border-radius: 18px;
box-sizing: border-box;
color: rgb(241, 241, 241);
display: inline-flex;
font:
500 14px/20px Arial,
sans-serif;
gap: 6px;
height: 36px;
justify-content: center;
margin: 0;
min-height: 36px;
min-width: 96px;
padding: 0 12px;
width: 96px;
}
[data-ryd-role].style-default-active button {
background: rgb(241, 241, 241);
color: rgb(15, 15, 15);
}
[data-fixture-icon] {
display: block;
flex: 0 0 20px;
height: 20px;
margin: 0;
padding: 0;
width: 20px;
}
[data-ryd-role] #text {
display: block;
font:
500 14px/20px Arial,
sans-serif;
margin: 0;
padding: 0;
}
</style>
</head>
<body>
<!-- A hidden menu-container selects the desktop metadata layout used by the real page. -->
<div id="menu-container" style="display: none"></div>
<div id="fixture-account"></div>
<div id="player" loading="false"></div>
<ytd-watch-flexy id="fixture-watch" video-id="__VIDEO_ID__"></ytd-watch-flexy>
<div id="top-row" data-fixture-shell="desktop">
<ytd-menu-renderer class="ytd-watch-metadata">
<div id="top-level-buttons-computed"></div>
</ytd-menu-renderer>
</div>
<div class="slim-video-action-bar-actions" data-fixture-shell="mobile">
<div id="fixture-mobile-buttons-host" class="segmented-buttons"></div>
</div>
<script>
(() => {
const account = document.getElementById("fixture-account");
const watch = document.getElementById("fixture-watch");
const isMobile = location.hostname === "m.youtube.com";
const desktopHost = document.getElementById("top-level-buttons-computed");
const mobileHost = document.getElementById("fixture-mobile-buttons-host");
const host = isMobile ? mobileHost : desktopHost;
document.querySelector('[data-fixture-shell="desktop"]').hidden = isMobile;
document.querySelector('[data-fixture-shell="mobile"]').hidden = !isMobile;
function setSignedIn(signedIn) {
account.innerHTML = signedIn
? '<button id="avatar-btn" aria-label="Account menu">Account</button>'
: '<a id="sign-in" href="https://accounts.google.com/ServiceLogin">Sign in</a>';
}
function desktopButtonMarkup() {
return `
<segmented-like-dislike-button-view-model data-ryd-role="buttons">
<like-button-view-model class="style-text" data-ryd-role="like">
<button type="button" aria-label="100 likes" aria-pressed="false">
<svg data-fixture-icon="like" aria-hidden="true" viewBox="0 0 24 24">
<path fill="currentColor" d="M8 21H5V9h3v12Zm2 0V9l4-7 2 1v5h5v5l-3 8h-8Z"></path>
</svg>
<span id="text" role="text">100</span>
</button>
</like-button-view-model>
<dislike-button-view-model class="style-text" data-ryd-role="dislike">
<button type="button" aria-label="Dislike this video" aria-pressed="false">
<svg data-fixture-icon="dislike" aria-hidden="true" viewBox="0 0 24 24">
<path fill="currentColor" d="M16 3h3v12h-3V3Zm-2 0v12l-4 7-2-1v-5H3v-5l3-8h8Z"></path>
</svg>
<span id="text" role="text"></span>
</button>
</dislike-button-view-model>
</segmented-like-dislike-button-view-model>
`;
}
function mobileButtonMarkup() {
return `
<like-button-view-model class="style-text" data-ryd-role="like">
<button type="button" aria-label="100 likes" aria-pressed="false">
<svg data-fixture-icon="like" aria-hidden="true" viewBox="0 0 24 24">
<path fill="currentColor" d="M8 21H5V9h3v12Zm2 0V9l4-7 2 1v5h5v5l-3 8h-8Z"></path>
</svg>
<span id="text" class="button-renderer-text" role="text">100</span>
</button>
</like-button-view-model>
<dislike-button-view-model class="style-text" data-ryd-role="dislike">
<button type="button" aria-label="Dislike this video" aria-pressed="false">
<svg data-fixture-icon="dislike" aria-hidden="true" viewBox="0 0 24 24">
<path fill="currentColor" d="M16 3h3v12h-3V3Zm-2 0v12l-4 7-2-1v-5H3v-5l3-8h8Z"></path>
</svg>
<span id="text" class="button-renderer-text" role="text"></span>
</button>
</dislike-button-view-model>
`;
}
function applyNativeState(state) {
const like = host.querySelector('[data-ryd-role="like"]');
const dislike = host.querySelector('[data-ryd-role="dislike"]');
if (!like || !dislike) return;
like.classList.toggle("style-default-active", state === "liked");
like.classList.toggle("style-text", state !== "liked");
dislike.classList.toggle("style-default-active", state === "disliked");
dislike.classList.toggle("style-text", state !== "disliked");
like.querySelector("button").setAttribute("aria-pressed", String(state === "liked"));
dislike.querySelector("button").setAttribute("aria-pressed", String(state === "disliked"));
host.dataset.nativeState = state;
}
function insertButtons(state = "neutral") {
host.innerHTML = isMobile ? mobileButtonMarkup() : desktopButtonMarkup();
applyNativeState(state);
}
function removeButtons() {
host.replaceChildren();
host.dataset.nativeState = "neutral";
}
function navigate(videoId, { state = "neutral", buttons = true } = {}) {
history.pushState({}, "", `/watch?v=${videoId}`);
watch.setAttribute("video-id", videoId);
if (buttons) insertButtons(state);
else removeButtons();
document.dispatchEvent(new Event("yt-navigate-finish", { bubbles: true }));
}
// Model the native YouTube toggle before userscript listeners observe the bubbled click.
host.addEventListener(
"click",
(event) => {
const like = event.target.closest('[data-ryd-role="like"]');
const dislike = event.target.closest('[data-ryd-role="dislike"]');
const current = host.dataset.nativeState || "neutral";
if (like) applyNativeState(current === "liked" ? "neutral" : "liked");
if (dislike) applyNativeState(current === "disliked" ? "neutral" : "disliked");
},
true,
);
window.__youtubeFixture = {
applyNativeState,
insertButtons,
navigate,
removeButtons,
setSignedIn,
};
setSignedIn(__SIGNED_IN__);
if (__INITIAL_BUTTONS__) insertButtons("__INITIAL_STATE__");
})();
</script>
</body>
</html>
+386
View File
@@ -0,0 +1,386 @@
const fs = require("fs");
const path = require("path");
const REPOSITORY_ROOT = path.resolve(__dirname, "../../..");
const GENERATED_USERSCRIPT =
process.env.RYD_USERSCRIPT_ARTIFACT ||
path.join(REPOSITORY_ROOT, "Extensions", "UserScript", "Return Youtube Dislike.user.js");
const WATCH_FIXTURE = fs.readFileSync(path.join(__dirname, "fixtures", "watch-page.html"), "utf8");
const SHORTS_FIXTURE = fs.readFileSync(path.join(__dirname, "fixtures", "shorts-page.html"), "utf8");
const NAVIGATION_FIXTURE = fs.readFileSync(path.join(__dirname, "fixtures", "navigation-page.html"), "utf8");
const API_ORIGIN = "https://returnyoutubedislikeapi.com";
const CREDENTIAL_KEY = "rydVoteCredentials";
const VIDEO_A = "abcdefghijk";
const VIDEO_B = "zyxwvutsrqp";
const ZERO_DIFFICULTY_PUZZLE = {
challenge: Buffer.alloc(16).toString("base64"),
difficulty: 0,
};
const delay = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
function requestKey(method, pathname) {
return `${method.toUpperCase()} ${pathname}`;
}
function parseRequestBody(request) {
const text = request.postData();
if (!text) return null;
try {
return JSON.parse(text);
} catch {
return text;
}
}
function jsonHeaders() {
return {
"access-control-allow-headers": "Accept, Content-Type",
"access-control-allow-methods": "GET, POST, OPTIONS",
"access-control-allow-origin": "*",
"content-type": "application/json; charset=utf-8",
};
}
function createFakeBackend({ countsByVideo = {}, countDelayByVideo = {}, fixture = {} } = {}) {
const blockedRequests = [];
const requests = [];
const responsePlans = new Map();
const fixtureOptions = {
initialButtons: fixture.initialButtons !== false,
initialState: fixture.initialState || "neutral",
signedIn: fixture.signedIn !== false,
};
function enqueue(method, pathname, response) {
const key = requestKey(method, pathname);
const queue = responsePlans.get(key) || [];
queue.push(response);
responsePlans.set(key, queue);
}
function defer(method, pathname) {
let releaseResponse;
let resolveSeen;
let released = false;
const seen = new Promise((resolve) => {
resolveSeen = resolve;
});
enqueue(method, pathname, (record) => {
resolveSeen(record);
return new Promise((resolve) => {
releaseResponse = resolve;
});
});
return {
get released() {
return released;
},
release(response) {
if (released) return;
if (!releaseResponse) {
throw new Error(`Cannot release ${requestKey(method, pathname)} before its request is seen`);
}
released = true;
releaseResponse(response);
},
seen,
};
}
function requestsFor(method, pathname) {
return requests.filter((entry) => entry.method === method.toUpperCase() && entry.pathname === pathname);
}
function takePlannedResponse(record) {
const queue = responsePlans.get(requestKey(record.method, record.pathname));
if (!queue?.length) return null;
const planned = queue.shift();
return typeof planned === "function" ? planned(record) : planned;
}
function defaultApiResponse(record) {
if (record.method === "GET" && record.pathname === "/configs/selectors") {
return { body: {} };
}
if (record.method === "GET" && record.pathname === "/votes") {
const videoId = record.query.videoId;
const counts = countsByVideo[videoId] || { dislikes: 25, likes: 100 };
return {
body: { ...counts, rating: 4.5 },
delayMs: countDelayByVideo[videoId] || 0,
};
}
if (record.method === "GET" && record.pathname === "/puzzle/registration") {
return { body: ZERO_DIFFICULTY_PUZZLE };
}
if (record.method === "POST" && record.pathname === "/puzzle/registration") {
return { body: true };
}
if (record.method === "POST" && record.pathname === "/interact/vote") {
return { body: ZERO_DIFFICULTY_PUZZLE };
}
if (record.method === "POST" && record.pathname === "/interact/confirmVote") {
return { body: true };
}
return null;
}
async function handle(route) {
const request = route.request();
const url = new URL(request.url());
if (["www.youtube.com", "m.youtube.com"].includes(url.hostname) && request.resourceType() === "document") {
const isShorts = url.pathname.startsWith("/shorts/");
const videoId = isShorts ? url.pathname.slice(8) || VIDEO_A : url.searchParams.get("v") || VIDEO_A;
const isNavigationFixture = url.searchParams.get("rydNavigationFixture") === "1";
const fixtureTemplate = isNavigationFixture ? NAVIGATION_FIXTURE : isShorts ? SHORTS_FIXTURE : WATCH_FIXTURE;
const html = fixtureTemplate
.replaceAll("__VIDEO_ID__", videoId)
.replaceAll("__SECOND_VIDEO_ID__", VIDEO_B)
.replaceAll("__INITIAL_PAGE_KIND__", isShorts ? "shorts" : url.pathname === "/watch" ? "watch" : "channel")
.replaceAll(
"__SHORTS_RENDERER_TAG__",
url.hostname === "m.youtube.com" ? "ytm-like-button-renderer" : "ytd-like-button-renderer",
)
.replaceAll("__SIGNED_IN__", String(fixtureOptions.signedIn))
.replaceAll("__INITIAL_BUTTONS__", String(fixtureOptions.initialButtons))
.replaceAll("__INITIAL_STATE__", fixtureOptions.initialState);
await route.fulfill({
status: 200,
contentType: "text/html; charset=utf-8",
body: html,
});
return;
}
if (url.origin !== API_ORIGIN) {
blockedRequests.push({
method: request.method(),
resourceType: request.resourceType(),
url: request.url(),
});
await route.abort("blockedbyclient");
return;
}
if (request.method() === "OPTIONS") {
await route.fulfill({ status: 204, headers: jsonHeaders(), body: "" });
return;
}
const record = {
at: Date.now(),
body: parseRequestBody(request),
method: request.method(),
pathname: url.pathname,
query: Object.fromEntries(url.searchParams.entries()),
url: url.toString(),
};
requests.push(record);
const plannedResponse = takePlannedResponse(record);
const response = (plannedResponse ? await plannedResponse : plannedResponse) || defaultApiResponse(record);
if (!response) {
blockedRequests.push({
method: request.method(),
resourceType: request.resourceType(),
url: request.url(),
});
await route.abort("blockedbyclient");
return;
}
if (response.delayMs) await delay(response.delayMs);
const body = response.body === undefined ? null : response.body;
await route.fulfill({
status: response.status || 200,
headers: { ...jsonHeaders(), ...(response.headers || {}) },
body: typeof body === "string" ? body : JSON.stringify(body),
});
record.respondedAt = Date.now();
}
return {
blockedRequests,
defer,
enqueue,
handle,
requests,
requestsFor,
};
}
async function installGmEnvironment(context, initialValues = {}) {
await context.addInitScript(
({ initialValues: seededValues, storagePrefix }) => {
if (!location.hostname.endsWith("youtube.com")) return;
const storageKey = (key) => `${storagePrefix}${key}`;
const read = (key, fallbackValue) => {
const stored = localStorage.getItem(storageKey(key));
if (stored === null) return fallbackValue;
try {
return JSON.parse(stored);
} catch {
return fallbackValue;
}
};
const write = (key, value) => {
if (value === undefined) localStorage.removeItem(storageKey(key));
else localStorage.setItem(storageKey(key), JSON.stringify(value));
};
for (const [key, value] of Object.entries(seededValues)) {
if (localStorage.getItem(storageKey(key)) === null) write(key, value);
}
globalThis.__gmCalls = [];
const getValue = async (key, fallbackValue) => {
globalThis.__gmCalls.push({ operation: "get", key });
return read(key, fallbackValue);
};
const setValue = async (key, value) => {
globalThis.__gmCalls.push({ operation: "set", key, value });
write(key, value);
};
const deleteValue = async (key) => {
globalThis.__gmCalls.push({ operation: "delete", key });
localStorage.removeItem(storageKey(key));
};
const addStyle = (css) => {
const style = document.createElement("style");
style.dataset.rydGmStyle = "true";
style.textContent = css;
(document.head || document.documentElement).appendChild(style);
return style;
};
globalThis.GM = { getValue, setValue, deleteValue, addStyle };
globalThis.GM_getValue = getValue;
globalThis.GM_setValue = setValue;
globalThis.GM_deleteValue = deleteValue;
globalThis.GM_addStyle = addStyle;
},
{
initialValues,
storagePrefix: "ryd-e2e-gm:",
},
);
}
async function installHermeticRoutes(context, backend) {
await context.route("**/*", (route) => backend.handle(route));
}
async function openWatchFixture(page, videoId = VIDEO_A, { hostname = "www.youtube.com" } = {}) {
await page.goto(`https://${hostname}/watch?v=${videoId}`, { waitUntil: "domcontentloaded" });
}
async function openShortsFixture(page, videoId = VIDEO_A, { hostname = "www.youtube.com" } = {}) {
await page.goto(`https://${hostname}/shorts/${videoId}`, { waitUntil: "domcontentloaded" });
}
async function openNavigationFixture(
page,
{ hostname = "www.youtube.com", pageKind = "channel", videoId = VIDEO_A } = {},
) {
const marker = "rydNavigationFixture=1";
const path =
pageKind === "shorts"
? `/shorts/${videoId}?${marker}`
: pageKind === "watch"
? `/watch?v=${videoId}&${marker}`
: `/@FixtureChannel?${marker}`;
await page.goto(`https://${hostname}${path}`, { waitUntil: "domcontentloaded" });
}
async function forbidUnsafeHtmlSinks(page) {
await page.evaluate(() => {
const innerHtmlDescriptor = Object.getOwnPropertyDescriptor(Element.prototype, "innerHTML");
if (!innerHtmlDescriptor?.get || !innerHtmlDescriptor?.set) {
throw new Error("Element.innerHTML descriptor is unavailable");
}
globalThis.__rydUnsafeHtmlSinkCalls = [];
const reject = (sink) => {
globalThis.__rydUnsafeHtmlSinkCalls.push(sink);
throw new TypeError(`${sink} is forbidden by the Trusted Types fixture`);
};
Object.defineProperty(Element.prototype, "innerHTML", {
configurable: innerHtmlDescriptor.configurable,
enumerable: innerHtmlDescriptor.enumerable,
get: innerHtmlDescriptor.get,
set() {
reject("Element.innerHTML");
},
});
Element.prototype.insertAdjacentHTML = function () {
reject("Element.insertAdjacentHTML");
};
});
}
function overrideBooleanOption(source, optionName, value) {
const optionPattern = new RegExp(`${optionName}:\\s*(?:true|false)`, "g");
const matches = source.match(optionPattern) || [];
if (matches.length !== 1) {
throw new Error(`Expected one ${optionName} option in generated userscript, found ${matches.length}`);
}
return source.replace(optionPattern, `${optionName}: ${value}`);
}
async function injectGeneratedUserscript(page, { coloredThumbs, disableVoteSubmission = false, rateBarEnabled } = {}) {
if (!fs.existsSync(GENERATED_USERSCRIPT)) {
throw new Error(`Generated userscript is missing: ${GENERATED_USERSCRIPT}`);
}
if (!disableVoteSubmission && rateBarEnabled === undefined && coloredThumbs === undefined) {
await page.addScriptTag({ path: GENERATED_USERSCRIPT });
return;
}
let source = fs.readFileSync(GENERATED_USERSCRIPT, "utf8");
if (disableVoteSubmission) {
source = overrideBooleanOption(source, "disableVoteSubmission", true);
}
if (rateBarEnabled !== undefined) {
source = overrideBooleanOption(source, "rateBarEnabled", rateBarEnabled);
}
if (coloredThumbs !== undefined) {
source = overrideBooleanOption(source, "coloredThumbs", coloredThumbs);
}
await page.addScriptTag({ content: source });
}
async function readGmValue(page, key) {
return page.evaluate((storageKey) => globalThis.GM.getValue(storageKey, null), key);
}
module.exports = {
API_ORIGIN,
CREDENTIAL_KEY,
GENERATED_USERSCRIPT,
VIDEO_A,
VIDEO_B,
ZERO_DIFFICULTY_PUZZLE,
createFakeBackend,
forbidUnsafeHtmlSinks,
injectGeneratedUserscript,
installGmEnvironment,
installHermeticRoutes,
openNavigationFixture,
openShortsFixture,
openWatchFixture,
readGmValue,
};
+168
View File
@@ -0,0 +1,168 @@
# Live YouTube smoke
This suite attaches over CDP to an already-running Brave profile. It is deliberately separate from `test:all` and CI.
The same page-level checks run in either userscript or extension mode; installation, storage, and protocol failure
coverage remain in the hermetic suites.
## Browser preparation
1. Open `brave://inspect/#remote-debugging` in the target Brave profile and enable **Allow remote debugging for this
browser instance**. Set `RYD_CDP_ENDPOINT` to the explicit HTTP or WebSocket endpoint exposed by that Brave instance;
`http://127.0.0.1:9222` is a common HTTP example, but use the actual port shown by Brave.
2. Use a dedicated two-item playlist containing two allowlisted public or unlisted test videos. Both must be accessible
to the signed-in profile and must have different rendered dislike counts.
3. Enable exactly one runtime:
- `userscript`: disable the browser extension and every other Return YouTube Dislike userscript, run
`npm run build:live:userscript`, import
`test-results/live-build/userscript/Return Youtube Dislike.user.js` into Tampermonkey, and enable it.
- `extension`: disable every Return YouTube Dislike userscript and any store-installed copy of the extension, run
`npm run build:live:extension`, then enable or reload only the unpacked extension at
`Extensions/combined/dist/chrome`.
Every live build receives a fresh random build ID even when its semantic version is unchanged. Import or reload the
runtime **after the final live build command**; rebuilding again invalidates the installed copy for this smoke.
4. Keep the Brave window open and do not interact with the tab created by the suite. The suite creates, uses, and closes
only its own tab.
Only the explicit live-test builds expose page markers containing their runtime, version, and exact build ID. Normal
production builds do not expose them. The suite reads the expected ID from the generated `live-build.json`, verifies
that the installed runtime exposes that exact ID and expected version, and verifies that the other runtime marker is
absent before every scenario. A stale installed script or extension therefore fails even when it has the same version.
There is no environment override for the build ID.
The read-only smoke still loads real YouTube and allows the installed runtime to read from the production RYD API,
including eager registration when the selected runtime has no confirmed identity. It does not mock API responses, but
it blocks production `POST /interact/*` requests before transmission as described below.
## Non-voting smoke
Set the following in PowerShell. Video IDs are the 11-character values from YouTube URLs, and the playlist URL must
start on `RYD_LIVE_WATCH_A`.
```powershell
$env:RYD_LIVE_YOUTUBE="1"
$env:RYD_LIVE_PRODUCTION_API="1"
$env:RYD_CDP_ENDPOINT="http://127.0.0.1:9222"
$env:RYD_LIVE_RUNTIME="userscript"
$env:RYD_LIVE_EXPECTED_CHANNEL="@your-test-channel"
$env:RYD_LIVE_WATCH_A="AAAAAAAAAAA"
$env:RYD_LIVE_WATCH_B="BBBBBBBBBBB"
$env:RYD_LIVE_SHORT="CCCCCCCCCCC"
$env:RYD_LIVE_PLAYLIST_URL="https://www.youtube.com/watch?v=AAAAAAAAAAA&list=PLAYLIST_ID"
# Optional overrides for the cold channel-navigation smoke. These are the defaults:
$env:RYD_LIVE_NAV_CHANNEL_URL="https://www.youtube.com/@SmashTrash"
$env:RYD_LIVE_NAV_SHORT="iKQhN7omLM4"
# Optional; the consecutive watch-sidebar stress defaults to three hops and accepts 1 through 10.
$env:RYD_LIVE_SIDEBAR_HOPS="3"
npm run test:live:youtube
```
The navigation smoke performs a real hard load and reload of `RYD_LIVE_NAV_CHANNEL_URL`, finds an exact visible link to
`RYD_LIVE_NAV_SHORT`, and clicks that link. It fails rather than falling back to direct navigation when the configured
channel no longer contains the exact card. It proves the channel-to-Short transition reused the same document, verifies
that the selected runtime initialized the current Short (including exactly one visible synthetic control in userscript
mode), clicks YouTube's visible **Next video** control, proves that transition also reused the document, and verifies a
new current video ID and initialized control. The channel-to-Short check runs first in the fresh suite tab so earlier
watch or Shorts visits cannot warm the page lifecycle it is meant to exercise.
YouTube can occasionally focus the visible **Next video** button without acting on its first trusted click. The live
driver waits five seconds for that first click, prints `LIVE_CHECKPOINT shorts-next-control.retrying`, and makes exactly
one more trusted click with the remaining 25-second navigation budget. If the URL still does not advance, the scenario
fails; it never loops or clicks a reaction control as part of this retry.
After the Shorts URL advances, the driver deliberately leaves playback running while YouTube hydrates the new reel,
the production `/votes` response arrives, and the current dislike control renders. It pauses only after those checks,
so a newly selected Short may play briefly before the `LIVE_CHECKPOINT playback.paused` message appears.
The watch-sidebar stress starts with the allowlisted `RYD_LIVE_WATCH_A`, then takes the first eligible visible
`#related` watch link on each page for the configured number of consecutive SPA hops. Previously visited IDs are
skipped. Every hop waits for the exact production `/votes?videoId=<target>` response and a rendered dislike count,
requires exactly one visible ratio bar for the selected runtime with valid reaction-control geometry, and samples that
same bar and count for four seconds to catch delayed YouTube pruning. It never clicks Like or Dislike. Deterministic
evidence paths are overwritten on each run at
`test-results/live-youtube/sidebar-stress/{runtime}-sidebar-hop-{01..N}.png`. The same scenario runs in userscript and
extension mode.
Every non-voting live scenario installs a BrowserContext deny route before it starts and aborts every production
`POST /interact/*` request. The accompanying request observer intentionally does not require a page frame, so attempts
from an extension or service worker also fail the scenario. The guard applies to the entire attached browser context;
do not use another tab in that Brave profile to react to a video while the smoke is running.
Automatic media-ended transitions for both watch pages and Shorts stay in the deterministic hermetic Playwright suite.
This production smoke intentionally exercises the visible Shorts **Next video** control only: live autoplay timing,
recommendation queues, ads, and account experiments are not stable enough to make an exact production transition a
reliable gate. The existing two-item playlist smoke continues to cover an explicit watch-page SPA transition.
The default channel dataset can drift as its public page changes. Override both variables with a channel URL and Short
that are deliberately kept together. The channel URL is restricted to a plain HTTPS `youtube.com/@handle` page or its
`featured`, `shorts`, or `videos` tab; the target must be an 11-character video ID.
An additional cold channel-to-watch scenario is available only when its exact link is deterministic on the configured
channel page. Opt in with an 11-character ID; otherwise that scenario is skipped:
```powershell
$env:RYD_LIVE_NAV_WATCH="DDDDDDDDDDD"
```
For the interactive Brave run, use `npm run test:live:youtube:interactive`. Before requesting any reaction approval,
it runs the read-only scenarios plus responsive visual checks. Enter `SKIP` at the prompt to finish without reactions.
The visual pass checks the watch-page ratio bar at widths 1280, 768, and 390. In userscript mode it also checks the
modern Shorts synthetic dislike control, its geometry, and its rendered count at those widths. In extension mode it
checks the active reel's native Like/Dislike pair at all three widths: exact action-host, button, and icon sizes;
typography; spacing; common reel ownership; ordering; and viewport containment. Cropped evidence images are written
under `test-results/live-youtube/responsive/`. If YouTube's native Like/Dislike pill is horizontally clipped by its own
mobile page overflow, the ratio bar may share only that same native left/right footprint; any extra overflow introduced
by the RYD bar still fails.
The interactive runner prints `LIVE_STAGE_START`, `LIVE_STAGE_COMPLETE`, and `LIVE_CHECKPOINT` records while it works.
In particular, `LIVE_CHECKPOINT playback.paused` means the runner deliberately paused the current YouTube video while
validating it; a stationary frame after that message is expected and is not evidence that Brave froze. Navigation,
account, runtime, control, and production `/votes` waits have their own checkpoints, so the last line identifies the
pending operation.
If a scenario fails, the runner records page errors, unhandled promise rejections, console errors, and the latest RYD
API request outcomes before closing its test tab. It writes a JSON snapshot under
`test-results/live-youtube/diagnostics/` and prints its absolute path as `LIVE_FAILURE_SNAPSHOT`. The snapshot includes
the URL, runtime markers, Shorts renderer IDs and links, action-bar and synthetic-control ownership, video paused state,
and recent API paths/statuses. Anonymous identity and proof-related query values are redacted. Preserve this file when
reporting a live-only failure; ordinary direct interactive runs otherwise have no Playwright trace.
Use `RYD_LIVE_RUNTIME="extension"` after manually switching the enabled runtime to execute the same smoke against the
extension. The expected version defaults to the local userscript candidate version or root package version; set
`RYD_LIVE_EXPECTED_VERSION` only when deliberately validating another installed build.
## Optional production reaction matrix
The reaction test is skipped by default. It covers all six Like/Dislike state transitions on `RYD_LIVE_WATCH_B` and the
allowlisted Short, asserts one logical production handshake for every transition, and returns each video to its initial
reaction state. A handshake contains one to three matching `/interact/vote` puzzle requests followed by exactly one
matching successful `/interact/confirmVote`. Any fourth vote request, changed identity/video/value, extra confirmation,
or other interaction traffic fails the run. It does not retry cleanup blindly if the state cannot be verified.
If click dispatch, the post-click state wait, or a handshake fails after a vote attempt while the UI already appears to
be back at its initial state, cleanup does not trust the UI alone: it confirms an away-and-back reaction round trip with
the same anonymous identity. A failed cleanup confirmation or identity mismatch reports the exact video URL for manual
restoration.
The matrix also captures the initial state and every post-transition state, for seven watch images and seven Shorts
images. Before each screenshot it reads both `aria-pressed` values, verifies the exact expected mutually-exclusive
state, and requires a numeric dislike count. Watch captures additionally require a visible, non-overlapping ratio bar
with sane geometry. Userscript Shorts captures reuse the strict native-vs-synthetic control geometry checks, including
the 48x78 action host, 24x24 icon, typography, spacing, active-reel ownership, and duplicate-control checks. Evidence is
written with deterministic names under
`test-results/live-youtube/reactions/{runtime}/{watch|short}-{0..6}-{neutral|liked|disliked}.png`. A failed visual check
still enters the same verified restoration path before the scenario reports the failure.
The exact live-build marker proves that the freshly generated test build is present and that the opposite runtime is
absent. It cannot detect an additional normal build of the same runtime, so the manual single-runtime preparation above
is mandatory.
Only after approving those real YouTube and production RYD writes, create a runtime-and-video-specific token immediately
before the run. It expires after two minutes and is consumed once locally, so the userscript token cannot silently
authorize a later extension run.
```powershell
$env:RYD_LIVE_VOTES="$env:RYD_LIVE_RUNTIME`:$env:RYD_LIVE_WATCH_B`:$([DateTimeOffset]::UtcNow.ToUnixTimeSeconds())"
npm run test:live:youtube
Remove-Item Env:RYD_LIVE_VOTES
```
After the live run, turn off **Allow remote debugging for this browser instance** or restart the test Brave profile.
@@ -0,0 +1,358 @@
const fs = require("node:fs");
const path = require("node:path");
const API_ORIGIN = "https://returnyoutubedislikeapi.com";
const UNHANDLED_REJECTION_PREFIX = "__RYD_LIVE_UNHANDLED_REJECTION__";
const MAX_BROWSER_SIGNALS = 50;
const MAX_API_REQUESTS = 50;
function serializeError(error) {
if (!error) return null;
if (typeof error === "string") return { message: error, name: "Error", stack: null };
return {
message: String(error.message ?? error),
name: String(error.name ?? "Error"),
stack: typeof error.stack === "string" ? error.stack : null,
};
}
function installUnhandledRejectionListener(prefix) {
if (globalThis.__rydLiveUnhandledRejectionListenerInstalled) return;
globalThis.__rydLiveUnhandledRejectionListenerInstalled = true;
globalThis.addEventListener("unhandledrejection", (event) => {
const reason = event.reason;
let message;
try {
if (reason instanceof Error)
message = `${reason.name}: ${reason.message}${reason.stack ? `\n${reason.stack}` : ""}`;
else if (typeof reason === "string") message = reason;
else message = JSON.stringify(reason);
} catch {
message = String(reason);
}
console.error(`${prefix}${message ?? "Unknown rejection"}`);
});
}
function readLivePageState() {
const readRect = (element) => {
const rect = element.getBoundingClientRect();
return {
bottom: rect.bottom,
height: rect.height,
left: rect.left,
right: rect.right,
top: rect.top,
width: rect.width,
};
};
const isVisible = (element) => {
const rect = element.getBoundingClientRect();
const style = getComputedStyle(element);
return (
rect.width > 0 &&
rect.height > 0 &&
rect.bottom > 0 &&
rect.right > 0 &&
rect.top < innerHeight &&
rect.left < innerWidth &&
style.display !== "none" &&
style.visibility !== "hidden"
);
};
const actionBarSelector = "reel-action-bar-view-model, .slim-video-action-bar-actions";
const syntheticSelector = "[data-ryd-synthetic-shorts-dislike]";
const renderers = [...document.querySelectorAll("ytd-reel-video-renderer, ytm-reel-video-renderer")];
return {
document: {
readyState: document.readyState,
title: document.title,
url: location.href,
viewport: { height: innerHeight, width: innerWidth },
},
runtimeMarkers: {
extensionBuild: document.documentElement.getAttribute("data-ryd-extension-build"),
extension: document.documentElement.getAttribute("data-ryd-extension-version"),
userscriptBuild: document.documentElement.getAttribute("data-ryd-userscript-build"),
userscript: document.documentElement.getAttribute("data-ryd-userscript-version"),
},
renderers: renderers.map((renderer, index) => ({
actionBars: renderer.querySelectorAll(actionBarSelector).length,
ariaHidden: renderer.getAttribute("aria-hidden"),
index,
isActive: renderer.getAttribute("is-active"),
links: [...renderer.querySelectorAll('a[href*="/shorts/"]')].map((link) => ({
href: link.getAttribute("href"),
visible: isVisible(link),
})),
rect: readRect(renderer),
syntheticControls: renderer.querySelectorAll(syntheticSelector).length,
tagName: renderer.tagName.toLowerCase(),
videoId: renderer.getAttribute("video-id"),
visible: isVisible(renderer),
})),
actionBars: [...document.querySelectorAll(actionBarSelector)].map((actionBar, index) => ({
index,
nativeDislikes: actionBar.querySelectorAll("dislike-button-view-model, #dislike-button").length,
nativeLikes: actionBar.querySelectorAll("like-button-view-model, #like-button").length,
rect: readRect(actionBar),
syntheticControls: actionBar.querySelectorAll(syntheticSelector).length,
tagName: actionBar.tagName.toLowerCase(),
videoId: actionBar.closest("ytd-reel-video-renderer, ytm-reel-video-renderer")?.getAttribute("video-id") ?? null,
visible: isVisible(actionBar),
})),
syntheticControls: [...document.querySelectorAll(syntheticSelector)].map((control, index) => ({
ariaPressed: control.querySelector("button")?.getAttribute("aria-pressed") ?? null,
index,
rect: readRect(control),
text: (control.textContent ?? "").replace(/\s+/g, " ").trim(),
videoId: control.getAttribute("data-ryd-video-id"),
visible: isVisible(control),
})),
videos: [...document.querySelectorAll("video")].map((video, index) => ({
currentTime: video.currentTime,
ended: video.ended,
index,
paused: video.paused,
readyState: video.readyState,
rect: readRect(video),
visible: isVisible(video),
})),
};
}
function diagnosticApiUrl(value) {
let url;
try {
url = new URL(value);
} catch {
return null;
}
if (url.origin !== API_ORIGIN) return null;
const query = {};
for (const [name, requestValue] of url.searchParams) {
query[name] = /auth|key|puzzle|secret|solution|token|user/i.test(name) ? "<redacted>" : requestValue;
}
return { pathname: url.pathname, query };
}
function timestampForFilename(date) {
return date.toISOString().replace(/[:.]/g, "-");
}
class LiveRunDiagnostics {
constructor(
page,
context,
{
clock = () => new Date(),
fileSystem = fs,
log = console.log,
outputDirectory = path.resolve(__dirname, "../../../../test-results/live-youtube/diagnostics"),
runtime = null,
} = {},
) {
this.browserSignals = [];
this.clock = clock;
this.context = context;
this.currentCheckpoint = null;
this.currentStage = "startup";
this.fileSystem = fileSystem;
this.log = log;
this.outputDirectory = outputDirectory;
this.page = page;
this.recentApiRequests = [];
this.requestRecords = new WeakMap();
this.runtime = runtime;
this.started = false;
this.onConsole = this.onConsole.bind(this);
this.onPageError = this.onPageError.bind(this);
this.onRequest = this.onRequest.bind(this);
this.onRequestFailed = this.onRequestFailed.bind(this);
this.onResponse = this.onResponse.bind(this);
}
now() {
return this.clock().toISOString();
}
appendCapped(collection, value, maximum) {
collection.push(value);
if (collection.length > maximum) collection.splice(0, collection.length - maximum);
}
recordBrowserSignal(type, details) {
const signal = {
at: this.now(),
checkpoint: this.currentCheckpoint,
stage: this.currentStage,
type,
...details,
};
this.appendCapped(this.browserSignals, signal, MAX_BROWSER_SIGNALS);
this.log(`LIVE_BROWSER_SIGNAL ${type} ${JSON.stringify(details)}`);
}
onPageError(error) {
this.recordBrowserSignal("pageerror", { error: serializeError(error) });
}
onConsole(message) {
if (message.type() !== "error") return;
const text = message.text();
const location = message.location?.() ?? {};
if (text.startsWith(UNHANDLED_REJECTION_PREFIX)) {
this.recordBrowserSignal("unhandledrejection", {
location,
message: text.slice(UNHANDLED_REJECTION_PREFIX.length),
});
return;
}
this.recordBrowserSignal("console.error", { location, message: text });
}
onRequest(request) {
const apiUrl = diagnosticApiUrl(request.url());
if (!apiUrl) return;
const record = {
...apiUrl,
at: this.now(),
failure: null,
method: request.method(),
resourceType: typeof request.resourceType === "function" ? request.resourceType() : null,
checkpoint: this.currentCheckpoint,
stage: this.currentStage,
status: null,
};
this.appendCapped(this.recentApiRequests, record, MAX_API_REQUESTS);
this.requestRecords.set(request, record);
}
onResponse(response) {
const record = this.requestRecords.get(response.request());
if (!record) return;
record.status = response.status();
}
onRequestFailed(request) {
const record = this.requestRecords.get(request);
if (!record) return;
record.failure = request.failure()?.errorText ?? "Unknown request failure";
}
async start() {
if (this.started) return;
this.started = true;
this.page.on("console", this.onConsole);
this.page.on("pageerror", this.onPageError);
this.context.on("request", this.onRequest);
this.context.on("requestfailed", this.onRequestFailed);
this.context.on("response", this.onResponse);
await this.page.addInitScript(installUnhandledRejectionListener, UNHANDLED_REJECTION_PREFIX);
if (!this.page.isClosed()) {
await this.page.evaluate(installUnhandledRejectionListener, UNHANDLED_REJECTION_PREFIX);
}
}
stop() {
if (!this.started) return;
this.started = false;
this.page.off("console", this.onConsole);
this.page.off("pageerror", this.onPageError);
this.context.off("request", this.onRequest);
this.context.off("requestfailed", this.onRequestFailed);
this.context.off("response", this.onResponse);
}
checkpoint(name, details = {}) {
this.currentCheckpoint = name;
this.log(`LIVE_CHECKPOINT ${name} ${JSON.stringify(details)}`);
}
stageStarted(name) {
this.currentCheckpoint = null;
this.currentStage = name;
this.log(`LIVE_STAGE_START ${name}`);
}
stageCompleted(name, startedAt) {
this.currentCheckpoint = null;
this.currentStage = name;
this.log(`LIVE_STAGE_COMPLETE ${name} durationMs=${Date.now() - startedAt}`);
}
stageFailed(name, startedAt, error) {
this.currentStage = name;
this.log(
`LIVE_STAGE_FAILED ${name} durationMs=${Date.now() - startedAt} error=${JSON.stringify(error?.message ?? String(error))}`,
);
}
async snapshot(error) {
let pageState = null;
let pageStateError = null;
let url = null;
try {
url = this.page.url();
} catch {
// A disconnected page still produces a useful harness/request snapshot.
}
if (!this.page.isClosed()) {
try {
pageState = await this.page.evaluate(readLivePageState);
} catch (snapshotError) {
pageStateError = serializeError(snapshotError);
}
}
return {
browserSignals: this.browserSignals,
capturedAt: this.now(),
currentCheckpoint: this.currentCheckpoint,
currentStage: this.currentStage,
error: serializeError(error),
pageClosed: this.page.isClosed(),
pageState,
pageStateError,
recentApiRequests: this.recentApiRequests,
runtime: this.runtime,
url,
};
}
async persistFailureSnapshot(error) {
const snapshot = await this.snapshot(error);
this.fileSystem.mkdirSync(this.outputDirectory, { recursive: true });
const fileName = `failure-${timestampForFilename(this.clock())}.json`;
const snapshotPath = path.join(this.outputDirectory, fileName);
this.fileSystem.writeFileSync(snapshotPath, `${JSON.stringify(snapshot, null, 2)}\n`, "utf8");
return snapshotPath;
}
}
async function runLoggedStage(diagnostics, name, action) {
const startedAt = Date.now();
diagnostics.stageStarted(name);
try {
const result = await action();
diagnostics.stageCompleted(name, startedAt);
return result;
} catch (error) {
diagnostics.stageFailed(name, startedAt, error);
throw error;
}
}
module.exports = {
API_ORIGIN,
LiveRunDiagnostics,
UNHANDLED_REJECTION_PREFIX,
diagnosticApiUrl,
installUnhandledRejectionListener,
readLivePageState,
runLoggedStage,
serializeError,
};
@@ -0,0 +1,138 @@
const readline = require("node:readline");
const { chromium } = require("@playwright/test");
const {
createExtensionLiveRuntimeAdapter,
createUserscriptLiveRuntimeAdapter,
} = require("../../../e2e/live-runtime-adapter");
const { createSharedLiveScenarioRunner } = require("../../../e2e/shared-live-scenarios");
const { consumeLiveVoteApproval, hasFreshVoteApproval, readLiveOptions } = require("../../live/live-options");
const { LiveRunDiagnostics, runLoggedStage } = require("./live-diagnostics");
const { LiveYoutubeDriver, VoteTrafficRecorder } = require("./live-youtube-driver");
const scenarioRunner = createSharedLiveScenarioRunner();
function readApprovalLine() {
const input = readline.createInterface({ input: process.stdin, terminal: false });
return new Promise((resolve) => {
input.once("line", (line) => {
input.close();
resolve(line.trim());
});
});
}
async function main() {
const options = readLiveOptions();
if (!options) throw new Error("Set RYD_LIVE_YOUTUBE=1 and the documented allowlist variables to opt in.");
let browser;
let diagnostics;
let page;
try {
console.log("WAITING_FOR_BRAVE_DEBUG_APPROVAL");
browser = await chromium.connectOverCDP(options.cdpEndpoint, {
isLocal: true,
noDefaults: true,
timeout: 120_000,
});
const [context] = browser.contexts();
if (!context) throw new Error("The attached Chromium browser has no default context.");
page = await context.newPage();
diagnostics = new LiveRunDiagnostics(page, context, { runtime: options.runtime });
await diagnostics.start();
const driver = new LiveYoutubeDriver(page, context, {
reportProgress: (name, details) => diagnostics.checkpoint(name, details),
});
const createAdapter =
options.runtime === "extension" ? createExtensionLiveRuntimeAdapter : createUserscriptLiveRuntimeAdapter;
const runtimeAdapter = createAdapter({
driver,
expectedBuildId: options.expectedBuildId,
expectedVersion: options.expectedVersion,
});
console.log("BRAVE_CONNECTED");
const readOnly = {};
readOnly.channelShorts = await runLoggedStage(diagnostics, "read-only.channel-to-shorts-and-next", () =>
scenarioRunner.run(runtimeAdapter, "channel-shorts-navigation", options),
);
if (options.navigation.watch) {
readOnly.channelWatch = await runLoggedStage(diagnostics, "read-only.channel-to-watch", () =>
scenarioRunner.run(runtimeAdapter, "channel-watch-navigation", options),
);
} else {
readOnly.channelWatch = null;
diagnostics.checkpoint("read-only.channel-to-watch.skipped", {
reason: "RYD_LIVE_NAV_WATCH is not configured",
});
}
readOnly.reload = await runLoggedStage(diagnostics, "read-only.reload", () =>
scenarioRunner.run(runtimeAdapter, "reload", options),
);
readOnly.responsive = await runLoggedStage(diagnostics, "read-only.responsive-visual", () =>
scenarioRunner.run(runtimeAdapter, "responsive-visual", options),
);
readOnly.short = await runLoggedStage(diagnostics, "read-only.short-render", () =>
scenarioRunner.run(runtimeAdapter, "shorts-render", options),
);
readOnly.spa = await runLoggedStage(diagnostics, "read-only.playlist-spa", () =>
scenarioRunner.run(runtimeAdapter, "spa-navigation", options),
);
readOnly.sidebarStress = await runLoggedStage(diagnostics, "read-only.sidebar-stress", () =>
scenarioRunner.run(runtimeAdapter, "sidebar-navigation-stress", options),
);
readOnly.watch = await runLoggedStage(diagnostics, "read-only.watch-render", () =>
scenarioRunner.run(runtimeAdapter, "watch-render", options),
);
console.log(`READ_ONLY_COMPLETE ${JSON.stringify(readOnly)}`);
console.log(
`READY_FOR_REACTION_APPROVAL runtime=${options.runtime} watch=${options.watchB} short=${options.short}`,
);
diagnostics.checkpoint("reaction-approval.waiting", {
instruction: "Enter SKIP to close the live-test tab without production reactions",
});
const approval = await readApprovalLine();
if (approval === "SKIP") {
console.log("REACTION_MATRIX_SKIPPED");
return;
}
if (!hasFreshVoteApproval(approval, options.runtime, options.watchB, Date.now())) {
throw new Error("The supplied live reaction approval is missing, expired, or does not match this run.");
}
const reaction = await runLoggedStage(diagnostics, "reaction-matrix", () =>
scenarioRunner.run(runtimeAdapter, "reaction-matrix", options, {
createRecorder: (videoId) => new VoteTrafficRecorder(context, videoId),
consumeVoteApproval: async () => {
driver.assertCurrentVideo(options.watchB);
await driver.assertRuntime(options.runtime, options.expectedVersion, options.expectedBuildId);
if (!consumeLiveVoteApproval(approval, options.runtime, options.watchB)) {
throw new Error("The live reaction approval expired or was already used. No reaction was clicked.");
}
},
}),
);
console.log(`REACTION_MATRIX_COMPLETE ${JSON.stringify(reaction)}`);
} catch (error) {
if (diagnostics) {
try {
const snapshotPath = await diagnostics.persistFailureSnapshot(error);
console.error(`LIVE_FAILURE_SNAPSHOT ${snapshotPath}`);
} catch (snapshotError) {
console.error(`LIVE_FAILURE_SNAPSHOT_FAILED ${snapshotError.message}`);
}
}
throw error;
} finally {
diagnostics?.stop();
if (page && !page.isClosed()) await page.close();
if (browser) await browser.close();
}
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
@@ -0,0 +1,568 @@
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const { WATCH_RATIO_SOAK_DURATION_MS } = require("./live-youtube-driver");
const RESPONSIVE_VIEWPORTS = [
{ height: 720, width: 1280 },
{ height: 720, width: 768 },
{ height: 844, width: 390 },
];
const REACTION_CYCLES = {
neutral: ["like", "like", "dislike", "like", "dislike", "dislike"],
liked: ["like", "dislike", "like", "dislike", "dislike", "like"],
disliked: ["like", "dislike", "dislike", "like", "like", "dislike"],
};
function nextReactionState(state, action) {
if (action === "like") return state === "liked" ? "neutral" : "liked";
if (action === "dislike") return state === "disliked" ? "neutral" : "disliked";
throw new Error(`Unsupported reaction action: ${action}`);
}
function reactionValue(state) {
if (state === "liked") return 1;
if (state === "disliked") return -1;
if (state === "neutral") return 0;
throw new Error(`Unsupported reaction state: ${state}`);
}
async function assertLivePreconditions(driver, options) {
await driver.assertSignedIn(options.expectedChannel);
await driver.assertRuntime(options.runtime, options.expectedVersion, options.expectedBuildId);
}
async function runWatchRenderScenario(driver, options) {
return driver.withNoProductionInteractions(async () => {
await driver.openPlaylist(options.playlistUrl, options.watchA);
await assertLivePreconditions(driver, options);
return driver.waitForDislikeText();
});
}
async function runReloadScenario(driver, options) {
return driver.withNoProductionInteractions(async () => {
await driver.openPlaylist(options.playlistUrl, options.watchA);
await assertLivePreconditions(driver, options);
await driver.waitForDislikeText();
await driver.reload(options.watchA);
await assertLivePreconditions(driver, options);
return driver.waitForDislikeText();
});
}
async function runSpaNavigationScenario(driver, options) {
return driver.withNoProductionInteractions(async () => {
await driver.openPlaylist(options.playlistUrl, options.watchA);
await assertLivePreconditions(driver, options);
const watchACount = await driver.waitForDislikeText();
await driver.navigateWithinPlaylist(options.watchB);
await assertLivePreconditions(driver, options);
const watchBCount = await driver.waitForDislikeText({ differentFrom: watchACount });
assert.match(watchACount, /\d/);
assert.match(watchBCount, /\d/);
assert.notEqual(
watchBCount,
watchACount,
"Choose allowlisted playlist videos whose rendered dislike counts differ so stale SPA UI can be detected.",
);
return { watchACount, watchBCount };
});
}
async function runSidebarStressScenario(
driver,
options,
{
makeDirectory = (directory) => fs.mkdirSync(directory, { recursive: true }),
outputDirectory = path.resolve(__dirname, "../../../../test-results/live-youtube/sidebar-stress"),
readyTimeoutMs = 1_000,
soakDurationMs = WATCH_RATIO_SOAK_DURATION_MS,
} = {},
) {
const hopCount = options.sidebar?.hopCount;
assert.ok(Number.isSafeInteger(hopCount) && hopCount > 0, "A positive sidebar stress hop count is required.");
assert.ok(
Number.isFinite(readyTimeoutMs) && readyTimeoutMs > 0,
"A positive ratio-bar readiness budget is required.",
);
makeDirectory(outputDirectory);
return driver.withNoProductionInteractions(async () => {
await driver.openWatch(options.watchA);
await assertLivePreconditions(driver, options);
const visitedVideoIds = [options.watchA];
const hops = [];
for (let index = 0; index < hopCount; index += 1) {
const { body, videoId } = await driver.navigateToRelatedWatch(visitedVideoIds);
assert.equal(typeof body.dislikes, "number", `Sidebar hop ${index + 1} has no production dislike count.`);
assert.ok(!visitedVideoIds.includes(videoId), `Sidebar hop ${index + 1} revisited ${videoId}.`);
driver.assertCurrentVideo(videoId);
await assertLivePreconditions(driver, options);
const screenshotPath = path.join(
outputDirectory,
`${options.runtime}-sidebar-hop-${String(index + 1).padStart(2, "0")}.png`,
);
const readinessStartedAt = Date.now();
const visual = await driver.captureWatchRatioVisual(options.runtime, screenshotPath, {
presenceTimeoutMs: readyTimeoutMs,
});
const readyLatencyMs = Date.now() - readinessStartedAt;
assert.ok(
readyLatencyMs <= readyTimeoutMs,
`Sidebar hop ${index + 1} ratio bar took ${readyLatencyMs}ms; the budget is ${readyTimeoutMs}ms.`,
);
assert.match(visual.count, /\d/, `Sidebar hop ${index + 1} did not render a dislike count.`);
const soak = await driver.soakWatchRatioVisual(options.runtime, {
durationMs: soakDurationMs,
expectedCount: visual.count,
videoId,
});
visitedVideoIds.push(videoId);
hops.push({
apiDislikes: body.dislikes,
count: visual.count,
readyLatencyMs,
readyTimeoutMs,
screenshotPath,
soak,
videoId,
});
}
return { hopCount, hops, outputDirectory, startVideoId: options.watchA };
});
}
async function runShortsRenderScenario(driver, options) {
return driver.withNoProductionInteractions(async () => {
await driver.openShort(options.short);
await assertLivePreconditions(driver, options);
return driver.waitForDislikeText();
});
}
async function runChannelShortsNavigationScenario(driver, options) {
return driver.withNoProductionInteractions(async () => {
await driver.navigateFromColdChannelToShort(options.navigation.channelUrl, options.navigation.short);
await assertLivePreconditions(driver, options);
const initial = await driver.assertCurrentShortsControl(options.navigation.short, options.runtime);
const nextVideoId = await driver.navigateToNextShort(options.navigation.short);
await driver.assertRuntime(options.runtime, options.expectedVersion, options.expectedBuildId);
const next = await driver.assertCurrentShortsControl(nextVideoId, options.runtime);
await driver.pausePlayback();
assert.notEqual(
next.videoId,
initial.videoId,
"The Shorts Next video scenario did not change the current video ID.",
);
return { initial, next };
});
}
async function runChannelWatchNavigationScenario(driver, options) {
if (!options.navigation.watch) {
throw new Error("RYD_LIVE_NAV_WATCH is required for the optional channel-to-watch scenario.");
}
return driver.withNoProductionInteractions(async () => {
await driver.navigateFromColdChannelToWatch(options.navigation.channelUrl, options.navigation.watch);
await assertLivePreconditions(driver, options);
const count = await driver.waitForDislikeText();
return { count, videoId: options.navigation.watch };
});
}
async function runResponsiveVisualScenario(
driver,
options,
{
makeDirectory = (directory) => fs.mkdirSync(directory, { recursive: true }),
outputDirectory = path.resolve(__dirname, "../../../../test-results/live-youtube/responsive"),
} = {},
) {
makeDirectory(outputDirectory);
return driver.withNoProductionInteractions(async () => {
const originalViewport = await driver.readViewportSize();
const watch = [];
const shorts = [];
try {
await driver.setViewportSize(RESPONSIVE_VIEWPORTS[0]);
await driver.openWatch(options.watchA);
await assertLivePreconditions(driver, options);
for (let index = 0; index < RESPONSIVE_VIEWPORTS.length; index += 1) {
const viewport = RESPONSIVE_VIEWPORTS[index];
if (index > 0) await driver.setViewportSize(viewport);
await driver.waitForDislikeText();
watch.push(
await driver.captureWatchRatioVisual(
options.runtime,
path.join(outputDirectory, `${options.runtime}-watch-ratio-${viewport.width}.png`),
),
);
}
await driver.setViewportSize(RESPONSIVE_VIEWPORTS[0]);
await driver.openShort(options.short);
await assertLivePreconditions(driver, options);
for (let index = 0; index < RESPONSIVE_VIEWPORTS.length; index += 1) {
const viewport = RESPONSIVE_VIEWPORTS[index];
if (index > 0) await driver.setViewportSize(viewport);
await driver.assertCurrentShortsControl(options.short, options.runtime);
await driver.waitForDislikeText();
const screenshotPath = path.join(outputDirectory, `${options.runtime}-shorts-control-${viewport.width}.png`);
shorts.push(
options.runtime === "userscript"
? await driver.captureSyntheticShortsVisual(options.short, screenshotPath)
: await driver.captureNativeShortsVisual(options.short, screenshotPath),
);
}
} finally {
await driver.setViewportSize(originalViewport);
}
return {
outputDirectory,
shorts,
shortsSkipped: null,
watch,
};
});
}
async function restoreReactionStateUnchecked(
driver,
recorder,
options,
videoId,
initialState,
expectedUserId,
isShort,
failedAttempt,
) {
try {
driver.assertCurrentVideo(videoId);
} catch {
if (isShort) await driver.openShort(videoId);
else await driver.openWatch(videoId);
await assertLivePreconditions(driver, options);
}
const currentState = await driver.readReactionState();
if (currentState === initialState && !failedAttempt) return;
const attemptedUserId = failedAttempt?.userId;
const assertCleanupUserId = (userId, roundTripUserId) => {
assert.equal(typeof userId, "string", "The cleanup reaction has no anonymous RYD identity.");
if (expectedUserId) {
assert.equal(userId, expectedUserId, "The cleanup reaction used a different anonymous RYD identity.");
}
if (attemptedUserId) {
assert.equal(userId, attemptedUserId, "The cleanup reaction did not use the failed attempt's RYD identity.");
}
if (roundTripUserId) {
assert.equal(userId, roundTripUserId, "The cleanup round trip changed anonymous RYD identity.");
}
};
const performCleanupTransition = async (action, targetState, roundTripUserId) => {
const mark = recorder.mark();
await driver.clickAction(videoId, action);
await driver.waitForReactionState(targetState);
const userId = await recorder.waitForHandshake(reactionValue(targetState), mark);
assertCleanupUserId(userId, roundTripUserId);
return userId;
};
if (currentState === initialState) {
const action = initialState === "liked" ? "like" : "dislike";
const awayState = initialState === "neutral" ? "disliked" : "neutral";
const awayUserId = await performCleanupTransition(action, awayState);
await performCleanupTransition(action, initialState, awayUserId);
return;
}
const action =
initialState === "neutral"
? currentState === "liked"
? "like"
: "dislike"
: initialState === "liked"
? "like"
: "dislike";
await performCleanupTransition(action, initialState);
}
async function restoreReactionState(
driver,
recorder,
options,
videoId,
initialState,
expectedUserId,
isShort,
failedAttempt,
) {
try {
await restoreReactionStateUnchecked(
driver,
recorder,
options,
videoId,
initialState,
expectedUserId,
isShort,
failedAttempt,
);
} catch (error) {
const url = isShort ? `https://www.youtube.com/shorts/${videoId}` : `https://www.youtube.com/watch?v=${videoId}`;
throw new Error(`Automatic cleanup could not be verified. Manually restore ${url}. ${error.message}`, {
cause: error,
});
}
}
async function runReactionCycle(
driver,
recorder,
options,
{ beforeFirstAction, captureReactionVisual = null, isShort = false, videoId },
) {
if (isShort) await driver.openShort(videoId);
else await driver.openWatch(videoId);
await assertLivePreconditions(driver, options);
await driver.waitForDislikeText();
const initialState = await driver.readReactionState();
const actions = REACTION_CYCLES[initialState];
assert.ok(actions, `Unsupported initial YouTube reaction state: ${initialState}`);
let authorized = false;
let completed = false;
let expectedUserId;
let failedAttempt;
let currentState = initialState;
const evidencePaths = [];
const captureEvidence = async (index) => {
if (!captureReactionVisual) return;
const screenshotPath = await captureReactionVisual({ index, state: currentState });
assert.equal(typeof screenshotPath, "string", "The reaction visual capture did not return an evidence path.");
evidencePaths.push(screenshotPath);
};
try {
await captureEvidence(0);
driver.assertCurrentVideo(videoId);
await assertLivePreconditions(driver, options);
if (beforeFirstAction) await beforeFirstAction();
authorized = true;
for (let index = 0; index < actions.length; index += 1) {
const action = actions[index];
const expectedState = nextReactionState(currentState, action);
const mark = recorder.mark();
const value = reactionValue(expectedState);
failedAttempt = { mark, userId: undefined, value };
await driver.clickAction(videoId, action);
await driver.waitForReactionState(expectedState);
const userId = await recorder.waitForHandshake(value, mark);
failedAttempt.userId = userId;
if (expectedUserId) assert.equal(userId, expectedUserId, "The transition cycle changed anonymous RYD identity.");
expectedUserId = userId;
failedAttempt = undefined;
currentState = expectedState;
await driver.waitForDislikeText();
await captureEvidence(index + 1);
}
assert.equal(currentState, initialState, "The six-transition cycle did not return to its initial state.");
completed = true;
return { evidencePaths, initialState, userId: expectedUserId };
} finally {
if (authorized && !completed) {
if (failedAttempt && !failedAttempt.userId && typeof recorder.voteUserId === "function") {
try {
failedAttempt.userId = recorder.voteUserId(failedAttempt.value, failedAttempt.mark);
} catch {
failedAttempt.userId = undefined;
}
}
await restoreReactionState(
driver,
recorder,
options,
videoId,
initialState,
expectedUserId,
isShort,
failedAttempt,
);
}
}
}
async function runProductionReactionMatrixScenario(
driver,
options,
createRecorder,
consumeVoteApproval,
visualOptions = {},
) {
const makeDirectory = visualOptions.makeDirectory ?? ((directory) => fs.mkdirSync(directory, { recursive: true }));
const outputDirectory =
visualOptions.outputDirectory ??
path.resolve(__dirname, "../../../../test-results/live-youtube/reactions", options.runtime);
makeDirectory(outputDirectory);
const captureFor =
(kind, videoId, isShort) =>
async ({ index, state }) => {
const screenshotPath = path.join(outputDirectory, `${kind}-${index}-${state}.png`);
const evidence = await driver.captureReactionStateVisual({
expectedState: state,
isShort,
runtime: options.runtime,
screenshotPath,
videoId,
});
assert.equal(
evidence?.screenshotPath,
screenshotPath,
`The ${kind} reaction capture did not write the requested evidence path.`,
);
return screenshotPath;
};
const watchRecorder = createRecorder(options.watchB);
let watchResult;
try {
watchResult = await runReactionCycle(driver, watchRecorder, options, {
beforeFirstAction: consumeVoteApproval,
captureReactionVisual: captureFor("watch", options.watchB, false),
videoId: options.watchB,
});
} finally {
watchRecorder.stop();
}
const shortRecorder = createRecorder(options.short);
try {
const shortResult = await runReactionCycle(driver, shortRecorder, options, {
captureReactionVisual: captureFor("short", options.short, true),
isShort: true,
videoId: options.short,
});
assert.equal(
shortResult.userId,
watchResult.userId,
"Watch and Shorts votes used different anonymous RYD identities.",
);
return {
evidencePaths: [...watchResult.evidencePaths, ...shortResult.evidencePaths],
outputDirectory,
short: shortResult,
watch: watchResult,
};
} finally {
shortRecorder.stop();
}
}
async function restoreNeutralStateUnchecked(driver, recorder, options, dislikeMark, expectedUserId) {
let state;
try {
driver.assertCurrentVideo(options.watchB);
state = await driver.readVoteState();
} catch {
await driver.openWatch(options.watchB);
await assertLivePreconditions(driver, options);
state = await driver.readVoteState();
}
if (state === "false") {
if (recorder.hasVote(-1, dislikeMark)) {
throw new Error(
`The YouTube button is neutral but the production -1 vote may remain. Manually verify and restore https://www.youtube.com/watch?v=${options.watchB}.`,
);
}
return;
}
if (state !== "true") {
throw new Error(
`Could not verify the YouTube dislike state. Manually restore https://www.youtube.com/watch?v=${options.watchB}.`,
);
}
const neutralMark = recorder.mark();
await driver.clickDislike(options.watchB);
await driver.waitForVoteState(false);
const neutralUserId = await recorder.waitForHandshake(0, neutralMark);
if (expectedUserId && neutralUserId !== expectedUserId) {
throw new Error(
`The neutral vote used a different identity from the dislike vote. Manually verify and restore https://www.youtube.com/watch?v=${options.watchB}.`,
);
}
}
async function restoreNeutralState(driver, recorder, options, dislikeMark, expectedUserId) {
try {
await restoreNeutralStateUnchecked(driver, recorder, options, dislikeMark, expectedUserId);
} catch (error) {
throw new Error(
`Automatic cleanup could not be verified. Manually restore https://www.youtube.com/watch?v=${options.watchB}. ${error.message}`,
{ cause: error },
);
}
}
async function runReversibleVoteScenario(driver, recorder, options, consumeVoteApproval) {
await driver.openWatch(options.watchB);
await assertLivePreconditions(driver, options);
await driver.waitForDislikeText();
const initialLikeState = await driver.readLikeState();
const initialDislikeState = await driver.readVoteState();
assert.equal(initialLikeState, "false", "The allowlisted vote video must not already be liked.");
assert.equal(initialDislikeState, "false", "The allowlisted vote video must not already be disliked.");
const dislikeMark = recorder.mark();
let cleanupRequired = false;
let dislikeUserId;
try {
driver.assertCurrentVideo(options.watchB);
await assertLivePreconditions(driver, options);
await consumeVoteApproval();
cleanupRequired = true;
await driver.clickDislike(options.watchB);
await driver.waitForVoteState(true);
dislikeUserId = await recorder.waitForHandshake(-1, dislikeMark);
} finally {
if (cleanupRequired) {
await restoreNeutralState(
driver,
recorder,
options,
dislikeMark,
dislikeUserId || recorder.voteUserId(-1, dislikeMark),
);
}
}
await driver.waitForDislikeText();
}
module.exports = {
RESPONSIVE_VIEWPORTS,
runChannelShortsNavigationScenario,
runChannelWatchNavigationScenario,
runProductionReactionMatrixScenario,
runReactionCycle,
runReloadScenario,
runReversibleVoteScenario,
runResponsiveVisualScenario,
runSidebarStressScenario,
runShortsRenderScenario,
runSpaNavigationScenario,
runWatchRenderScenario,
};
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,113 @@
const { chromium, test } = require("@playwright/test");
const {
createExtensionLiveRuntimeAdapter,
createUserscriptLiveRuntimeAdapter,
} = require("../../../e2e/live-runtime-adapter");
const { createSharedLiveScenarioRunner } = require("../../../e2e/shared-live-scenarios");
const { consumeLiveVoteApproval, hasFreshVoteApproval, readLiveOptions } = require("../../live/live-options");
const { LiveYoutubeDriver, VoteTrafficRecorder } = require("./live-youtube-driver");
const options = readLiveOptions();
const scenarioRunner = createSharedLiveScenarioRunner();
test.describe("live YouTube RYD smoke", () => {
test.skip(options === null, "Set RYD_LIVE_YOUTUBE=1 and the documented allowlist variables to opt in.");
test.describe.configure({ mode: "serial" });
let browser;
let context;
let page;
let driver;
let runtimeAdapter;
test.beforeAll(async () => {
try {
browser = await chromium.connectOverCDP(options.cdpEndpoint, {
isLocal: true,
noDefaults: true,
timeout: 15_000,
});
} catch (error) {
throw new Error(
`Could not attach to the running Brave/Chromium profile. Enable remote debugging at brave://inspect/#remote-debugging and set RYD_CDP_ENDPOINT to its explicit CDP endpoint, then retry. ${error.message}`,
);
}
[context] = browser.contexts();
if (!context) throw new Error("The attached Brave/Chromium browser has no default context.");
page = await context.newPage();
driver = new LiveYoutubeDriver(page, context);
const createAdapter =
options.runtime === "extension" ? createExtensionLiveRuntimeAdapter : createUserscriptLiveRuntimeAdapter;
runtimeAdapter = createAdapter({
driver,
expectedBuildId: options.expectedBuildId,
expectedVersion: options.expectedVersion,
});
});
test.afterAll(async () => {
if (page && !page.isClosed()) await page.close();
if (browser) await browser.close();
});
test(`${options?.runtime || "runtime"}: initializes after cold channel-to-Short and Next-video SPA navigation`, async () => {
test.setTimeout(150_000);
await scenarioRunner.run(runtimeAdapter, "channel-shorts-navigation", options);
});
test(`${options?.runtime || "runtime"}: initializes after optional cold channel-to-watch SPA navigation`, async () => {
test.setTimeout(120_000);
test.skip(
!options?.navigation.watch,
"Set RYD_LIVE_NAV_WATCH only when the configured channel page contains an exact visible link to that video.",
);
await scenarioRunner.run(runtimeAdapter, "channel-watch-navigation", options);
});
test(`${options?.runtime || "runtime"}: renders on a signed-in watch page`, async () => {
await scenarioRunner.run(runtimeAdapter, "watch-render", options);
});
test(`${options?.runtime || "runtime"}: reinitializes after reload`, async () => {
await scenarioRunner.run(runtimeAdapter, "reload", options);
});
test(`${options?.runtime || "runtime"}: reinitializes after real playlist SPA navigation`, async () => {
await scenarioRunner.run(runtimeAdapter, "spa-navigation", options);
});
test(`${options?.runtime || "runtime"}: survives consecutive real sidebar SPA navigations`, async () => {
test.setTimeout(60_000 + options.sidebar.hopCount * 75_000);
await scenarioRunner.run(runtimeAdapter, "sidebar-navigation-stress", options);
});
test(`${options?.runtime || "runtime"}: renders on an allowlisted Short`, async () => {
await scenarioRunner.run(runtimeAdapter, "shorts-render", options);
});
test(`${options?.runtime || "runtime"}: preserves reaction UI geometry across responsive widths`, async () => {
test.setTimeout(180_000);
await scenarioRunner.run(runtimeAdapter, "responsive-visual", options);
});
test(`${options?.runtime || "runtime"}: covers all reaction transitions on watch and Shorts`, async () => {
test.setTimeout(0);
const voteApproval = process.env.RYD_LIVE_VOTES;
test.skip(
!hasFreshVoteApproval(voteApproval, options.runtime, options.watchB, Date.now()),
"Create the documented short-lived RYD_LIVE_VOTES token only after approving the real vote pair.",
);
await scenarioRunner.run(runtimeAdapter, "reaction-matrix", options, {
createRecorder: (videoId) => new VoteTrafficRecorder(context, videoId),
consumeVoteApproval: async () => {
driver.assertCurrentVideo(options.watchB);
await driver.assertRuntime(options.runtime, options.expectedVersion, options.expectedBuildId);
if (!consumeLiveVoteApproval(voteApproval, options.runtime, options.watchB)) {
throw new Error("The live reaction approval expired or was already used. No reaction was clicked.");
}
},
});
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,418 @@
const { test, expect } = require("@playwright/test");
const {
CREDENTIAL_KEY,
VIDEO_A,
VIDEO_B,
createFakeBackend,
injectGeneratedUserscript,
installGmEnvironment,
installHermeticRoutes,
openShortsFixture,
readGmValue,
} = require("./harness");
const EXISTING_CREDENTIALS = {
userId: "ExistingUserscriptCredential000000000001",
registrationConfirmed: true,
};
const SYNTHETIC_STATE_KEY = `rydSyntheticDislikedShort:${VIDEO_A}`;
function visibleVoteButton(page, role) {
return page.locator(`[data-ryd-role="${role}"]:visible button`);
}
async function installDelayedSyntheticStateRead(page) {
await page.evaluate((delayedKey) => {
const originalGetValue = globalThis.GM.getValue;
let releaseRead;
let reportReadStarted;
let delayed = false;
const readGate = new Promise((resolve) => {
releaseRead = resolve;
});
globalThis.__rydDelayedGmReadStarted = new Promise((resolve) => {
reportReadStarted = resolve;
});
globalThis.__releaseRydDelayedGmRead = releaseRead;
globalThis.GM.getValue = async (key, fallbackValue) => {
if (key === delayedKey) {
if (!delayed) {
delayed = true;
reportReadStarted();
await readGate;
}
}
return originalGetValue(key, fallbackValue);
};
}, SYNTHETIC_STATE_KEY);
}
async function launchDelayedShorts(
{ context, page },
{ backendOptions, configureBackend, nativeDislike = false } = {},
) {
const consoleErrors = [];
const pageErrors = [];
page.on("console", (message) => {
if (message.type() === "error") consoleErrors.push(message.text());
});
page.on("pageerror", (error) => pageErrors.push(error.message));
const backend = createFakeBackend(backendOptions);
configureBackend?.(backend);
await installGmEnvironment(context, { [CREDENTIAL_KEY]: EXISTING_CREDENTIALS });
await installHermeticRoutes(context, backend);
await openShortsFixture(page, VIDEO_A);
await page.evaluate(() => {
globalThis.__unhandledRejections = [];
addEventListener("unhandledrejection", (event) => {
globalThis.__unhandledRejections.push(
event.reason instanceof Error ? event.reason.message : String(event.reason),
);
});
});
if (nativeDislike) {
await page.evaluate(() => window.__shortsFixture.installNativeDislike());
}
await installDelayedSyntheticStateRead(page);
await injectGeneratedUserscript(page);
await page.evaluate(() => globalThis.__rydDelayedGmReadStarted);
return { backend, consoleErrors, pageErrors };
}
async function releaseDelayedRead(page) {
await page.evaluate(() => globalThis.__releaseRydDelayedGmRead());
}
async function expectCompletedHandshakes(backend, expectedValues) {
await expect.poll(() => backend.requestsFor("POST", "/interact/confirmVote").length).toBe(expectedValues.length);
await expect
.poll(() => backend.requestsFor("POST", "/interact/confirmVote").every((request) => request.respondedAt))
.toBe(true);
await new Promise((resolve) => setTimeout(resolve, 150));
const votes = backend.requestsFor("POST", "/interact/vote");
const confirmations = backend.requestsFor("POST", "/interact/confirmVote");
expect(votes).toHaveLength(expectedValues.length);
expect(confirmations).toHaveLength(expectedValues.length);
expect(votes.map((request) => request.body.value)).toEqual(expectedValues);
for (let index = 0; index < expectedValues.length; index += 1) {
expect(votes[index].body).toEqual({
userId: EXISTING_CREDENTIALS.userId,
videoId: VIDEO_A,
value: expectedValues[index],
});
expect(confirmations[index].body).toMatchObject({
userId: EXISTING_CREDENTIALS.userId,
videoId: VIDEO_A,
});
}
}
async function expectSingleCompletedHandshake(backend, { value, videoId }) {
await expect.poll(() => backend.requestsFor("POST", "/interact/confirmVote").length).toBe(1);
await expect.poll(() => backend.requestsFor("POST", "/interact/confirmVote")[0]?.respondedAt).toBeTruthy();
await new Promise((resolve) => setTimeout(resolve, 150));
const votes = backend.requestsFor("POST", "/interact/vote");
const confirmations = backend.requestsFor("POST", "/interact/confirmVote");
expect(votes).toHaveLength(1);
expect(confirmations).toHaveLength(1);
expect(votes[0].body).toEqual({ userId: EXISTING_CREDENTIALS.userId, videoId, value });
expect(confirmations[0].body).toMatchObject({ userId: EXISTING_CREDENTIALS.userId, videoId });
}
async function expectNoRuntimeFailures(page, harness) {
expect(harness.backend.blockedRequests).toEqual([]);
expect(harness.consoleErrors).toEqual([]);
expect(harness.pageErrors).toEqual([]);
expect(await page.evaluate(() => globalThis.__unhandledRejections)).toEqual([]);
}
test("immediate native Like during synthetic Shorts initialization submits once and remains coherent", async ({
context,
page,
}) => {
const harness = await launchDelayedShorts({ context, page });
const likeButton = visibleVoteButton(page, "like");
await likeButton.click();
try {
await expect.poll(() => harness.backend.requestsFor("POST", "/interact/confirmVote").length).toBe(1);
} finally {
await releaseDelayedRead(page);
}
await expect(page.locator("[data-ryd-synthetic-shorts-dislike]:visible #text")).toHaveText("25");
await expect(likeButton).toHaveAttribute("aria-pressed", "true");
await expect(visibleVoteButton(page, "dislike")).toHaveAttribute("aria-pressed", "false");
await likeButton.click();
await expectCompletedHandshakes(harness.backend, [1, 0]);
await expect(likeButton).toHaveAttribute("aria-pressed", "false");
await expect(visibleVoteButton(page, "dislike")).toHaveAttribute("aria-pressed", "false");
await expectNoRuntimeFailures(page, harness);
});
test("immediate native Dislike during native Shorts initialization submits once and remains coherent", async ({
context,
page,
}) => {
const harness = await launchDelayedShorts({ context, page }, { nativeDislike: true });
const dislikeButton = visibleVoteButton(page, "dislike");
await dislikeButton.click();
try {
await expect.poll(() => harness.backend.requestsFor("POST", "/interact/confirmVote").length).toBe(1);
} finally {
await releaseDelayedRead(page);
}
await expect(dislikeButton.locator("#text")).toHaveText(/\d/);
await expect(dislikeButton).toHaveAttribute("aria-pressed", "true");
await expect(visibleVoteButton(page, "like")).toHaveAttribute("aria-pressed", "false");
await dislikeButton.click();
await expectCompletedHandshakes(harness.backend, [-1, 0]);
await expect(dislikeButton).toHaveAttribute("aria-pressed", "false");
await expect(visibleVoteButton(page, "like")).toHaveAttribute("aria-pressed", "false");
await expectNoRuntimeFailures(page, harness);
});
test("stale native Shorts hydration preserves video A's captured dislike across a recycled video B", async ({
context,
page,
}) => {
const harness = await launchDelayedShorts(
{ context, page },
{
backendOptions: {
countsByVideo: {
[VIDEO_A]: { dislikes: 11, likes: 100 },
[VIDEO_B]: { dislikes: 22, likes: 200 },
},
},
nativeDislike: true,
},
);
await visibleVoteButton(page, "dislike").click();
try {
await expect.poll(() => harness.backend.requestsFor("POST", "/interact/confirmVote").length).toBe(1);
expect(harness.backend.requestsFor("POST", "/interact/vote")[0].body).toEqual({
userId: EXISTING_CREDENTIALS.userId,
videoId: VIDEO_A,
value: -1,
});
await page.evaluate((videoId) => window.__shortsFixture.recycleActiveRenderer(videoId), VIDEO_B);
} finally {
await releaseDelayedRead(page);
}
await expect(page.locator('[data-ryd-role="dislike"]:visible #text')).toHaveText("22");
await expect.poll(() => readGmValue(page, SYNTHETIC_STATE_KEY)).toBe(true);
await page.evaluate((videoId) => {
window.__shortsFixture.recycleActiveRenderer(videoId);
window.__shortsFixture.removeNativeDislike();
}, VIDEO_A);
await expect(page.locator('[data-ryd-role="dislike"]:visible #text')).toHaveText("11");
const restoredSyntheticDislike = page.locator("[data-ryd-synthetic-shorts-dislike]:visible button");
await expect(restoredSyntheticDislike).toHaveAttribute("aria-pressed", "true");
await restoredSyntheticDislike.click();
await expectCompletedHandshakes(harness.backend, [-1, 0]);
await expect(restoredSyntheticDislike).toHaveAttribute("aria-pressed", "false");
await expect.poll(() => readGmValue(page, SYNTHETIC_STATE_KEY)).toBe(null);
expect(
harness.backend.requestsFor("POST", "/interact/vote").filter((request) => request.body.videoId === VIDEO_B),
).toHaveLength(0);
await expectNoRuntimeFailures(page, harness);
});
test("video B Like remains bound while video A hydration is delayed", async ({ context, page }) => {
const harness = await launchDelayedShorts(
{ context, page },
{
backendOptions: {
countsByVideo: {
[VIDEO_A]: { dislikes: 11, likes: 100 },
[VIDEO_B]: { dislikes: 22, likes: 200 },
},
},
},
);
await page.evaluate((videoId) => window.__shortsFixture.recycleActiveRenderer(videoId), VIDEO_B);
const videoBLike = visibleVoteButton(page, "like");
await videoBLike.click();
try {
await expectSingleCompletedHandshake(harness.backend, { videoId: VIDEO_B, value: 1 });
} finally {
await releaseDelayedRead(page);
}
await expect(page.locator('[data-ryd-role="dislike"]:visible #text')).toHaveText("22");
await expect(videoBLike).toHaveAttribute("aria-pressed", "true");
await expect(visibleVoteButton(page, "dislike")).toHaveAttribute("aria-pressed", "false");
expect(
harness.backend.requestsFor("POST", "/interact/vote").filter((request) => request.body.videoId === VIDEO_A),
).toHaveLength(0);
await expectNoRuntimeFailures(page, harness);
});
for (const replacement of ["inner Like button", "action bar"]) {
test(`a replacement ${replacement} receives the pending same-video hydration listener`, async ({ context, page }) => {
const harness = await launchDelayedShorts({ context, page });
await page.evaluate((replacementKind) => {
if (replacementKind === "inner Like button") {
window.__shortsFixture.replaceInnerButton("like");
} else {
window.__shortsFixture.replaceActionBar();
}
}, replacement);
const replacementLike = visibleVoteButton(page, "like");
await replacementLike.click();
try {
await expectSingleCompletedHandshake(harness.backend, { videoId: VIDEO_A, value: 1 });
} finally {
await releaseDelayedRead(page);
}
await expect(page.locator('[data-ryd-role="dislike"]:visible #text')).toHaveText("25");
await expect(replacementLike).toHaveAttribute("aria-pressed", "true");
await expect(page.locator("[data-ryd-synthetic-shorts-dislike]:visible")).toHaveCount(1);
await expectNoRuntimeFailures(page, harness);
});
}
test("enabled synthetic Dislike clears native Like during a pending same-wrapper rehydration", async ({
context,
page,
}) => {
const harness = await launchDelayedShorts({ context, page });
await releaseDelayedRead(page);
const syntheticDislike = visibleVoteButton(page, "dislike");
await expect(page.locator('[data-ryd-role="dislike"]:visible #text')).toHaveText("25");
await expect(syntheticDislike).toHaveAttribute("aria-disabled", "false");
await installDelayedSyntheticStateRead(page);
await page.evaluate((videoId) => window.__shortsFixture.activate(videoId, { state: "liked" }), VIDEO_A);
await page.evaluate(() => globalThis.__rydDelayedGmReadStarted);
await expect(visibleVoteButton(page, "like")).toHaveAttribute("aria-pressed", "true");
await syntheticDislike.click();
try {
await expectSingleCompletedHandshake(harness.backend, { videoId: VIDEO_A, value: -1 });
} finally {
await releaseDelayedRead(page);
}
await expect(visibleVoteButton(page, "like")).toHaveAttribute("aria-pressed", "false");
await expect(syntheticDislike).toHaveAttribute("aria-pressed", "true");
await expect.poll(() => readGmValue(page, SYNTHETIC_STATE_KEY)).toBe(true);
expect(harness.backend.requestsFor("GET", "/votes")).toHaveLength(1);
await expectNoRuntimeFailures(page, harness);
});
test("new same-video hydration wins after two native Dislike transitions complete before the old read", async ({
context,
page,
}) => {
const harness = await launchDelayedShorts(
{ context, page },
{
backendOptions: {
countsByVideo: {
[VIDEO_A]: { dislikes: 25, likes: 100 },
},
},
nativeDislike: true,
},
);
await expect(page.locator('[data-ryd-role="dislike"]:visible #text')).toHaveText("25");
let readReleased = false;
try {
await visibleVoteButton(page, "dislike").click();
await expectSingleCompletedHandshake(harness.backend, { videoId: VIDEO_A, value: -1 });
await expect(page.locator('[data-ryd-role="dislike"]:visible #text')).toHaveText("26");
await page.evaluate(() => window.__shortsFixture.replaceActionBar());
const replacementDislike = visibleVoteButton(page, "dislike");
await replacementDislike.click();
await expectCompletedHandshakes(harness.backend, [-1, 0]);
await expect(replacementDislike).toHaveAttribute("aria-pressed", "false");
await expect(page.locator('[data-ryd-role="dislike"]:visible #text')).toHaveText("25");
await expect.poll(() => readGmValue(page, SYNTHETIC_STATE_KEY)).toBe(null);
expect(harness.backend.requestsFor("GET", "/votes")).toHaveLength(1);
} finally {
await releaseDelayedRead(page);
readReleased = true;
}
expect(readReleased).toBe(true);
const finalDislike = visibleVoteButton(page, "dislike");
await expect(finalDislike).toHaveAttribute("aria-pressed", "false");
await expect(page.locator('[data-ryd-role="dislike"]:visible #text')).toHaveText("25");
await expect.poll(() => readGmValue(page, SYNTHETIC_STATE_KEY)).toBe(null);
await page.waitForTimeout(200);
expect(harness.backend.requestsFor("GET", "/votes")).toHaveLength(1);
expect(harness.backend.requestsFor("POST", "/interact/vote").map((request) => request.body.value)).toEqual([-1, 0]);
await finalDislike.click();
await expectCompletedHandshakes(harness.backend, [-1, 0, -1]);
await expect(finalDislike).toHaveAttribute("aria-pressed", "true");
await expect(page.locator('[data-ryd-role="dislike"]:visible #text')).toHaveText("26");
await expect.poll(() => readGmValue(page, SYNTHETIC_STATE_KEY)).toBe(true);
expect(harness.backend.requestsFor("GET", "/votes")).toHaveLength(1);
await expectNoRuntimeFailures(page, harness);
});
test("fresh same-video count state supersedes stale hydration correction", async ({ context, page }) => {
const harness = await launchDelayedShorts(
{ context, page },
{
configureBackend: (backend) => {
backend.enqueue("GET", "/votes", { body: { dislikes: 25, likes: 100, rating: 4.5 } });
backend.enqueue("GET", "/votes", { body: { dislikes: 22, likes: 200, rating: 4.5 } });
backend.enqueue("GET", "/votes", { body: { dislikes: 26, likes: 100, rating: 4.5 } });
},
nativeDislike: true,
},
);
const dislikeCount = page.locator('[data-ryd-role="dislike"]:visible #text');
await expect(dislikeCount).toHaveText("25");
try {
await visibleVoteButton(page, "dislike").click();
await expectSingleCompletedHandshake(harness.backend, { videoId: VIDEO_A, value: -1 });
await expect(dislikeCount).toHaveText("26");
await page.evaluate((videoId) => window.__shortsFixture.recycleActiveRenderer(videoId), VIDEO_B);
await expect(dislikeCount).toHaveText("22");
await page.evaluate(
(videoId) => window.__shortsFixture.recycleActiveRenderer(videoId, { state: "disliked" }),
VIDEO_A,
);
await expect(dislikeCount).toHaveText("26");
expect(
harness.backend.requestsFor("GET", "/votes").filter((request) => request.query.videoId === VIDEO_A),
).toHaveLength(2);
} finally {
await releaseDelayedRead(page);
}
await page.waitForTimeout(250);
await expect(dislikeCount).toHaveText("26");
await expect(visibleVoteButton(page, "dislike")).toHaveAttribute("aria-pressed", "true");
await expect.poll(() => readGmValue(page, SYNTHETIC_STATE_KEY)).toBe(true);
expect(harness.backend.requestsFor("POST", "/interact/vote").map((request) => request.body.value)).toEqual([-1]);
expect(harness.backend.requestsFor("POST", "/interact/confirmVote")).toHaveLength(1);
expect(harness.backend.requestsFor("GET", "/votes")).toHaveLength(3);
expect(
harness.backend.requestsFor("GET", "/votes").filter((request) => request.query.videoId === VIDEO_B),
).toHaveLength(1);
await expectNoRuntimeFailures(page, harness);
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,67 @@
const { test, expect } = require("@playwright/test");
const {
CREDENTIAL_KEY,
createFakeBackend,
injectGeneratedUserscript,
installGmEnvironment,
installHermeticRoutes,
openNavigationFixture,
} = require("./harness");
const {
NAVIGATION_MATRIX,
USERSCRIPT_MATRIX_RUNTIME,
installNavigationMatrixFixture,
runNavigationMatrixScenario,
} = require("./navigation-matrix");
const EXISTING_CREDENTIALS = {
registrationConfirmed: true,
userId: "ExistingUserscriptCredential000000000001",
};
for (const scenario of NAVIGATION_MATRIX) {
test(`userscript navigation matrix: ${scenario.id}`, async ({ context, page }) => {
await page.setViewportSize(scenario.viewport);
const consoleErrors = [];
const pageErrors = [];
page.on("console", (message) => {
if (message.type() === "error") consoleErrors.push(message.text());
});
page.on("pageerror", (error) => pageErrors.push(error.message));
await context.addInitScript(() => {
globalThis.__unhandledRejections = [];
addEventListener("unhandledrejection", (event) => {
const reason = event.reason;
globalThis.__unhandledRejections.push(reason instanceof Error ? reason.message : String(reason));
});
});
const backend = createFakeBackend({
countsByVideo: {
[scenario.destination.videoId]: scenario.destination.counts,
[scenario.origin.videoId]: scenario.origin.counts,
},
});
await installGmEnvironment(context, { [CREDENTIAL_KEY]: EXISTING_CREDENTIALS });
await installNavigationMatrixFixture(context, scenario);
await installHermeticRoutes(context, backend);
await openNavigationFixture(page, {
pageKind: scenario.origin.kind,
videoId: scenario.origin.videoId,
});
await injectGeneratedUserscript(page);
await runNavigationMatrixScenario({
backend,
page,
runtime: USERSCRIPT_MATRIX_RUNTIME,
scenario,
});
expect(backend.blockedRequests).toEqual([]);
expect(consoleErrors).toEqual([]);
expect(pageErrors).toEqual([]);
expect(await page.evaluate(() => globalThis.__unhandledRejections)).toEqual([]);
});
}
@@ -0,0 +1,219 @@
const { test, expect } = require("@playwright/test");
const {
CREDENTIAL_KEY,
VIDEO_A,
createFakeBackend,
injectGeneratedUserscript,
installGmEnvironment,
installHermeticRoutes,
openWatchFixture,
} = require("./harness");
const EXISTING_CREDENTIALS = {
userId: "ExistingUserscriptCredential000000000001",
registrationConfirmed: true,
};
const COUNTS = { likes: 300, dislikes: 100 };
const VIEWPORTS = [
{ name: "wide desktop", width: 1280, height: 720 },
{ name: "narrow desktop", width: 768, height: 720 },
{ name: "mobile-sized", width: 390, height: 844 },
];
function colorChannels(color) {
const srgb = color.match(/^color\(srgb\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)/);
if (srgb) return srgb.slice(1).map((channel) => Number(channel) * 255);
const channels = color
.match(/[\d.]+/g)
?.slice(0, 3)
.map(Number);
if (!channels || channels.length !== 3) throw new Error(`Unsupported computed color: ${color}`);
return channels;
}
function relativeLuminance(color) {
const channels = colorChannels(color);
const linear = channels.map((channel) => {
const normalized = channel / 255;
return normalized <= 0.04045 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4;
});
return linear[0] * 0.2126 + linear[1] * 0.7152 + linear[2] * 0.0722;
}
function contrastRatio(first, second) {
const lighter = Math.max(relativeLuminance(first), relativeLuminance(second));
const darker = Math.min(relativeLuminance(first), relativeLuminance(second));
return (lighter + 0.05) / (darker + 0.05);
}
async function readBarColors(page) {
return page.evaluate(() => ({
background: getComputedStyle(document.body).backgroundColor,
negative: getComputedStyle(document.querySelector("#return-youtube-dislike-bar-container")).backgroundColor,
positive: getComputedStyle(document.querySelector("#return-youtube-dislike-bar")).backgroundColor,
}));
}
function expectVisibleBarColors(colors, expected) {
expect(colors.background).toBe(expected.background);
expect(colors.positive).toBe(expected.positive);
colorChannels(colors.negative).forEach((channel) => expect(channel).toBeCloseTo(expected.negative, 0));
expect(contrastRatio(colors.negative, colors.background)).toBeGreaterThanOrEqual(3);
expect(contrastRatio(colors.positive, colors.negative)).toBeGreaterThanOrEqual(3);
}
async function launchRateBarFixture({ context, page }, { rateBarEnabled, theme = "dark" } = {}) {
const pageErrors = [];
page.on("pageerror", (error) => pageErrors.push(error.message));
const backend = createFakeBackend({ countsByVideo: { [VIDEO_A]: COUNTS } });
await installGmEnvironment(context, { [CREDENTIAL_KEY]: EXISTING_CREDENTIALS });
await installHermeticRoutes(context, backend);
await openWatchFixture(page, VIDEO_A);
if (theme === "light") {
await page.evaluate(() => {
document.documentElement.style.setProperty("--yt-spec-base-background", "rgb(255, 255, 255)");
document.documentElement.style.setProperty("--yt-spec-text-primary", "rgb(15, 15, 15)");
document.documentElement.style.setProperty("--yt-spec-text-secondary", "rgb(96, 96, 96)");
document.body.style.background = "rgb(255, 255, 255)";
});
}
await injectGeneratedUserscript(page, { rateBarEnabled });
await expect(page.locator('[data-ryd-role="dislike"] #text')).toHaveText(String(COUNTS.dislikes));
return { backend, pageErrors };
}
for (const viewport of VIEWPORTS) {
test(`default ratio bar renders cleanly at ${viewport.name} width`, async ({ context, page }) => {
await page.setViewportSize({ width: viewport.width, height: viewport.height });
const { backend, pageErrors } = await launchRateBarFixture({ context, page });
const wrapper = page.locator(".ryd-tooltip");
const container = page.locator("#return-youtube-dislike-bar-container");
const bar = page.locator("#return-youtube-dislike-bar");
const tooltip = page.locator("#ryd-dislike-tooltip");
const dislikeCount = page.locator('[data-ryd-role="dislike"] #text');
await expect(dislikeCount).toBeVisible();
await expect(dislikeCount).toHaveText(String(COUNTS.dislikes));
await expect(wrapper).toBeVisible();
await expect(container).toBeVisible();
await expect(bar).toBeVisible();
await expect(tooltip).toContainText("300 / 100");
await expect(tooltip).toHaveAttribute("role", "tooltip");
await expect(tooltip).toBeHidden();
await expect(page.locator("tp-yt-paper-tooltip#ryd-dislike-tooltip")).toHaveCount(0);
const idleTooltipStyle = await tooltip.evaluate((element) => {
const style = getComputedStyle(element);
return { opacity: style.opacity, visibility: style.visibility };
});
expect(idleTooltipStyle).toEqual({ opacity: "0", visibility: "hidden" });
await wrapper.hover({ position: { x: 1, y: 1 } });
await expect(tooltip).toBeVisible();
await expect.poll(() => tooltip.evaluate((element) => getComputedStyle(element).opacity)).toBe("1");
const hoveredTooltip = await tooltip.evaluate((element) => {
const bounds = element.getBoundingClientRect();
const style = getComputedStyle(element);
return {
backgroundColor: style.backgroundColor,
bottom: bounds.bottom,
color: style.color,
left: bounds.left,
opacity: style.opacity,
pointerEvents: style.pointerEvents,
position: style.position,
right: bounds.right,
visibility: style.visibility,
viewportWidth: innerWidth,
};
});
expect(hoveredTooltip).toMatchObject({
backgroundColor: "rgba(28, 28, 28, 0.96)",
color: "rgb(255, 255, 255)",
opacity: "1",
pointerEvents: "none",
position: "absolute",
visibility: "visible",
});
expect(hoveredTooltip.left).toBeGreaterThanOrEqual(0);
expect(hoveredTooltip.right).toBeLessThanOrEqual(hoveredTooltip.viewportWidth);
expect(hoveredTooltip.bottom).toBeGreaterThan(0);
await page.mouse.move(viewport.width - 1, viewport.height - 1);
await expect(tooltip).toBeHidden();
await wrapper.focus();
await expect(tooltip).toBeVisible();
await wrapper.evaluate((element) => element.blur());
await expect(tooltip).toBeHidden();
const geometry = await page.evaluate(() => {
const rect = (selector) => {
const bounds = document.querySelector(selector).getBoundingClientRect();
return {
bottom: bounds.bottom,
height: bounds.height,
left: bounds.left,
right: bounds.right,
top: bounds.top,
width: bounds.width,
};
};
return {
bar: rect("#return-youtube-dislike-bar"),
container: rect("#return-youtube-dislike-bar-container"),
dislike: rect('[data-ryd-role="dislike"] button'),
like: rect('[data-ryd-role="like"] button'),
viewport: { height: innerHeight, width: innerWidth },
wrapper: rect(".ryd-tooltip"),
};
});
expect(geometry.container.width).toBeGreaterThan(0);
expect(geometry.container.height).toBeGreaterThan(0);
expect(geometry.bar.width).toBeGreaterThan(0);
expect(geometry.bar.height).toBeGreaterThan(0);
expect(geometry.bar.width / geometry.container.width).toBeCloseTo(0.75, 2);
expect(geometry.wrapper.width).toBeCloseTo(geometry.like.width + geometry.dislike.width, 0);
expect(geometry.container.left).toBeGreaterThanOrEqual(0);
expect(geometry.container.top).toBeGreaterThanOrEqual(0);
expect(geometry.container.right).toBeLessThanOrEqual(geometry.viewport.width);
expect(geometry.container.bottom).toBeLessThanOrEqual(geometry.viewport.height);
expect(geometry.container.top).toBeGreaterThanOrEqual(Math.max(geometry.like.bottom, geometry.dislike.bottom));
expectVisibleBarColors(await readBarColors(page), {
background: "rgb(15, 15, 15)",
negative: 139,
positive: "rgb(241, 241, 241)",
});
expect(backend.blockedRequests).toEqual([]);
expect(pageErrors).toEqual([]);
});
}
test("default ratio bar keeps both sides distinct in the light theme", async ({ context, page }) => {
await page.setViewportSize({ width: 1280, height: 720 });
const { backend, pageErrors } = await launchRateBarFixture({ context, page }, { theme: "light" });
expectVisibleBarColors(await readBarColors(page), {
background: "rgb(255, 255, 255)",
negative: 123,
positive: "rgb(15, 15, 15)",
});
expect(backend.blockedRequests).toEqual([]);
expect(pageErrors).toEqual([]);
});
test("in-memory disabled option omits the ratio bar", async ({ context, page }) => {
await page.setViewportSize({ width: 1280, height: 720 });
const { backend, pageErrors } = await launchRateBarFixture({ context, page }, { rateBarEnabled: false });
await expect(page.locator("#return-youtube-dislike-bar-container")).toHaveCount(0);
await expect(page.locator("#return-youtube-dislike-bar")).toHaveCount(0);
await expect(page.locator("#ryd-dislike-tooltip")).toHaveCount(0);
expect(backend.blockedRequests).toEqual([]);
expect(pageErrors).toEqual([]);
});
@@ -0,0 +1,233 @@
const { test, expect } = require("@playwright/test");
const {
CREDENTIAL_KEY,
VIDEO_A,
createFakeBackend,
injectGeneratedUserscript,
installGmEnvironment,
installHermeticRoutes,
openShortsFixture,
} = require("./harness");
const EXISTING_CREDENTIALS = {
userId: "ExistingUserscriptCredential000000000001",
registrationConfirmed: true,
};
const VIEWPORTS = [
{ height: 720, name: "wide", width: 1280 },
{ height: 720, name: "narrow", width: 768 },
{ height: 844, name: "mobile-sized", width: 390 },
];
async function launchShortsLayoutFixture({ context, page }, fixture = {}) {
const pageErrors = [];
page.on("pageerror", (error) => pageErrors.push(error.message));
const backend = createFakeBackend({ fixture });
await installGmEnvironment(context, { [CREDENTIAL_KEY]: EXISTING_CREDENTIALS });
await installHermeticRoutes(context, backend);
await openShortsFixture(page, VIDEO_A);
await injectGeneratedUserscript(page);
const syntheticDislike = page.locator("[data-ryd-synthetic-shorts-dislike]:visible");
await expect(syntheticDislike.locator("#text")).toHaveText("25");
return { backend, pageErrors, syntheticDislike };
}
for (const viewport of VIEWPORTS) {
test(`synthetic desktop Shorts action matches the full native stack at ${viewport.name} width`, async ({
context,
page,
}) => {
await page.setViewportSize(viewport);
const { backend, pageErrors } = await launchShortsLayoutFixture({ context, page });
const geometry = await page.evaluate(() => {
const nativeOuter = document.querySelector('[data-ryd-role="like"]');
const syntheticOuter = document.querySelector("[data-ryd-synthetic-shorts-dislike]");
const actionBar = syntheticOuter.parentElement;
const commentsOuter = document.querySelector('[data-fixture-control="comments"]');
const nativeLabel = nativeOuter.querySelector("label");
const syntheticLabel = syntheticOuter.querySelector("label");
const syntheticButton = syntheticOuter.querySelector("button");
const syntheticIconWrapper = syntheticButton.querySelector(".ytSpecButtonShapeNextIcon");
const syntheticIcon = syntheticIconWrapper.querySelector("svg");
const nativeCount = nativeOuter.querySelector("#text");
const syntheticCount = syntheticOuter.querySelector("#text");
const rect = (element) => {
const bounds = element.getBoundingClientRect();
return {
bottom: bounds.bottom,
height: bounds.height,
left: bounds.left,
right: bounds.right,
top: bounds.top,
width: bounds.width,
};
};
const boxStyle = (element) => {
const style = getComputedStyle(element);
return {
margin: [style.marginTop, style.marginRight, style.marginBottom, style.marginLeft],
padding: [style.paddingTop, style.paddingRight, style.paddingBottom, style.paddingLeft],
};
};
const textStyle = (element) => {
const style = getComputedStyle(element);
return { fontSize: style.fontSize, lineHeight: style.lineHeight };
};
return {
actionStack: [...actionBar.children].map((element) => ({
role:
element.getAttribute("data-fixture-control") ??
(element.matches("[data-ryd-synthetic-shorts-dislike]") ? "synthetic-dislike" : "like"),
...rect(element),
})),
commentsOuter: rect(commentsOuter),
nativeClass: nativeOuter.getAttribute("class"),
nativeCount: textStyle(nativeCount),
nativeLabel: rect(nativeLabel),
nativeOuter: rect(nativeOuter),
nativeOuterStyle: boxStyle(nativeOuter),
syntheticButton: rect(syntheticButton),
syntheticClass: syntheticOuter.getAttribute("class"),
syntheticCount: textStyle(syntheticCount),
syntheticIcon: rect(syntheticIcon),
syntheticIconWrapper: rect(syntheticIconWrapper),
syntheticLabel: rect(syntheticLabel),
syntheticOuter: rect(syntheticOuter),
syntheticOuterStyle: boxStyle(syntheticOuter),
};
});
expect(geometry.nativeClass).toBe("ytLikeButtonViewModelHost ytwReelActionBarViewModelHostDesktopActionButton");
expect(geometry.nativeOuter.width).toBe(48);
expect(geometry.nativeOuter.height).toBe(78);
expect(geometry.nativeOuterStyle.padding).toEqual(["0px", "0px", "8px", "0px"]);
expect(geometry.nativeLabel.width).toBe(48);
expect(geometry.nativeLabel.height).toBe(70);
expect(geometry.nativeCount).toEqual({ fontSize: "12px", lineHeight: "18px" });
expect(geometry.syntheticClass.split(/\s+/)).toEqual(
expect.arrayContaining([
"ytLikeButtonViewModelHost",
"ytwReelActionBarViewModelHostDesktopActionButton",
"ryd-synthetic-shorts-dislike",
]),
);
expect(geometry.syntheticClass).not.toContain("undefined");
expect(geometry.syntheticOuter.width).toBeCloseTo(geometry.nativeOuter.width, 5);
expect(geometry.syntheticOuter.height).toBeCloseTo(geometry.nativeOuter.height, 5);
expect(geometry.syntheticOuterStyle.padding).toEqual(geometry.nativeOuterStyle.padding);
expect(geometry.syntheticOuterStyle.margin).toEqual(geometry.nativeOuterStyle.margin);
expect(geometry.syntheticOuter.top).toBeCloseTo(geometry.nativeOuter.bottom, 5);
expect(geometry.syntheticButton.width).toBe(48);
expect(geometry.syntheticButton.height).toBe(48);
expect(geometry.syntheticLabel.width).toBe(48);
expect(geometry.syntheticLabel.height).toBe(70);
expect(geometry.syntheticIconWrapper.width).toBe(24);
expect(geometry.syntheticIconWrapper.height).toBe(24);
expect(geometry.syntheticIcon.width).toBe(24);
expect(geometry.syntheticIcon.height).toBe(24);
expect(geometry.syntheticIconWrapper.left + geometry.syntheticIconWrapper.width / 2).toBeCloseTo(
geometry.syntheticButton.left + geometry.syntheticButton.width / 2,
5,
);
expect(geometry.syntheticIconWrapper.top + geometry.syntheticIconWrapper.height / 2).toBeCloseTo(
geometry.syntheticButton.top + geometry.syntheticButton.height / 2,
5,
);
expect(geometry.syntheticIcon.left + geometry.syntheticIcon.width / 2).toBeCloseTo(
geometry.syntheticButton.left + geometry.syntheticButton.width / 2,
5,
);
expect(geometry.syntheticIcon.top + geometry.syntheticIcon.height / 2).toBeCloseTo(
geometry.syntheticButton.top + geometry.syntheticButton.height / 2,
5,
);
expect(geometry.syntheticCount).toEqual({ fontSize: "12px", lineHeight: "18px" });
expect(geometry.syntheticCount).toEqual(geometry.nativeCount);
expect(geometry.commentsOuter.top).toBeCloseTo(geometry.syntheticOuter.bottom, 5);
expect(geometry.actionStack.map(({ role }) => role)).toEqual([
"like",
"synthetic-dislike",
"comments",
"share",
"remix",
]);
geometry.actionStack.forEach((control, index) => {
expect(control.width).toBeCloseTo(48, 5);
expect(control.height).toBeCloseTo(78, 5);
expect(control.left).toBeGreaterThanOrEqual(0);
expect(control.right).toBeLessThanOrEqual(viewport.width);
expect(control.top).toBeGreaterThanOrEqual(0);
expect(control.bottom).toBeLessThanOrEqual(viewport.height);
expect(control.left + control.width / 2).toBeCloseTo(
geometry.actionStack[0].left + geometry.actionStack[0].width / 2,
5,
);
if (index > 0) expect(control.top).toBeCloseTo(geometry.actionStack[index - 1].bottom, 5);
});
expect(backend.blockedRequests).toEqual([]);
expect(pageErrors).toEqual([]);
});
}
for (const viewport of VIEWPORTS) {
test(`Shorts selected and unselected states preserve the stack at ${viewport.name} width`, async ({
context,
page,
}) => {
await page.setViewportSize(viewport);
const { backend, pageErrors, syntheticDislike } = await launchShortsLayoutFixture(
{ context, page },
{ initialState: "liked" },
);
const nativeLike = page.locator('[data-ryd-role="like"]:visible');
const syntheticButton = syntheticDislike.locator("button");
const readStackGeometry = () =>
page.locator("reel-action-bar-view-model:visible").evaluate((actionBar) =>
[...actionBar.children].map((element) => {
const bounds = element.getBoundingClientRect();
return { height: bounds.height, left: bounds.left, top: bounds.top, width: bounds.width };
}),
);
const initialGeometry = await readStackGeometry();
const neutralColor = await syntheticButton.evaluate((button) => getComputedStyle(button).color);
await expect(nativeLike).toHaveClass(/\bstyle-default-active\b/);
await expect(nativeLike.locator("button")).toHaveAttribute("aria-pressed", "true");
await expect(syntheticDislike).toHaveClass(/\bytLikeButtonViewModelHost\b/);
await expect(syntheticDislike).toHaveClass(/\bytwReelActionBarViewModelHostDesktopActionButton\b/);
await expect(syntheticDislike).not.toHaveClass(/\bstyle-default-active\b/);
await expect(syntheticDislike).not.toHaveClass(/\bundefined\b/);
await expect(syntheticButton).toHaveAttribute("aria-pressed", "false");
await syntheticButton.click();
await expect.poll(() => backend.requestsFor("POST", "/interact/confirmVote").length).toBe(1);
await expect(nativeLike.locator("button")).toHaveAttribute("aria-pressed", "false");
await expect(syntheticButton).toHaveAttribute("aria-pressed", "true");
await expect(syntheticDislike).toHaveClass(/\bstyle-default-active\b/);
await expect(syntheticDislike).not.toHaveClass(/\bstyle-text\b/);
await expect
.poll(() => syntheticButton.evaluate((button) => getComputedStyle(button).color))
.toBe("rgb(62, 166, 255)");
expect(await readStackGeometry()).toEqual(initialGeometry);
await syntheticButton.click();
await expect.poll(() => backend.requestsFor("POST", "/interact/confirmVote").length).toBe(2);
await expect(syntheticButton).toHaveAttribute("aria-pressed", "false");
await expect(syntheticDislike).toHaveClass(/\bstyle-text\b/);
await expect(syntheticDislike).not.toHaveClass(/\bstyle-default-active\b/);
await expect.poll(() => syntheticButton.evaluate((button) => getComputedStyle(button).color)).toBe(neutralColor);
expect(await readStackGeometry()).toEqual(initialGeometry);
expect(backend.blockedRequests).toEqual([]);
expect(pageErrors).toEqual([]);
});
}
@@ -0,0 +1,426 @@
const { test, expect } = require("@playwright/test");
const {
CREDENTIAL_KEY,
VIDEO_A,
createFakeBackend,
injectGeneratedUserscript,
installGmEnvironment,
installHermeticRoutes,
openWatchFixture,
} = require("./harness");
const EXISTING_CREDENTIALS = {
userId: "ExistingUserscriptCredential000000000001",
registrationConfirmed: true,
};
const BASE_COUNTS = { likes: 300, dislikes: 100 };
const VIEWPORTS = [
{ name: "wide", width: 1280, height: 720 },
{ name: "narrow", width: 768, height: 720 },
{ name: "mobile-sized", width: 390, height: 844 },
];
const TRANSITIONS = [
{
action: "like",
initialState: "neutral",
nextState: "liked",
value: 1,
likesDelta: 1,
dislikesDelta: 0,
},
{
action: "dislike",
initialState: "neutral",
nextState: "disliked",
value: -1,
likesDelta: 0,
dislikesDelta: 1,
},
{
action: "like",
initialState: "liked",
nextState: "neutral",
value: 0,
likesDelta: -1,
dislikesDelta: 0,
},
{
action: "dislike",
initialState: "liked",
nextState: "disliked",
value: -1,
likesDelta: -1,
dislikesDelta: 1,
},
{
action: "like",
initialState: "disliked",
nextState: "liked",
value: 1,
likesDelta: 1,
dislikesDelta: -1,
},
{
action: "dislike",
initialState: "disliked",
nextState: "neutral",
value: 0,
likesDelta: 0,
dislikesDelta: -1,
},
];
function monitorRuntime(page) {
const consoleErrors = [];
const pageErrors = [];
page.on("console", (message) => {
if (message.type() === "error") consoleErrors.push(message.text());
});
page.on("pageerror", (error) => pageErrors.push(error.message));
return { consoleErrors, pageErrors };
}
async function launchVisualFixture({ context, page }, initialState) {
const runtime = monitorRuntime(page);
const backend = createFakeBackend({
countsByVideo: { [VIDEO_A]: BASE_COUNTS },
fixture: { initialState },
});
await installGmEnvironment(context, { [CREDENTIAL_KEY]: EXISTING_CREDENTIALS });
await installHermeticRoutes(context, backend);
await openWatchFixture(page, VIDEO_A);
await injectGeneratedUserscript(page);
await expect(page.locator('[data-ryd-role="dislike"] #text')).toHaveText(String(BASE_COUNTS.dislikes));
await expect(page.locator("#return-youtube-dislike-bar-container")).toBeVisible();
return { backend, ...runtime };
}
async function readVisualContract(page) {
return page.evaluate(() => {
const required = (selector) => {
const element = document.querySelector(selector);
if (!element) throw new Error(`Missing visual-contract element: ${selector}`);
return element;
};
const rect = (element) => {
const bounds = element.getBoundingClientRect();
return {
bottom: bounds.bottom,
height: bounds.height,
left: bounds.left,
right: bounds.right,
top: bounds.top,
width: bounds.width,
};
};
const box = (element) => {
const style = getComputedStyle(element);
return {
margin: [style.marginTop, style.marginRight, style.marginBottom, style.marginLeft],
padding: [style.paddingTop, style.paddingRight, style.paddingBottom, style.paddingLeft],
};
};
const control = (role) => {
const outer = required(`[data-ryd-role="${role}"]`);
const button = required(`[data-ryd-role="${role}"] button`);
const icon = required(`[data-ryd-role="${role}"] [data-fixture-icon]`);
const text = required(`[data-ryd-role="${role}"] #text`);
const buttonStyle = getComputedStyle(button);
return {
ariaPressed: button.getAttribute("aria-pressed"),
backgroundColor: buttonStyle.backgroundColor,
button: rect(button),
buttonBox: box(button),
classes: [...outer.classList].sort(),
color: buttonStyle.color,
icon: rect(icon),
iconBox: box(icon),
outer: rect(outer),
outerBox: box(outer),
text: rect(text),
textBox: box(text),
};
};
const surface = required("#top-level-buttons-computed");
const reactionGroup = required('[data-ryd-role="buttons"]');
const topRow = required("#top-row");
const wrapper = required(".ryd-tooltip");
const barContainer = required("#return-youtube-dislike-bar-container");
const bar = required("#return-youtube-dislike-bar");
const tooltip = required("#ryd-dislike-tooltip");
const reactionGroupStyle = getComputedStyle(reactionGroup);
const surfaceStyle = getComputedStyle(surface);
const topRowStyle = getComputedStyle(topRow);
const barStyle = getComputedStyle(bar);
const barContainerStyle = getComputedStyle(barContainer);
const tooltipStyle = getComputedStyle(tooltip);
const wrapperStyle = getComputedStyle(wrapper);
return {
bar: rect(bar),
barAppearance: {
backgroundColor: barStyle.backgroundColor,
borderRadius: barStyle.borderRadius,
},
barContainer: rect(barContainer),
barContainerAppearance: {
backgroundColor: barContainerStyle.backgroundColor,
borderRadius: barContainerStyle.borderRadius,
},
buttons: rect(surface),
buttonsBox: box(surface),
buttonsGap: reactionGroupStyle.gap,
buttonsPosition: surfaceStyle.position,
dislike: control("dislike"),
like: control("like"),
ownership: {
barOwnedByButtons: surface.contains(barContainer),
tooltipDescribedBy: wrapper.getAttribute("aria-describedby"),
tooltipOwnedByButtons: surface.contains(tooltip),
tooltipRole: tooltip.getAttribute("role"),
wrapperOwnedByButtons: wrapper.parentElement === surface,
},
reactionGroup: rect(reactionGroup),
reactionGroupBox: box(reactionGroup),
topRow: rect(topRow),
topRowBox: box(topRow),
topRowStyle: {
borderBottomWidth: topRowStyle.borderBottomWidth,
paddingBottom: topRowStyle.paddingBottom,
},
tooltipText: tooltip.textContent.replace(/\s+/g, " ").trim(),
tooltipAppearance: {
fontSize: tooltipStyle.fontSize,
lineHeight: tooltipStyle.lineHeight,
opacity: tooltipStyle.opacity,
padding: box(tooltip).padding,
position: tooltipStyle.position,
visibility: tooltipStyle.visibility,
},
unique: {
bars: document.querySelectorAll("#return-youtube-dislike-bar").length,
containers: document.querySelectorAll("#return-youtube-dislike-bar-container").length,
tooltips: document.querySelectorAll("#ryd-dislike-tooltip").length,
wrappers: document.querySelectorAll(".ryd-tooltip").length,
},
viewport: { height: innerHeight, width: innerWidth },
wrapper: rect(wrapper),
wrapperAppearance: {
box: box(wrapper),
position: wrapperStyle.position,
},
};
});
}
function expectClose(actual, expected, precision = 5) {
expect(actual).toBeCloseTo(expected, precision);
}
function expectComputedColor(actual, expectedChannel) {
const srgb = actual.match(/^color\(srgb\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)/);
const channels = srgb
? srgb.slice(1).map((channel) => Number(channel) * 255)
: actual
.match(/[\d.]+/g)
?.slice(0, 3)
.map(Number);
expect(channels).toHaveLength(3);
channels.forEach((channel) => expect(channel).toBeCloseTo(expectedChannel, 0));
}
function expectControlGeometry(control) {
expectClose(control.outer.width, 96);
expectClose(control.outer.height, 36);
expectClose(control.button.width, 96);
expectClose(control.button.height, 36);
expect(control.outerBox).toEqual({
margin: ["0px", "0px", "0px", "0px"],
padding: ["0px", "0px", "0px", "0px"],
});
expect(control.buttonBox).toEqual({
margin: ["0px", "0px", "0px", "0px"],
padding: ["0px", "12px", "0px", "12px"],
});
expect(control.iconBox).toEqual({
margin: ["0px", "0px", "0px", "0px"],
padding: ["0px", "0px", "0px", "0px"],
});
expect(control.textBox).toEqual({
margin: ["0px", "0px", "0px", "0px"],
padding: ["0px", "0px", "0px", "0px"],
});
expectClose(control.icon.width, 20);
expectClose(control.icon.height, 20);
expectClose(control.icon.top + control.icon.height / 2, control.button.top + control.button.height / 2);
expectClose(control.text.top + control.text.height / 2, control.button.top + control.button.height / 2);
expectClose(control.text.left - control.icon.right, 6);
}
function expectPressedState(control, pressed) {
expect(control.ariaPressed).toBe(String(pressed));
expect(control.classes).toEqual([pressed ? "style-default-active" : "style-text"]);
expect(control.backgroundColor).toBe(pressed ? "rgb(241, 241, 241)" : "rgb(39, 39, 39)");
expect(control.color).toBe(pressed ? "rgb(15, 15, 15)" : "rgb(241, 241, 241)");
}
function expectVisualState(snapshot, state, counts, viewport) {
expect(snapshot.viewport).toEqual({ height: viewport.height, width: viewport.width });
expect(snapshot.unique).toEqual({ bars: 1, containers: 1, tooltips: 1, wrappers: 1 });
expect(snapshot.ownership).toEqual({
barOwnedByButtons: true,
tooltipDescribedBy: "ryd-dislike-tooltip",
tooltipOwnedByButtons: true,
tooltipRole: "tooltip",
wrapperOwnedByButtons: true,
});
expect(snapshot.tooltipText).toBe(`${counts.likes} / ${counts.dislikes}`);
expect(snapshot.buttonsGap).toBe("0px");
expect(snapshot.buttonsPosition).toBe("relative");
expect(snapshot.buttonsBox).toEqual({
margin: ["0px", "0px", "0px", "0px"],
padding: ["0px", "0px", "0px", "0px"],
});
expect(snapshot.reactionGroupBox).toEqual(snapshot.buttonsBox);
expect(snapshot.topRowBox).toEqual({
margin: ["0px", "0px", "0px", "0px"],
padding: ["0px", "0px", "10px", "0px"],
});
expect(snapshot.topRowStyle).toEqual({ borderBottomWidth: "1px", paddingBottom: "10px" });
expect(snapshot.wrapperAppearance).toEqual({
box: {
margin: ["0px", "0px", "0px", "0px"],
padding: ["0px", "0px", "0px", "0px"],
},
position: "absolute",
});
expect(snapshot.barContainerAppearance.borderRadius).toBe("2px");
expectComputedColor(snapshot.barContainerAppearance.backgroundColor, 139);
expect(snapshot.barAppearance).toEqual({
backgroundColor: "rgb(241, 241, 241)",
borderRadius: "2px",
});
expect(snapshot.tooltipAppearance).toEqual({
fontSize: "12px",
lineHeight: "16px",
opacity: "0",
padding: ["6px", "8px", "6px", "8px"],
position: "absolute",
visibility: "hidden",
});
expectControlGeometry(snapshot.like);
expectControlGeometry(snapshot.dislike);
expectPressedState(snapshot.like, state === "liked");
expectPressedState(snapshot.dislike, state === "disliked");
expectClose(snapshot.like.outer.right, snapshot.dislike.outer.left);
expectClose(snapshot.wrapper.left, snapshot.like.outer.left);
expectClose(snapshot.wrapper.right, snapshot.dislike.outer.right);
expectClose(snapshot.wrapper.width, snapshot.like.outer.width + snapshot.dislike.outer.width);
expectClose(snapshot.wrapper.height, 2);
expectClose(snapshot.wrapper.top - Math.max(snapshot.like.outer.bottom, snapshot.dislike.outer.bottom), 8);
expectClose(snapshot.barContainer.width, snapshot.wrapper.width);
expectClose(snapshot.barContainer.height, 2);
expectClose(snapshot.bar.height, 2);
expectClose(snapshot.bar.width / snapshot.barContainer.width, counts.likes / (counts.likes + counts.dislikes), 3);
expect(snapshot.topRow.left).toBeGreaterThanOrEqual(0);
expect(snapshot.topRow.right).toBeLessThanOrEqual(snapshot.viewport.width);
expect(snapshot.wrapper.left).toBeGreaterThanOrEqual(snapshot.topRow.left);
expect(snapshot.wrapper.right).toBeLessThanOrEqual(snapshot.topRow.right);
}
function expectNoStructuralLayoutShift(before, after) {
const paths = [
["topRow"],
["buttons"],
["reactionGroup"],
["wrapper"],
["barContainer"],
["like", "outer"],
["like", "button"],
["dislike", "outer"],
["dislike", "button"],
];
const dimensions = ["top", "right", "bottom", "left", "width", "height"];
for (const path of paths) {
const beforeRect = path.reduce((value, key) => value[key], before);
const afterRect = path.reduce((value, key) => value[key], after);
for (const dimension of dimensions) {
expect(
Math.abs(afterRect[dimension] - beforeRect[dimension]),
`${path.join(".")}.${dimension}`,
).toBeLessThanOrEqual(0.25);
}
}
}
async function waitForBarRatio(page, counts) {
const expectedRatio = counts.likes / (counts.likes + counts.dislikes);
await expect
.poll(() =>
page.locator("#return-youtube-dislike-bar").evaluate((bar) => {
const container = bar.parentElement;
return bar.getBoundingClientRect().width / container.getBoundingClientRect().width;
}),
)
.toBeCloseTo(expectedRatio, 3);
}
async function attachFailureScreenshot(testInfo, page, name) {
let body;
try {
const scope = page.locator("#top-row");
body = (await scope.count()) > 0 ? await scope.screenshot({ animations: "disabled" }) : await page.screenshot();
} catch {
body = await page.screenshot().catch(() => null);
}
if (body) {
await testInfo.attach(`${name}-visual-contract-failure`, { body, contentType: "image/png" });
}
}
for (const viewport of VIEWPORTS) {
test.describe(`${viewport.name} watch visual contract`, () => {
for (const transition of TRANSITIONS) {
const name = `${transition.initialState} + ${transition.action} -> ${transition.nextState}`;
test(name, async ({ context, page }, testInfo) => {
await page.setViewportSize({ width: viewport.width, height: viewport.height });
try {
const harness = await launchVisualFixture({ context, page }, transition.initialState);
const before = await readVisualContract(page);
expectVisualState(before, transition.initialState, BASE_COUNTS, viewport);
await page.locator(`[data-ryd-role="${transition.action}"] button`).click();
await expect.poll(() => harness.backend.requestsFor("POST", "/interact/confirmVote").length).toBe(1);
const nextCounts = {
likes: BASE_COUNTS.likes + transition.likesDelta,
dislikes: BASE_COUNTS.dislikes + transition.dislikesDelta,
};
await waitForBarRatio(page, nextCounts);
const after = await readVisualContract(page);
expectVisualState(after, transition.nextState, nextCounts, viewport);
expectNoStructuralLayoutShift(before, after);
expect(harness.backend.requestsFor("GET", "/votes")).toHaveLength(1);
expect(harness.backend.requestsFor("POST", "/interact/vote")).toHaveLength(1);
expect(harness.backend.requestsFor("POST", "/interact/vote")[0].body).toMatchObject({
userId: EXISTING_CREDENTIALS.userId,
value: transition.value,
videoId: VIDEO_A,
});
expect(harness.backend.requestsFor("POST", "/interact/confirmVote")).toHaveLength(1);
expect(harness.backend.blockedRequests).toEqual([]);
expect(harness.consoleErrors).toEqual([]);
expect(harness.pageErrors).toEqual([]);
} catch (error) {
await attachFailureScreenshot(testInfo, page, `${viewport.name}-${name}`);
throw error;
}
});
}
});
}
@@ -0,0 +1,67 @@
const { test, expect } = require("@playwright/test");
const {
CREDENTIAL_KEY,
VIDEO_A,
createFakeBackend,
forbidUnsafeHtmlSinks,
injectGeneratedUserscript,
installGmEnvironment,
installHermeticRoutes,
openShortsFixture,
openWatchFixture,
} = require("./harness");
const EXISTING_CREDENTIALS = {
userId: "ExistingUserscriptCredential000000000001",
registrationConfirmed: true,
};
async function prepareGuardedFixture({ context, page }, openFixture) {
const pageErrors = [];
page.on("pageerror", (error) => pageErrors.push(error.message));
const backend = createFakeBackend();
await installGmEnvironment(context, { [CREDENTIAL_KEY]: EXISTING_CREDENTIALS });
await installHermeticRoutes(context, backend);
await openFixture(page, VIDEO_A);
await forbidUnsafeHtmlSinks(page);
await injectGeneratedUserscript(page);
return { backend, pageErrors };
}
async function expectNoUnsafeHtmlUsage(page, backend, pageErrors) {
expect(await page.evaluate(() => globalThis.__rydUnsafeHtmlSinkCalls)).toEqual([]);
expect(backend.blockedRequests).toEqual([]);
expect(pageErrors).toEqual([]);
}
test("watch ratio bar initializes when unsafe HTML sinks are forbidden", async ({ context, page }) => {
const { backend, pageErrors } = await prepareGuardedFixture({ context, page }, openWatchFixture);
await expect(page.locator('[data-ryd-role="dislike"] #text')).toHaveText("25");
expect(await page.evaluate(() => globalThis.__rydUnsafeHtmlSinkCalls)).toEqual([]);
await expect(page.locator("#return-youtube-dislike-bar-container")).toBeVisible();
await expect(page.locator("#return-youtube-dislike-bar")).toBeVisible();
await expect(page.locator("#ryd-dislike-tooltip")).toContainText("100 / 25");
await expectNoUnsafeHtmlUsage(page, backend, pageErrors);
});
test("modern Shorts synthetic dislike initializes when unsafe HTML sinks are forbidden", async ({ context, page }) => {
const { backend, pageErrors } = await prepareGuardedFixture({ context, page }, openShortsFixture);
const syntheticDislike = page.locator("[data-ryd-synthetic-shorts-dislike]");
await expect
.poll(() =>
page.evaluate(() => ({
initialized: Boolean(document.querySelector("[data-ryd-synthetic-shorts-dislike]")),
unsafeHtmlSinkCalls: globalThis.__rydUnsafeHtmlSinkCalls,
})),
)
.toEqual({ initialized: true, unsafeHtmlSinkCalls: [] });
await expect(syntheticDislike).toBeVisible();
await expect(syntheticDislike.locator("button")).toHaveAttribute("aria-pressed", "false");
await expect(syntheticDislike.locator("#text")).toHaveText("25");
await expect(syntheticDislike.locator("svg path")).toHaveCount(1);
await expectNoUnsafeHtmlUsage(page, backend, pageErrors);
});
File diff suppressed because it is too large Load Diff