mirror of
https://github.com/Alvin-Zilverstand/return-youtube-dislike.git
synced 2026-10-28 07:40:27 +01:00
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:
@@ -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>
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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
Reference in New Issue
Block a user