Prepare 4.0.5 Firefox update and Shorts fixes

Add Firefox data consent, repair contributor sign-in, and introduce optional Hide clutter buttons. Harden video navigation, voting, and action layouts with shared browser regression coverage and reproducible release packaging.
This commit is contained in:
Anarios
2026-09-06 12:51:12 +02:00
parent 3657de9b2c
commit e500088e65
196 changed files with 39706 additions and 2694 deletions
@@ -29,6 +29,15 @@
min-width: 96px;
}
/* Model YouTube's overlaid Shorts next control without causing a document scroll on activation. */
#short-next {
margin: 0;
position: fixed;
right: 4px;
top: 4px;
z-index: 1;
}
ytd-watch-flexy,
ytd-menu-renderer,
segmented-like-dislike-button-view-model,
@@ -49,6 +58,19 @@
}
segmented-like-dislike-button-view-model {
display: block;
min-height: 48px;
width: 320px;
}
segmented-like-dislike-button-view-model > yt-smartimation,
[data-fixture-smartimation-content-shell] {
display: block;
min-height: 48px;
width: 320px;
}
[data-fixture-smartimation-content] {
display: flex;
gap: 8px;
min-height: 48px;
@@ -60,10 +82,26 @@
min-width: 96px;
}
[data-fixture-watch-icon] {
height: 20px;
width: 20px;
}
[data-fixture-touch-feedback] {
display: none;
}
ytd-reel-video-renderer {
display: block;
min-height: 640px;
width: 420px;
width: min(420px, 100%);
}
.reel-video-in-sequence-new,
.reel-video-in-sequence-thumbnail {
display: block;
min-height: 1px;
min-width: 1px;
}
ytd-reel-video-renderer[hidden] {
@@ -155,11 +193,27 @@
(() => {
const firstVideoId = "__VIDEO_ID__";
const secondVideoId = "__SECOND_VIDEO_ID__";
const nativeLikesByVideo = new Map([
[firstVideoId, Number("__VIDEO_LIKES__")],
[secondVideoId, Number("__SECOND_VIDEO_LIKES__")],
]);
const marker = "rydNavigationFixture=1";
const navigation = document.getElementById("fixture-navigation");
const page = document.getElementById("fixture-page");
const isMobile = location.hostname === "m.youtube.com";
const fixtureBaseline = {
renderedDesktopShortsNativeDislikes: null,
renderedMobileShortsNativeDislikes: null,
renderedWatchNativeDislikeTextCount: null,
};
const nativeReactionStats = {
activations: 0,
dislikeActivations: 0,
likeActivations: 0,
};
globalThis.__navigationFixtureBaseline = fixtureBaseline;
let pendingDelayedRender = null;
let pendingStartedWatchRender = null;
if (!customElements.get("like-button-view-model")) {
customElements.define(
@@ -178,6 +232,55 @@
return `/@FixtureChannel?${marker}`;
};
const nativeLikes = (videoId) => nativeLikesByVideo.get(videoId) ?? 100;
function setNativeLikeCount(root, videoId) {
const count = nativeLikes(videoId);
const like = root?.querySelector('[data-fixture-role="like"]');
like?.querySelector("button")?.setAttribute("aria-label", `${count} likes`);
const text = like?.querySelector("#text, [role='text']");
if (text) text.textContent = String(count);
}
function setNativeReactionPressed(control, pressed) {
control?.classList.toggle("style-default-active", pressed);
control?.classList.toggle("style-text", !pressed);
control?.querySelector("button")?.setAttribute("aria-pressed", String(pressed));
}
function adjustNativeLikeCount(like, delta) {
const text = like?.querySelector("#text, [role='text']");
const count = Number.parseInt(text?.textContent, 10);
if (!text || !Number.isFinite(count)) return;
text.textContent = String(Math.max(0, count + delta));
}
document.addEventListener(
"click",
(event) => {
const nativeButton = event.target.closest("button");
const control = nativeButton?.closest('[data-fixture-role="like"], [data-fixture-role="dislike"]');
if (!control || control.querySelector("button") !== nativeButton) return;
const controls = control.closest('[data-fixture-role="buttons"]');
const role = control.getAttribute("data-fixture-role");
const like = controls?.querySelector('[data-fixture-role="like"]');
const dislike = controls?.querySelector('[data-fixture-role="dislike"]');
const wasPressed = nativeButton.getAttribute("aria-pressed") === "true";
const counterpart = role === "like" ? dislike : like;
const counterpartWasPressed = counterpart?.querySelector("button")?.getAttribute("aria-pressed") === "true";
nativeReactionStats.activations += 1;
nativeReactionStats[`${role}Activations`] += 1;
setNativeReactionPressed(control, !wasPressed);
if (!wasPressed) setNativeReactionPressed(counterpart, false);
if (role === "like") adjustNativeLikeCount(like, wasPressed ? -1 : 1);
if (role === "dislike" && !wasPressed && counterpartWasPressed) adjustNativeLikeCount(like, -1);
},
true,
);
const linkMarkup = (id, label, pageKind, videoId, options = {}) => `
<a
id="${id}"
@@ -192,13 +295,13 @@
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">
<div class="segmented-buttons" data-fixture-role="buttons">
<like-button-view-model class="style-text" data-fixture-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">
<dislike-button-view-model class="style-text" data-fixture-role="dislike">
<button type="button" aria-label="Channel preview dislike" aria-pressed="false">
<span id="text" role="text"></span>
</button>
@@ -210,13 +313,13 @@
<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">
<segmented-like-dislike-button-view-model data-fixture-role="buttons">
<like-button-view-model class="style-text" data-fixture-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">
<dislike-button-view-model class="style-text" data-fixture-role="dislike">
<button type="button" aria-label="Channel preview dislike" aria-pressed="false">
<span id="text" role="text"></span>
</button>
@@ -237,32 +340,57 @@
function watchMarkup(videoId) {
const nextVideoId = videoId === firstVideoId ? secondVideoId : firstVideoId;
const likeCount = nativeLikes(videoId);
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>
<ytd-watch-flexy video-id="${videoId}">
<div id="menu-container" style="display: none"></div>
<div id="player" loading="false"></div>
<div id="top-row">
<ytd-menu-renderer class="ytd-watch-metadata">
<div id="top-level-buttons-computed">
<segmented-like-dislike-button-view-model
data-fixture-role="buttons"
data-fixture-control-video-id="${videoId}"
>
<yt-smartimation>
<div id="content" data-fixture-smartimation-content-shell>
<div id="wrapper" data-fixture-smartimation-content>
<like-button-view-model class="style-text" data-fixture-role="like">
<toggle-button-view-model>
<button-view-model>
<button type="button" aria-label="${likeCount} likes" aria-pressed="false">
<svg data-fixture-watch-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>
<div class="ytSpecButtonShapeNextButtonTextContent" data-fixture-provided-native-text>
<span id="text" role="text">${likeCount}</span>
</div>
<span data-fixture-touch-feedback aria-hidden="true"></span>
</button>
</button-view-model>
</toggle-button-view-model>
</like-button-view-model>
<dislike-button-view-model class="style-text" data-fixture-role="dislike">
<toggle-button-view-model>
<button-view-model>
<button type="button" aria-label="Dislike this video" aria-pressed="false">
<svg data-fixture-watch-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 data-fixture-touch-feedback aria-hidden="true"></span>
</button>
</button-view-model>
</toggle-button-view-model>
</dislike-button-view-model>
</div>
</div>
</yt-smartimation>
</segmented-like-dislike-button-view-model>
</div>
</ytd-menu-renderer>
</div>
</ytd-watch-flexy>
${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)}
@@ -272,38 +400,75 @@
}
function shortRendererMarkup(videoId, active) {
const likeCount = nativeLikes(videoId);
return `
<div class="reel-video-in-sequence-new" data-fixture-sequence-video-id="${videoId}">
<div class="reel-video-in-sequence-thumbnail"></div>
<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">
<div id="experiment-overlay"><span>Ready</span></div>
<a class="ytp-title-link" href="/shorts/${videoId}" aria-label="Short ${videoId}"></a>
<reel-action-bar-view-model data-fixture-role="buttons">
<like-button-view-model
class="ytLikeButtonViewModelHost ytwReelActionBarViewModelHostDesktopActionButton"
data-ryd-role="like"
data-fixture-role="like"
>
<label class="ytSpecButtonShapeWithLabelHost">
<button class="ytSpecButtonShapeNextHost" type="button" aria-label="100 likes" aria-pressed="false">
<button class="ytSpecButtonShapeNextHost" type="button" aria-label="${likeCount} likes" aria-pressed="false">
<span class="ytSpecButtonShapeNextIcon" aria-hidden="true">
<svg viewBox="0 0 24 24"><path d="M12 21"></path></svg>
<svg viewBox="0 0 24 24"><path d="M8 21H5V9h3v12Zm2 0V9l4-7 2 1v5h5v5l-3 8h-8Z"></path></svg>
</span>
</button>
<div class="ytSpecButtonShapeWithLabelLabel"><span id="text" role="text">100</span></div>
<div class="ytSpecButtonShapeWithLabelLabel"><span id="text" role="text">${likeCount}</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>
<label class="ytSpecButtonShapeWithLabelHost">
<button type="button" aria-label="View comments">
<span class="ytSpecButtonShapeNextIcon" aria-hidden="true">
<svg viewBox="0 0 24 24"><path d="M3 4h18v14H8l-5 3Z"></path></svg>
</span>
</button>
<div class="ytSpecButtonShapeWithLabelLabel"><span role="text">12</span></div>
</label>
</button-view-model>
<button-view-model data-fixture-control="share">
<button type="button" aria-label="Share">Share</button>
<label class="ytSpecButtonShapeWithLabelHost">
<button type="button" aria-label="Share">
<span class="ytSpecButtonShapeNextIcon" aria-hidden="true">
<svg viewBox="0 0 24 24"><path d="M14 4l7 7-7 7v-4H3V8h11Z"></path></svg>
</span>
</button>
<div class="ytSpecButtonShapeWithLabelLabel"><span role="text">Share</span></div>
</label>
</button-view-model>
<button-view-model data-fixture-control="remix">
<button type="button" aria-label="Remix">Remix</button>
<label class="ytSpecButtonShapeWithLabelHost">
<button type="button" aria-label="Remix">
<span class="ytSpecButtonShapeNextIcon" aria-hidden="true">
<svg viewBox="0 0 24 24"><path d="M4 7h10l-2-2 2-2 6 6-6 6-2-2 2-2H4Z"></path></svg>
</span>
</button>
<div class="ytSpecButtonShapeWithLabelLabel"><span role="text">Remix</span></div>
</label>
</button-view-model>
<button-view-model data-fixture-control="sound">
<label class="ytSpecButtonShapeWithLabelHost">
<button type="button" aria-label="Sound">
<span class="ytSpecButtonShapeNextIcon" aria-hidden="true">
<svg viewBox="0 0 24 24"><path d="M4 9v6h4l5 4V5L8 9H4Zm12.5 3a3.5 3.5 0 0 0-2-3.16v6.32a3.5 3.5 0 0 0 2-3.16Z"></path></svg>
</span>
</button>
<div class="ytSpecButtonShapeWithLabelLabel"><span role="text">Sound</span></div>
</label>
</button-view-model>
</reel-action-bar-view-model>
</ytd-reel-video-renderer>
</div>
`;
}
function mobileShortRendererMarkup(videoId, active) {
const likeCount = nativeLikes(videoId);
return `
<ytm-reel-video-renderer
data-fixture-mobile-short="${videoId}"
@@ -313,13 +478,13 @@
<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>
<ytm-like-button-renderer data-fixture-role="buttons">
<like-button-view-model class="style-text" data-fixture-role="like">
<button type="button" aria-label="${likeCount} likes" aria-pressed="false">
<span id="text" class="button-renderer-text" role="text">${likeCount}</span>
</button>
</like-button-view-model>
<dislike-button-view-model class="style-text" data-ryd-role="dislike">
<dislike-button-view-model class="style-text" data-fixture-role="dislike">
<button type="button" aria-label="Dislike this video" aria-pressed="false">
<span id="text" class="button-renderer-text" role="text"></span>
</button>
@@ -367,6 +532,24 @@
function render(pageKind, videoId) {
page.innerHTML = pageMarkup(pageKind, videoId);
fixtureBaseline.renderedWatchNativeDislikeTextCount =
pageKind === "watch"
? page.querySelectorAll(
'[data-fixture-page-kind="watch"] [data-fixture-role="dislike"] .ytSpecButtonShapeNextButtonTextContent',
).length
: null;
fixtureBaseline.renderedDesktopShortsNativeDislikes =
pageKind === "shorts" && !isMobile
? page.querySelectorAll(
"ytd-reel-video-renderer[is-active] > reel-action-bar-view-model > dislike-button-view-model",
).length
: null;
fixtureBaseline.renderedMobileShortsNativeDislikes =
pageKind === "shorts" && isMobile
? page.querySelectorAll(
"ytm-reel-video-renderer[is-active] ytm-like-button-renderer > dislike-button-view-model",
).length
: null;
navigation.innerHTML =
pageKind === "channel"
? ""
@@ -475,6 +658,28 @@
dispatchNavigation();
}
function navigateWatchAfterStartWithoutFinish(videoId) {
const watchPage = page.querySelector('[data-fixture-page-kind="watch"]');
const watch = watchPage?.querySelector("ytd-watch-flexy");
if (!watchPage || !watch) return;
document.dispatchEvent(new Event("yt-navigate-start", { bubbles: true }));
history.pushState({}, "", hrefFor("watch", videoId));
watchPage.setAttribute("data-fixture-video-id", videoId);
watch.setAttribute("video-id", videoId);
page.dataset.fixtureTransitionPending = "watch-start-without-finish";
pendingStartedWatchRender = () => {
delete page.dataset.fixtureTransitionPending;
render("watch", videoId);
};
}
function finishWatchAfterStartWithoutFinish() {
if (!pendingStartedWatchRender) throw new Error("No start-only Watch navigation is pending");
const finish = pendingStartedWatchRender;
pendingStartedWatchRender = null;
finish();
}
function beginSameNodeWatchNavigation(videoId) {
const watch = page.querySelector("ytd-watch-flexy");
if (!watch) return;
@@ -488,7 +693,7 @@
}
function mutateOutgoingWatchDescendant() {
const dislike = page.querySelector('[data-fixture-page-kind="watch"] [data-ryd-role="dislike"]');
const dislike = page.querySelector('[data-fixture-page-kind="watch"] [data-fixture-role="dislike"]');
if (!dislike) return;
const countMarker = document.createElement("span");
countMarker.hidden = true;
@@ -502,7 +707,7 @@
}
function replaceDelayedWatchControl(role) {
const control = page.querySelector(`[data-fixture-page-kind="watch"] [data-ryd-role="${role}"]`);
const control = page.querySelector(`[data-fixture-page-kind="watch"] [data-fixture-role="${role}"]`);
if (!control) return;
const replacement = control.cloneNode(true);
replacement.setAttribute("data-fixture-watch-replacement", role);
@@ -516,7 +721,7 @@
control.replaceWith(replacement);
}
function replaceCurrentWatchActions({ retainOutgoing = false } = {}) {
function replaceCurrentWatchActions({ nativeHydrationDelay = 0, retainOutgoing = false } = {}) {
const section = page.querySelector('[data-fixture-page-kind="watch"]');
const currentActions = section?.querySelector("#top-level-buttons-computed");
const videoId = section?.dataset.fixtureVideoId;
@@ -526,10 +731,10 @@
replacement.setAttribute("data-fixture-watch-actions-replacement", videoId);
replacement.querySelectorAll(".ryd-tooltip").forEach((tooltip) => tooltip.remove());
const controls = replacement.querySelector('[data-ryd-role="buttons"]');
const controls = replacement.querySelector('[data-fixture-role="buttons"]');
controls?.setAttribute("data-fixture-control-video-id", videoId);
for (const role of ["like", "dislike"]) {
const control = controls?.querySelector(`[data-ryd-role="${role}"]`);
const control = controls?.querySelector(`[data-fixture-role="${role}"]`);
control?.classList.remove("style-default-active");
control?.classList.add("style-text");
const button = control?.querySelector("button");
@@ -537,8 +742,24 @@
button?.removeAttribute("disabled");
button?.setAttribute("aria-disabled", "false");
}
const dislikeCount = controls?.querySelector('[data-ryd-role="dislike"] #text');
if (dislikeCount) dislikeCount.textContent = "";
controls
?.querySelectorAll(
'[data-fixture-role="dislike"] .ytSpecButtonShapeNextButtonTextContent, [data-fixture-role="dislike"] #text',
)
.forEach((element) => element.remove());
const delayedNativeButtons = [];
if (nativeHydrationDelay > 0) {
for (const role of ["like", "dislike"]) {
const control = controls?.querySelector(`[data-fixture-role="${role}"]`);
const button = control?.querySelector("button");
if (control && button) {
control.setAttribute("data-fixture-native-hydration-pending", role);
delayedNativeButtons.push({ button, control });
button.remove();
}
}
}
currentActions.replaceWith(replacement);
globalThis.__fixtureReplacedWatchActions = currentActions;
@@ -549,17 +770,28 @@
retained.appendChild(currentActions);
document.body.appendChild(retained);
}
if (delayedNativeButtons.length > 0) {
setTimeout(() => {
for (const { button, control } of delayedNativeButtons) {
if (replacement.isConnected && control.isConnected && !control.querySelector("button")) {
control.appendChild(button);
control.removeAttribute("data-fixture-native-hydration-pending");
}
}
}, nativeHydrationDelay);
}
return true;
}
function refreshReusedWatchControl(role, videoId) {
const control = page.querySelector(`[data-fixture-page-kind="watch"] [data-ryd-role="${role}"]`);
const control = page.querySelector(`[data-fixture-page-kind="watch"] [data-fixture-role="${role}"]`);
if (!control) return;
setNativeReactionPressed(control, false);
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`);
const button = page.querySelector(`[data-fixture-page-kind="watch"] [data-fixture-role="${role}"] button`);
if (!button) return;
button.setAttribute("aria-label", `${role} drift while ${videoId}`);
button.setAttribute("title", `${role} title drift while ${videoId}`);
@@ -571,7 +803,7 @@
if (!overlay) return;
const replacement = overlay.cloneNode(true);
replacement.setAttribute("data-fixture-replacement", "true");
const dislike = replacement.querySelector('[data-ryd-role="dislike"]');
const dislike = replacement.querySelector('[data-fixture-role="dislike"]');
dislike?.classList.remove("style-default-active");
dislike?.classList.add("style-text");
dislike?.querySelector("button")?.setAttribute("aria-pressed", "false");
@@ -624,18 +856,24 @@
},
finishDelayedNavigation,
finishSameNodeWatchNavigation,
finishWatchAfterStartWithoutFinish,
driftWatchControlBeforeNavigation,
navigate,
navigateDelayedAnonymousShort,
navigateDelayedShort,
navigateDelayedWatch,
navigateToShortsDescendant,
navigateWatchAfterStartWithoutFinish,
mutateOutgoingWatchDescendant,
nativeReactionSnapshot() {
return { ...nativeReactionStats };
},
recycleShort,
replaceActiveMobileOverlay,
replaceCurrentWatchActions,
replaceDelayedWatchControl,
refreshReusedWatchControl,
setNativeLikeCount,
};
render("__INITIAL_PAGE_KIND__", "__VIDEO_ID__");
@@ -25,6 +25,9 @@
.slim-video-action-bar-actions,
.segmented-buttons,
segmented-like-dislike-button-view-model,
yt-smartimation,
toggle-button-view-model,
button-view-model,
like-button-view-model,
dislike-button-view-model {
display: block;
@@ -32,17 +35,33 @@
ytd-menu-renderer.ytd-watch-metadata > div {
display: block;
width: min(320px, 100%);
min-height: 36px;
}
#top-row {
width: min(320px, 100%);
width: min(500px, 100%);
}
ytd-menu-renderer.ytd-watch-metadata {
display: flex;
gap: 8px;
width: min(500px, 100%);
}
#top-level-buttons-computed {
position: relative;
width: 192px;
}
segmented-like-dislike-button-view-model {
display: block;
gap: 0;
}
[data-fixture-smartimation-content] {
display: flex;
gap: 0;
width: 192px;
}
.segmented-buttons {
@@ -56,8 +75,8 @@
display: none;
}
[data-ryd-role="like"],
[data-ryd-role="dislike"] {
[__FIXTURE_ROLE_ATTRIBUTE__="like"],
[__FIXTURE_ROLE_ATTRIBUTE__="dislike"] {
box-sizing: border-box;
flex: 0 0 96px;
height: 36px;
@@ -66,8 +85,8 @@
width: 96px;
}
[data-ryd-role="like"] button,
[data-ryd-role="dislike"] button {
[__FIXTURE_ROLE_ATTRIBUTE__="like"] button,
[__FIXTURE_ROLE_ATTRIBUTE__="dislike"] button {
align-items: center;
background: rgb(39, 39, 39);
border: 0;
@@ -88,7 +107,7 @@
width: 96px;
}
[data-ryd-role].style-default-active button {
[__FIXTURE_ROLE_ATTRIBUTE__].style-default-active button {
background: rgb(241, 241, 241);
color: rgb(15, 15, 15);
}
@@ -102,7 +121,8 @@
width: 20px;
}
[data-ryd-role] #text {
[__FIXTURE_ROLE_ATTRIBUTE__] #text,
[__FIXTURE_ROLE_ATTRIBUTE__] .ytSpecButtonShapeNextButtonTextContent {
display: block;
font:
500 14px/20px Arial,
@@ -110,6 +130,32 @@
margin: 0;
padding: 0;
}
#flexible-item-buttons {
display: flex;
gap: 8px;
margin: 0;
width: 172px;
}
#flexible-item-buttons button {
background: rgb(39, 39, 39);
border: 0;
border-radius: 18px;
color: rgb(241, 241, 241);
height: 36px;
min-width: 82px;
}
#fixture-comment-action-surfaces #top-level-buttons-computed {
height: 0;
overflow: hidden;
width: 0;
}
[data-fixture-touch-feedback] {
display: none;
}
</style>
</head>
<body>
@@ -117,12 +163,22 @@
<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>
<ytd-watch-flexy id="fixture-watch" video-id="__VIDEO_ID__">
<div id="top-row" data-fixture-shell="desktop">
<ytd-menu-renderer class="ytd-watch-metadata">
<div id="top-level-buttons-computed"></div>
<div id="flexible-item-buttons" data-fixture-unrelated-actions>
<button-view-model
><button type="button"><span role="text">Save</span></button></button-view-model
>
<button-view-model
><button type="button"><span role="text">Download</span></button></button-view-model
>
</div>
</ytd-menu-renderer>
</div>
</ytd-watch-flexy>
<div id="fixture-comment-action-surfaces"></div>
<div class="slim-video-action-bar-actions" data-fixture-shell="mobile">
<div id="fixture-mobile-buttons-host" class="segmented-buttons"></div>
</div>
@@ -147,51 +203,67 @@
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 __FIXTURE_ROLE_ATTRIBUTE__="buttons">
<yt-smartimation>
<div id="content" data-fixture-smartimation-content-shell>
<div id="wrapper" data-fixture-smartimation-content>
<like-button-view-model class="style-text" __FIXTURE_ROLE_ATTRIBUTE__="like">
<toggle-button-view-model>
<button-view-model>
<button type="button" aria-label="__VIDEO_LIKES__ 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>
<div class="ytSpecButtonShapeNextButtonTextContent"><span id="text" role="text">__VIDEO_LIKES__</span></div>
<span data-fixture-touch-feedback aria-hidden="true"></span>
</button>
</button-view-model>
</toggle-button-view-model>
</like-button-view-model>
<dislike-button-view-model class="style-text" __FIXTURE_ROLE_ATTRIBUTE__="dislike">
<toggle-button-view-model>
<button-view-model>
<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>
__DISLIKE_TEXT_MARKUP__
<span data-fixture-touch-feedback aria-hidden="true"></span>
</button>
</button-view-model>
</toggle-button-view-model>
</dislike-button-view-model>
</div>
</div>
</yt-smartimation>
</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">
<like-button-view-model class="style-text" __FIXTURE_ROLE_ATTRIBUTE__="like">
<button type="button" aria-label="__VIDEO_LIKES__ 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>
<div class="ytSpecButtonShapeNextButtonTextContent"><span id="text" class="button-renderer-text" role="text">__VIDEO_LIKES__</span></div>
</button>
</like-button-view-model>
<dislike-button-view-model class="style-text" data-ryd-role="dislike">
<dislike-button-view-model class="style-text" __FIXTURE_ROLE_ATTRIBUTE__="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>
__DISLIKE_TEXT_MARKUP__
</button>
</dislike-button-view-model>
`;
}
function applyNativeState(state) {
const like = host.querySelector('[data-ryd-role="like"]');
const dislike = host.querySelector('[data-ryd-role="dislike"]');
const like = host.querySelector('[__FIXTURE_ROLE_ATTRIBUTE__="like"]');
const dislike = host.querySelector('[__FIXTURE_ROLE_ATTRIBUTE__="dislike"]');
if (!like || !dislike) return;
like.classList.toggle("style-default-active", state === "liked");
@@ -203,6 +275,17 @@
host.dataset.nativeState = state;
}
function applyNativeLikeDelta(delta) {
const button = host.querySelector('[__FIXTURE_ROLE_ATTRIBUTE__="like"] button');
const text = button?.querySelector("#text, [role='text']");
if (!button || !text) return;
const current = Number(text.textContent.replace(/[^\d]/g, "")) || 0;
const next = Math.max(0, current + delta);
text.textContent = String(next);
button.setAttribute("aria-label", `${next} likes`);
}
function insertButtons(state = "neutral") {
host.innerHTML = isMobile ? mobileButtonMarkup() : desktopButtonMarkup();
applyNativeState(state);
@@ -213,6 +296,28 @@
host.dataset.nativeState = "neutral";
}
function installCommentActionSurfaces() {
const comments = document.getElementById("fixture-comment-action-surfaces");
for (let index = 0; index < 20; index += 1) {
const duplicate = document.createElement("div");
duplicate.id = "top-level-buttons-computed";
duplicate.setAttribute("data-fixture-comment-action-surface", String(index));
duplicate.innerHTML = `
<like-button-view-model data-fixture-comment-like>
<button type="button" aria-label="Like comment" aria-pressed="false">
<span role="text">1</span>
</button>
</like-button-view-model>
<dislike-button-view-model data-fixture-comment-dislike>
<button type="button" aria-label="Dislike comment" aria-pressed="false">
<span data-fixture-touch-feedback aria-hidden="true"></span>
</button>
</dislike-button-view-model>
`;
comments.appendChild(duplicate);
}
}
function navigate(videoId, { state = "neutral", buttons = true } = {}) {
history.pushState({}, "", `/watch?v=${videoId}`);
watch.setAttribute("video-id", videoId);
@@ -225,11 +330,22 @@
host.addEventListener(
"click",
(event) => {
const like = event.target.closest('[data-ryd-role="like"]');
const dislike = event.target.closest('[data-ryd-role="dislike"]');
const like = event.target.closest('[__FIXTURE_ROLE_ATTRIBUTE__="like"]');
const dislike = event.target.closest('[__FIXTURE_ROLE_ATTRIBUTE__="dislike"]');
const current = host.dataset.nativeState || "neutral";
if (like) applyNativeState(current === "liked" ? "neutral" : "liked");
if (dislike) applyNativeState(current === "disliked" ? "neutral" : "disliked");
const next = like
? current === "liked"
? "neutral"
: "liked"
: dislike
? current === "disliked"
? "neutral"
: "disliked"
: current;
if (next !== current) {
applyNativeLikeDelta(Number(next === "liked") - Number(current === "liked"));
applyNativeState(next);
}
},
true,
);
@@ -243,7 +359,11 @@
};
setSignedIn(__SIGNED_IN__);
installCommentActionSurfaces();
if (__INITIAL_BUTTONS__) insertButtons("__INITIAL_STATE__");
window.__youtubeFixture.initialDislikeTextContainers = host.querySelectorAll(
'[__FIXTURE_ROLE_ATTRIBUTE__="dislike"] #text, [__FIXTURE_ROLE_ATTRIBUTE__="dislike"] .ytSpecButtonShapeNextButtonTextContent',
).length;
})();
</script>
</body>
+42 -9
View File
@@ -1,10 +1,10 @@
const fs = require("fs");
const path = require("path");
const { isAllowedApiPreflight } = require("../../e2e/hermetic-api-contract");
const { resolveRequestedUserscriptArtifact } = require("../../e2e/verify-userscript-artifact");
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 GENERATED_USERSCRIPT = resolveRequestedUserscriptArtifact();
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");
@@ -45,11 +45,14 @@ function jsonHeaders() {
function createFakeBackend({ countsByVideo = {}, countDelayByVideo = {}, fixture = {} } = {}) {
const blockedRequests = [];
const preflightRequests = [];
const requests = [];
const responsePlans = new Map();
const fixtureOptions = {
initialButtons: fixture.initialButtons !== false,
initialState: fixture.initialState || "neutral",
nativeDislikeText: fixture.nativeDislikeText !== false,
roleAttribute: fixture.roleAttribute || "data-ryd-role",
signedIn: fixture.signedIn !== false,
};
@@ -147,6 +150,13 @@ function createFakeBackend({ countsByVideo = {}, countDelayByVideo = {}, fixture
const html = fixtureTemplate
.replaceAll("__VIDEO_ID__", videoId)
.replaceAll("__SECOND_VIDEO_ID__", VIDEO_B)
.replaceAll("__VIDEO_LIKES__", String(countsByVideo[videoId]?.likes ?? 100))
.replaceAll("__SECOND_VIDEO_LIKES__", String(countsByVideo[VIDEO_B]?.likes ?? 100))
.replaceAll(
"__DISLIKE_TEXT_MARKUP__",
fixtureOptions.nativeDislikeText ? '<span id="text" role="text"></span>' : "",
)
.replaceAll("__FIXTURE_ROLE_ATTRIBUTE__", fixtureOptions.roleAttribute)
.replaceAll("__INITIAL_PAGE_KIND__", isShorts ? "shorts" : url.pathname === "/watch" ? "watch" : "channel")
.replaceAll(
"__SHORTS_RENDERER_TAG__",
@@ -174,6 +184,22 @@ function createFakeBackend({ countsByVideo = {}, countDelayByVideo = {}, fixture
}
if (request.method() === "OPTIONS") {
const requestedMethod = request.headers()["access-control-request-method"];
const preflight = {
method: request.method(),
pathname: url.pathname,
requestedMethod: requestedMethod ?? null,
url: url.toString(),
};
if (!isAllowedApiPreflight(url.pathname, requestedMethod)) {
blockedRequests.push({
...preflight,
resourceType: request.resourceType(),
});
await route.abort("blockedbyclient");
return;
}
preflightRequests.push(preflight);
await route.fulfill({ status: 204, headers: jsonHeaders(), body: "" });
return;
}
@@ -202,11 +228,14 @@ function createFakeBackend({ countsByVideo = {}, countDelayByVideo = {}, fixture
if (response.delayMs) await delay(response.delayMs);
const body = response.body === undefined ? null : response.body;
const responseStatus = response.status || 200;
await route.fulfill({
status: response.status || 200,
status: responseStatus,
headers: { ...jsonHeaders(), ...(response.headers || {}) },
body: typeof body === "string" ? body : JSON.stringify(body),
});
record.responseBody = body;
record.responseStatus = responseStatus;
record.respondedAt = Date.now();
}
@@ -215,6 +244,7 @@ function createFakeBackend({ countsByVideo = {}, countDelayByVideo = {}, fixture
defer,
enqueue,
handle,
preflightRequests,
requests,
requestsFor,
};
@@ -340,17 +370,20 @@ function overrideBooleanOption(source, optionName, value) {
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}`);
async function injectGeneratedUserscript(
page,
{ artifactPath = GENERATED_USERSCRIPT, coloredThumbs, disableVoteSubmission = false, rateBarEnabled } = {},
) {
if (!fs.existsSync(artifactPath)) {
throw new Error(`Generated userscript is missing: ${artifactPath}`);
}
if (!disableVoteSubmission && rateBarEnabled === undefined && coloredThumbs === undefined) {
await page.addScriptTag({ path: GENERATED_USERSCRIPT });
await page.addScriptTag({ path: artifactPath });
return;
}
let source = fs.readFileSync(GENERATED_USERSCRIPT, "utf8");
let source = fs.readFileSync(artifactPath, "utf8");
if (disableVoteSubmission) {
source = overrideBooleanOption(source, "disableVoteSubmission", true);
}
+185 -61
View File
@@ -1,33 +1,56 @@
# Live YouTube smoke
This suite attaches over CDP to an already-running Brave profile. It is deliberately separate from `test:all` and CI.
This suite attaches over CDP to an already-running Chrome, Brave, or compatible Chromium 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.
1. Open the target browser's remote-debugging page (for example, `chrome://inspect/#remote-debugging` or
`brave://inspect/#remote-debugging`) and enable **Allow remote debugging for this browser instance**. If the page
displays only a server such as `127.0.0.1:60011`, set `RYD_CDP_ENDPOINT` to that exact host and port. Current Chromium
builds can return 404 from `/json/version` in this mode; the launcher reads the full browser WebSocket path from the
matching `DevToolsActivePort` file in the standard Chrome, Brave, Edge, or Chromium user-data root. Explicit
`ws://...` endpoints and legacy `http://127.0.0.1:9222` endpoints remain supported unchanged. Set the endpoint to the
browser alias `chrome`, `brave`, `edge`, or `chromium` to read that browser's standard active-port file directly.
For a custom or portable profile, set either `RYD_CDP_USER_DATA_DIR` to its user-data root or
`RYD_CDP_ACTIVE_PORT_FILE` to the exact file; do not set both overrides. The launcher waits 120 seconds for the
browser's **Allow remote debugging** confirmation. `RYD_CDP_CONNECT_TIMEOUT_MS` can override that wait from 15000 to
300000 milliseconds.
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`.
- `userscript`: disable the browser extension and every other Return YouTube Dislike userscript. A read-only run
requires `npm run build:live:userscript` followed by importing
`test-results/live-build/userscript/Return Youtube Dislike.user.js` into Tampermonkey. A full run performs that
build itself, stops before browser attachment, and requires `INSTALLED` after the freshly generated file has been
reimported.
- `extension`: disable every Return YouTube Dislike userscript and any store-installed copy of the extension. A
read-only run requires `npm run build:live:extension` followed by `npm run reload:live:extension`. A full run
performs the fresh build and reload itself. The already-enabled unpacked extension must point at
`Extensions/combined/dist/chrome`. Keep an authenticated ordinary
YouTube page open in the target profile. Before reloading anything, the runner scans every attached browser
context and page, ignores internal and non-YouTube tabs, and verifies the exact public
`RYD_LIVE_EXPECTED_CHANNEL` handle from YouTube's account menu. Exactly one context may match. It reuses that
existing page for the CDP session and the worker's own identity check; it never reads cookies or storage and fails
closed on an absent, unverifiable, or ambiguous profile.
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.
Hard-reload any already-open YouTube tab before ad hoc testing after an unpacked-extension reload. Existing documents
retain their previously injected content script; a rendered count or control in such a tab does not prove that the
newly built bundle is running. The automated suite avoids this ambiguity by creating a fresh tab and checking the
exact live-build marker before every scenario.
4. Keep the browser 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.
Before connecting to the browser it also verifies a build receipt against every current userscript or extension source
input and against the generated artifact bytes. Editing source after a build, changing generated output, or reusing a
receipt from another build fails closed. There is no environment override for the build ID or receipt.
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
@@ -41,74 +64,148 @@ 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_CDP_ENDPOINT="127.0.0.1:60011" # Copy the current value shown by the browser.
# Optional for a custom or portable profile (choose only one):
# $env:RYD_CDP_USER_DATA_DIR="C:\path\to\User Data"
# $env:RYD_CDP_ACTIVE_PORT_FILE="C:\path\to\User Data\DevToolsActivePort"
# $env:RYD_CDP_CONNECT_TIMEOUT_MS="180000"
$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"
# Optional: use a different allowlisted Short whose exact count visibly changes by one for reaction validation.
# Defaults to RYD_LIVE_SHORT.
# $env:RYD_LIVE_REACTION_SHORT="EEEEEEEEEEE"
$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"
$env:RYD_LIVE_NAV_CHANNEL_URL="https://www.youtube.com/@MrBeast"
$env:RYD_LIVE_NAV_SHORT="5mU6SRS2Bxo"
$env:RYD_LIVE_NAV_WATCH="Qtl8lJwbd4g" # Exact visible Watch link on the configured channel page.
# Optional; the Shorts stress targets 10 successful Next samples and accepts 10 through 25.
$env:RYD_LIVE_SHORTS_NEXT_HOPS="10"
# 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
npm run test:live:youtube:read-only
```
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.
The default channel fixture was last verified in authenticated Chrome on September 4, 2026. Its exact first Short was
visible on the channel shelf, and ten consecutive visible **Next video** controls reached ten distinct Shorts. Reverify
the exact channel cards before a later release run because YouTube channel ordering changes over time.
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.
The navigation smoke derives the channel's `/shorts` and `/videos` tabs from `RYD_LIVE_NAV_CHANNEL_URL`, performs a
real hard load and reload of the relevant tab, finds the configured exact visible target, and activates that link. If
YouTube ignores the first hit-tested pointer activation, the driver re-resolves the same exact target and makes one
hit-tested keyboard activation attempt. It fails rather than falling back to direct navigation when the relevant
channel tab 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, then keeps pressing YouTube's visible **Next video** control
until it has audited at least 10 successful, distinct samples. `RYD_LIVE_SHORTS_NEXT_HOPS` is the successful-sample
target, not the raw number of presses; the runner makes at most twice that many Next attempts. The initial transition
and every attempt baseline the driver's request IDs before navigation and reject any stale `/votes` request. A valid
sample must have at least one visible native YouTube action, an HTTP 200 response whose JSON `id` equals the exact
current 11-character video ID, and the authoritative dislike count rendered in one of the runtime's supported localized
number formats. Every valid Next sample must emit exactly one request. During the initial transition, the extension may
instead emit the source-supported sequence of one aggregate request followed by one request enriched with YouTube's
newly available numeric `likeCount`; the second response is authoritative. A repeated aggregate request, repeated
enrichment, reversed order, third request, or invalid `likeCount` still fails as duplicate traffic. Every attempt must
also keep the same document and produce a video ID that has not appeared earlier in the sequence. The current
synthetic dislike control must have no visible stale or duplicate controls and must preserve its count and ownership
through a bounded two-second stability soak. The suite also rechecks the runtime build marker and the complete Shorts
action-stack geometry after every hop. Page-owned requests must belong to the suite tab. Frame-less requests are
accepted only from the selected extension's `ryd.background.js` service worker, and the hop fails as ambiguous if
another browser tab has the destination video open. 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. Cropped evidence for the initial Short and every hop is written under
`test-results/live-youtube/shorts-navigation/` for both runtimes.
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.
YouTube itself sometimes leaves a selected reel with no visible native action controls. The runner samples the whole
visible Shorts rail across a bounded 20-second observation window. Each synchronous DOM sample also returns the exact
current reel's visible video box and viewport, which the runner uses to periodically pulse the real browser pointer so
an idle auto-hidden rail gets a fair chance to appear. It does not walk retained video locators or wait for animation
frames in this polling path. Every DOM probe and pointer operation has its own two-second watchdog; a stalled browser
operation invalidates and fails the stage instead of producing blank evidence. Native buttons are evaluated individually
instead of requiring their whole action-bar host to fit in the viewport. Before a sample is finally recorded as blank,
the runner captures its evidence, waits two animation frames, and remeasures once; a rail revealed at that boundary
follows the normal strict runtime-control assertions. Only a sample with zero visible native actions for that complete window is recorded
as `shorts-sample.skipped`, including its video ID, observation duration, reason, attempt number, and whether any
`/votes` request was seen. Any request seen during a blank sample is still required to target that exact current ID and
return a valid successful response; stale traffic is never forgiven. If even one meaningful native action is visible,
a missing runtime Dislike or missing `/votes` request remains a hard failure. The run passes only after reaching the
configured successful-sample target and only when valid samples strictly outnumber blank YouTube samples.
YouTube can occasionally focus the visible **Next video** button without acting on its first trusted click. On each
attempt, 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 indefinitely or clicks a
reaction control as part of this retry.
After each 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 and geometry render. It pauses only after all
configured successful samples pass, so newly selected Shorts 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,
skipped. Every hop applies the same new-request baseline and exact request/HTTP/body-ID oracle, compares the rendered
dislike count with that response,
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.
The direct Watch, reload, playlist SPA, and cold channel-to-Watch scenarios use that same exact network oracle.
The userscript must issue one request. The extension may issue either one request or the single ordered aggregate-to-
`likeCount` refinement described above, matching the intentional richer-refresh behavior in its shared count client.
After each response they require one current-video ratio bar and fill, reject a visible stale or duplicate bar, compare
the rendered localized count with the final authoritative API response, and repeatedly sample the current video and
unchanged result for a bounded stability interval.
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.
This production smoke intentionally exercises at least 10 valid Shorts **Next video** transitions; fully blank native
YouTube rails do not reduce that total. 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.
The default channel dataset can drift as its public page changes. Override the channel URL and both target IDs with a
channel whose `/shorts` and `/videos` tabs deliberately retain those exact cards. The channel URL is restricted to a
plain HTTPS `youtube.com/@handle` page or its `featured`, `shorts`, or `videos` tab; either way the driver selects the
kind-specific tab. Each 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:
The cold channel-to-Watch scenario is required. `RYD_LIVE_NAV_WATCH` must identify an exact visible Watch link on the
configured channel page; an absent or stale target fails the run and cannot count as completed coverage.
`npm run test:live:youtube:read-only` is the deliberately non-voting command. Its completion record is classified as
`read-only` with `releaseReady: false`; it cannot be reported as full validation. For a full run, use
`npm run test:live:youtube` (or its `:full` alias). It builds the selected runtime first, runs every read-only scenario,
then waits for the production-reaction approval described below. Entering `SKIP` fails the command as incomplete.
To keep one approved browser-debugging connection alive across complete retries, opt into the persistent interactive
session before starting the full runner:
```powershell
$env:RYD_LIVE_NAV_WATCH="DDDDDDDDDDD"
$env:RYD_LIVE_KEEP_CDP_SESSION="1"
npm run test:live:youtube
```
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
After each passed or failed attempt it archives the current live evidence under
`test-results/live-youtube/attempts/`, prints `READY_FOR_LIVE_RERUN_OR_EXIT`, and waits for exactly `RERUN` or `EXIT`.
`RERUN` rebuilds the selected runtime, reloads the unpacked extension when applicable, and executes the complete
read-only and reaction flow again through the same top-level browser connection instead of making a new attachment.
Each rerun still requires a fresh reaction approval token, and a userscript rerun still requires a fresh install
acknowledgement. The endpoint, authenticated handle, and runtime cannot change while reusing the connection. `EXIT`
closes the connection and returns the latest attempt's status. Leaving
`RYD_LIVE_KEEP_CDP_SESSION` unset preserves the normal single-attempt behavior.
The visual pass checks the watch-page ratio bar at widths 1280, 768, and 390. For both runtimes it also checks the
modern Shorts synthetic dislike control, its geometry, its rendered count, and its position beside the active reel's
native Like control at those 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.
@@ -117,10 +214,23 @@ The interactive runner prints `LIVE_STAGE_START`, `LIVE_STAGE_COMPLETE`, and `LI
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.
pending operation. Every read-only stage also reports when it starts and completes removal of its production-interaction
deny route. That cleanup and the final test-tab close each have a ten-second watchdog: a stalled Chrome target fails the
attempt and returns control to the persistent runner instead of waiting indefinitely. A cleanup watchdog failure never
permits the runner to continue into production reactions.
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
Both live runners treat a page error, unhandled promise rejection, unexpected `console.error`, or error from the
selected extension's service worker as a test failure even when the DOM and network assertions otherwise pass. A
benign message can be ignored only by adding an explicit rule that matches its exact signal type, full message, and
full source URL; there are no wildcard or callback suppressions. The Playwright runner uses a fresh page, driver, and
diagnostic guard for each scenario and one worker, so one failed read-only scenario does not suppress the remaining
independent scenarios. Its production-reaction test is gated on every required read-only scenario completing in the
same worker; a failure-driven worker restart therefore cannot accidentally carry a green gate forward. The interactive
runner also completes all independent read-only stages, then returns an aggregate failure and never reaches reaction
approval if any failed.
If a scenario fails, the runner records those browser signals 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
@@ -130,13 +240,21 @@ Use `RYD_LIVE_RUNTIME="extension"` after manually switching the enabled runtime
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
## Full production reaction validation
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
The full command cannot succeed without this phase. Its first check opens `RYD_LIVE_WATCH_A`, clicks the exact
`RYD_LIVE_WATCH_B` playlist link, proves that YouTube kept the same document, and only then clicks Dislike on B. It
records before navigation and fails if navigation itself emits any `/interact/*` request. The reaction and its cleanup
must each send one logical handshake for B's exact video ID, and the cleanup must restore B's initial YouTube reaction
state. This is the production stale-video-ID gate shared by the userscript and extension.
The same approved reaction phase then covers all six Like/Dislike state transitions on `RYD_LIVE_WATCH_B` and the
allowlisted `RYD_LIVE_REACTION_SHORT` (defaulting to `RYD_LIVE_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.
matching successful `/interact/confirmVote`. Every request must receive a 2xx response; confirmation must return the
literal JSON value `true`. 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
@@ -155,14 +273,20 @@ The exact live-build marker proves that the freshly generated test build is pres
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
Only after the full runner prints `READY_FOR_REACTION_APPROVAL`, create a runtime-and-video-specific token in a second
PowerShell window and paste it into the waiting runner. It expires after two minutes and is consumed once locally at the
first post-navigation reaction. That
single approval gates the contiguous post-navigation check and reaction matrix, 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
$runtime="extension" # Or "userscript", matching the waiting run.
$watchB="BBBBBBBBBBB"
"$runtime`:$watchB`:$([DateTimeOffset]::UtcNow.ToUnixTimeSeconds())" | Set-Clipboard
```
After the live run, turn off **Allow remote debugging for this browser instance** or restart the test Brave profile.
Paste the copied value into the waiting full-run terminal. The command exits successfully only after both
`post-navigation-vote` and `reaction-matrix` complete, then emits `LIVE_VALIDATION_COMPLETE` with
`productionReactionsCompleted: true` and `releaseReady: true`.
After the live run, turn off **Allow remote debugging for this browser instance** or restart the test browser profile.
@@ -0,0 +1,43 @@
const path = require("node:path");
const {
verifyBuildReceipt,
verifyMirroredJavaScript,
verifyProductionJavaScript,
verifyStandaloneMv3Artifact,
} = require("../../../e2e/verify-extension-artifact");
const { verifyGeneratedUserscriptArtifact } = require("../../../e2e/verify-userscript-artifact");
const REPOSITORY_ROOT = path.resolve(__dirname, "../../../..");
const EXTENSION_DIST_ROOT = path.join(REPOSITORY_ROOT, "Extensions", "combined", "dist");
const EXTENSION_CHROME_ARTIFACT = path.join(EXTENSION_DIST_ROOT, "chrome");
function assertCurrentLiveArtifact(
runtime,
expectedBuildId,
{
verifyExtensionBuildReceipt = () => verifyBuildReceipt(EXTENSION_DIST_ROOT, REPOSITORY_ROOT),
verifyExtensionJavaScript = () => {
verifyMirroredJavaScript(EXTENSION_DIST_ROOT);
verifyProductionJavaScript(EXTENSION_DIST_ROOT);
return verifyStandaloneMv3Artifact(EXTENSION_CHROME_ARTIFACT);
},
verifyUserscript = verifyGeneratedUserscriptArtifact,
} = {},
) {
if (runtime === "extension") {
const receipt = verifyExtensionBuildReceipt();
const artifact = verifyExtensionJavaScript();
return { artifact, buildId: expectedBuildId, receipt, runtime };
}
if (runtime === "userscript") {
const receipt = verifyUserscript({ expectedBuildId, liveTestBuild: true });
return { buildId: expectedBuildId, receipt, runtime };
}
throw new TypeError(`Unsupported live artifact runtime: ${runtime}`);
}
module.exports = {
EXTENSION_CHROME_ARTIFACT,
EXTENSION_DIST_ROOT,
assertCurrentLiveArtifact,
};
@@ -0,0 +1,357 @@
const DEFAULT_ACCOUNT_PROBE_TIMEOUT_MS = 8_000;
const DEFAULT_ACCOUNT_SELECTION_ATTEMPTS = 2;
const DEFAULT_ACCOUNT_SELECTION_RETRY_DELAY_MS = 250;
const YOUTUBE_HOSTNAMES = new Set(["youtube.com", "www.youtube.com", "m.youtube.com"]);
function normalizeChannelHandle(value) {
if (typeof value !== "string" || !/^@[A-Za-z0-9._-]{3,100}$/.test(value.trim())) {
throw new Error("An exact public YouTube @handle is required to select the live browser profile.");
}
return value.trim().toLowerCase();
}
function isCommittedYoutubePage(page) {
if (!page || typeof page.url !== "function" || page.isClosed?.()) return false;
try {
const url = new URL(page.url());
return (
url.protocol === "https:" &&
YOUTUBE_HOSTNAMES.has(url.hostname.toLowerCase()) &&
!url.port &&
!url.username &&
!url.password
);
} catch {
return false;
}
}
function inspectYoutubeSessionDocument() {
const allowedHostnames = ["youtube.com", "www.youtube.com", "m.youtube.com"];
const configuredLoggedIn = (() => {
try {
const value = globalThis.ytcfg?.get?.("LOGGED_IN") ?? globalThis.ytcfg?.data_?.LOGGED_IN;
return typeof value === "boolean" ? value : null;
} catch {
return null;
}
})();
return {
committed:
Boolean(document.documentElement) &&
location.protocol === "https:" &&
allowedHostnames.includes(location.hostname.toLowerCase()) &&
!location.port &&
!location.username &&
!location.password,
configuredLoggedIn,
};
}
function inspectVisibleAccountMenu(expectedHandle) {
const allowedHostnames = ["youtube.com", "www.youtube.com", "m.youtube.com"];
const normalizedExpectedHandle = expectedHandle.toLowerCase();
const isVisible = (element) => {
const rectangle = element.getBoundingClientRect();
const style = getComputedStyle(element);
return rectangle.width > 0 && rectangle.height > 0 && style.display !== "none" && style.visibility !== "hidden";
};
const menuSelectors = [
"ytd-multi-page-menu-renderer",
"ytm-multi-page-menu-renderer",
"tp-yt-iron-dropdown ytd-active-account-header-renderer",
];
const menus = [...document.querySelectorAll(menuSelectors.join(","))].filter(isVisible);
let foundHandle = false;
let matchedExpectedHandle = false;
for (const menu of menus) {
for (const link of menu.querySelectorAll("a[href]")) {
if (!isVisible(link)) continue;
try {
const url = new URL(link.getAttribute("href"), location.origin);
if (url.protocol !== "https:" || !allowedHostnames.includes(url.hostname.toLowerCase())) continue;
const path = decodeURIComponent(url.pathname).replace(/\/$/, "").toLowerCase();
if (!/^\/@[a-z0-9._-]{3,100}$/.test(path)) continue;
foundHandle = true;
if (path === `/${normalizedExpectedHandle}`) matchedExpectedHandle = true;
} catch {}
}
}
if (!matchedExpectedHandle) {
for (const menu of menus) {
for (const header of menu.querySelectorAll(
"ytd-active-account-header-renderer, ytm-active-account-header-renderer",
)) {
if (!isVisible(header)) continue;
const identityElements = [header, ...header.querySelectorAll("#channel-handle, yt-formatted-string, span")];
const tokens = identityElements
.flatMap((element) => (element.textContent ?? "").split(/\s+/))
.map((token) => token.trim().replace(/\/$/, "").toLowerCase())
.filter((token) => /^@[a-z0-9._-]{3,100}$/.test(token));
if (tokens.length > 0) foundHandle = true;
if (tokens.includes(normalizedExpectedHandle)) matchedExpectedHandle = true;
}
}
}
if (matchedExpectedHandle || foundHandle) {
return { matchedExpectedHandle, ready: true };
}
return null;
}
async function firstVisibleAvatar(page) {
const avatars = page.locator("#avatar-btn");
const count = await avatars.count();
for (let index = 0; index < count; index += 1) {
const avatar = avatars.nth(index);
if (await avatar.isVisible()) return avatar;
}
return null;
}
async function readVisibleAccountMenu(page, expectedHandle) {
return page.evaluate(inspectVisibleAccountMenu, expectedHandle);
}
async function waitForVisibleAccountMenu(page, expectedHandle, timeoutMilliseconds) {
const handle = await page.waitForFunction(inspectVisibleAccountMenu, expectedHandle, {
polling: 100,
timeout: timeoutMilliseconds,
});
try {
return await handle.jsonValue();
} finally {
// The result has already been serialized. A concurrent navigation can make
// disposal fail, but must not erase a conclusive account-menu result.
await handle.dispose?.().catch(() => {});
}
}
async function verifyAuthenticatedYoutubePage(
page,
expectedHandle,
{ accountProbeTimeoutMilliseconds = DEFAULT_ACCOUNT_PROBE_TIMEOUT_MS } = {},
) {
const normalizedExpectedHandle = normalizeChannelHandle(expectedHandle);
if (!isCommittedYoutubePage(page)) return false;
// Attached background tabs can be heavily throttled. Bringing only the page
// currently being verified forward makes the account-menu hydration behave
// like an ordinary user interaction without navigating or changing profile
// state.
await page.bringToFront?.();
if (!isCommittedYoutubePage(page)) return false;
const documentState = await page.evaluate(inspectYoutubeSessionDocument);
if (documentState?.committed !== true || documentState.configuredLoggedIn === false) return false;
const existingMenu = await readVisibleAccountMenu(page, normalizedExpectedHandle);
if (existingMenu?.ready) return existingMenu.matchedExpectedHandle === true;
const avatar = await firstVisibleAvatar(page);
if (!avatar) return false;
let openedMenu = false;
try {
await avatar.click({ timeout: accountProbeTimeoutMilliseconds });
openedMenu = true;
const menu = await waitForVisibleAccountMenu(page, normalizedExpectedHandle, accountProbeTimeoutMilliseconds);
return menu?.matchedExpectedHandle === true;
} finally {
if (openedMenu) await page.keyboard.press("Escape").catch(() => {});
}
}
function classifyAccountProbeFailure(error) {
if (error?.name === "TimeoutError") return "timeout";
if (/target (?:page|context|browser).*closed|page has been closed/i.test(error?.message ?? "")) {
return "page-closed";
}
if (
/execution context was destroyed|cannot find context|most likely because of a navigation/i.test(
error?.message ?? "",
)
) {
return "navigation-race";
}
return "probe-error";
}
function wait(milliseconds) {
return new Promise((resolve) => setTimeout(resolve, milliseconds));
}
async function verifyCandidateYoutubePage(
page,
expectedHandle,
verifyPage,
{ accountProbeTimeoutMilliseconds = DEFAULT_ACCOUNT_PROBE_TIMEOUT_MS } = {},
) {
try {
return {
matched: Boolean(
await verifyPage(page, expectedHandle, {
accountProbeTimeoutMilliseconds,
}),
),
resolved: true,
};
} catch (error) {
// A page which closed or left YouTube while it was being inspected no
// longer represents a candidate profile. Do not let that stale tab poison
// the result, but keep every still-committed candidate fail-closed.
if (!isCommittedYoutubePage(page)) return { matched: false, resolved: true };
return {
failureKind: classifyAccountProbeFailure(error),
matched: false,
resolved: false,
};
}
}
async function scanAuthenticatedYoutubeContexts(browser, normalizedExpectedHandle, options) {
const contexts = browser.contexts();
if (!Array.isArray(contexts) || contexts.length === 0) {
throw new Error("The attached Chromium browser has no browser contexts.");
}
const matches = [];
const unresolvedContextIndexes = new Set();
const unresolvedCandidates = [];
let youtubePageCount = 0;
for (let contextIndex = 0; contextIndex < contexts.length; contextIndex += 1) {
const context = contexts[contextIndex];
const pages = typeof context?.pages === "function" ? context.pages() : null;
if (!Array.isArray(pages)) {
throw new Error(`Attached Chromium context ${contextIndex + 1} returned a malformed page inventory.`);
}
const contextMatches = [];
for (let pageIndex = 0; pageIndex < pages.length; pageIndex += 1) {
const sessionPage = pages[pageIndex];
if (!isCommittedYoutubePage(sessionPage)) continue;
youtubePageCount += 1;
const verification = await verifyCandidateYoutubePage(sessionPage, normalizedExpectedHandle, options.verifyPage, {
accountProbeTimeoutMilliseconds: options.accountProbeTimeoutMilliseconds,
});
if (verification.matched) {
contextMatches.push({ context, contextIndex, pageIndex, sessionPage });
// Authentication belongs to the BrowserContext/profile, not to an
// individual tab. One exact match resolves this profile; probing more
// tabs would only add UI churn and transient failure opportunities.
break;
}
if (!verification.resolved) {
unresolvedContextIndexes.add(contextIndex);
unresolvedCandidates.push({ contextIndex, failureKind: verification.failureKind, pageIndex });
}
}
if (contextMatches.length > 0) {
unresolvedContextIndexes.delete(contextIndex);
matches.push(contextMatches[0]);
}
}
return { matches, unresolvedCandidates, unresolvedContextIndexes, youtubePageCount };
}
function isSelectedSessionPageCurrent(selection) {
if (!isCommittedYoutubePage(selection.sessionPage)) return false;
try {
const pages = selection.context.pages();
return Array.isArray(pages) && pages.includes(selection.sessionPage);
} catch {
return false;
}
}
async function selectAuthenticatedYoutubeContext(
browser,
expectedHandle,
{
accountSelectionAttempts = DEFAULT_ACCOUNT_SELECTION_ATTEMPTS,
accountSelectionRetryDelayMilliseconds = DEFAULT_ACCOUNT_SELECTION_RETRY_DELAY_MS,
accountProbeTimeoutMilliseconds = DEFAULT_ACCOUNT_PROBE_TIMEOUT_MS,
verifyPage = verifyAuthenticatedYoutubePage,
waitForRetry = wait,
} = {},
) {
const normalizedExpectedHandle = normalizeChannelHandle(expectedHandle);
if (!browser || typeof browser.contexts !== "function") {
throw new TypeError("An attached Chromium browser is required to select the authenticated profile.");
}
if (!Number.isSafeInteger(accountSelectionAttempts) || accountSelectionAttempts < 1) {
throw new TypeError("The authenticated Chromium selection attempt count must be a positive integer.");
}
let scan;
for (let attempt = 1; attempt <= accountSelectionAttempts; attempt += 1) {
scan = await scanAuthenticatedYoutubeContexts(browser, normalizedExpectedHandle, {
accountProbeTimeoutMilliseconds,
verifyPage,
});
if (scan.matches.length > 1) {
throw new Error(
`More than one attached Chromium context has a signed-in YouTube page for ${normalizedExpectedHandle}; refusing to choose a profile.`,
);
}
if (scan.matches.length === 1 && scan.unresolvedContextIndexes.size === 0) {
const selection = scan.matches[0];
if (isSelectedSessionPageCurrent(selection)) return selection;
scan.unresolvedContextIndexes.add(selection.contextIndex);
scan.unresolvedCandidates.push({
contextIndex: selection.contextIndex,
failureKind: "selected-page-stale",
pageIndex: selection.pageIndex,
});
}
if (attempt < accountSelectionAttempts) await waitForRetry(accountSelectionRetryDelayMilliseconds);
}
if (scan.unresolvedContextIndexes.size > 0) {
const diagnostic = scan.unresolvedCandidates
.filter(({ contextIndex }) => scan.unresolvedContextIndexes.has(contextIndex))
.map(
({ contextIndex, failureKind, pageIndex }) =>
`context ${contextIndex + 1} page ${pageIndex + 1} (${failureKind})`,
)
.join(", ");
throw new Error(
`Could not safely verify every candidate Chromium profile for ${normalizedExpectedHandle}; no extension was changed. Unresolved probes: ${diagnostic}.`,
);
}
if (scan.matches.length === 0) {
if (scan.youtubePageCount === 0) {
throw new Error(
`No pre-existing committed HTTPS YouTube page was found for ${normalizedExpectedHandle}; no extension was changed.`,
);
}
throw new Error(
`No attached Chromium context has a pre-existing signed-in YouTube page for ${normalizedExpectedHandle}; no extension was changed.`,
);
}
throw new Error(`The authenticated Chromium profile for ${normalizedExpectedHandle} became unavailable.`);
}
module.exports = {
DEFAULT_ACCOUNT_SELECTION_ATTEMPTS,
DEFAULT_ACCOUNT_SELECTION_RETRY_DELAY_MS,
DEFAULT_ACCOUNT_PROBE_TIMEOUT_MS,
classifyAccountProbeFailure,
inspectVisibleAccountMenu,
inspectYoutubeSessionDocument,
isCommittedYoutubePage,
normalizeChannelHandle,
scanAuthenticatedYoutubeContexts,
selectAuthenticatedYoutubeContext,
verifyCandidateYoutubePage,
verifyAuthenticatedYoutubePage,
};
@@ -5,15 +5,168 @@ const API_ORIGIN = "https://returnyoutubedislikeapi.com";
const UNHANDLED_REJECTION_PREFIX = "__RYD_LIVE_UNHANDLED_REJECTION__";
const MAX_BROWSER_SIGNALS = 50;
const MAX_API_REQUESTS = 50;
const EXTENSION_ID_PATTERN = /^[a-p]{32}$/;
const EXTERNAL_RESOURCE_HOSTS = Object.freeze({
doubleclick: "doubleclick.net",
googlevideo: "googlevideo.com",
});
const EXTERNAL_RESOURCE_FAILURE_PATTERN =
/^Failed to load resource: (?:the server responded with a status of 403(?: \([^)]*\))?|net::ERR_(?:BLOCKED_BY_CLIENT|CONNECTION_CLOSED|CONNECTION_REFUSED|CONNECTION_RESET|FAILED|NAME_NOT_RESOLVED|TIMED_OUT))\.?$/;
const DOUBLECLICK_CORS_PATTERN =
/^Access to (?:fetch|XMLHttpRequest) at ['"]([^'"]+)['"](?: \(redirected from ['"]([^'"]+)['"]\))? from origin ['"](https:\/\/(?:www\.|m\.)?youtube\.com)['"] has been blocked by CORS policy: .+$/s;
const SANDBOXED_ABOUT_BLANK_SCRIPT_MESSAGE =
"Blocked script execution in 'about:blank' because the document's frame is sandboxed and the 'allow-scripts' permission is not set.";
const YOUTUBE_ACCOUNTS_REPORT_ONLY_FRAME_ANCESTORS_MESSAGE =
"Framing 'https://accounts.youtube.com/' violates the following report-only Content Security Policy directive: \"frame-ancestors 'self'\". The violation has been logged, but no further action has been taken.\n";
const YOUTUBE_ROTATE_COOKIES_RATE_LIMIT_PATTERN =
/^Failed to load resource: the server responded with a status of 429(?: \([^)]*\))?\.?$/;
const FATAL_SIGNAL_TYPES = new Set([
"console.error",
"pageerror",
"unhandledrejection",
"extension-service-worker.console.error",
"extension-service-worker.instrumentation-error",
"extension-service-worker.unhandledrejection",
]);
function serializeError(error) {
if (!error) return null;
if (typeof error === "string") return { message: error, name: "Error", stack: null };
return {
const serialized = {
message: String(error.message ?? error),
name: String(error.name ?? "Error"),
stack: typeof error.stack === "string" ? error.stack : null,
};
if (error instanceof AggregateError) serialized.errors = [...error.errors].map(serializeError);
return serialized;
}
function normalizeIgnoredSignalRules(rules = []) {
if (!Array.isArray(rules)) throw new TypeError("ignoredSignalRules must be an array.");
const ids = new Set();
return rules.map((rule) => {
if (!rule || typeof rule !== "object" || Array.isArray(rule)) {
throw new TypeError("Each ignored-signal rule must be an object.");
}
const allowedKeys = new Set(["id", "message", "type", "url"]);
const unknownKeys = Object.keys(rule).filter((key) => !allowedKeys.has(key));
if (unknownKeys.length > 0) {
throw new TypeError(`Ignored-signal rule contains unsupported fields: ${unknownKeys.join(", ")}.`);
}
if (typeof rule.id !== "string" || rule.id.trim() === "") {
throw new TypeError("Each ignored-signal rule requires a non-empty id.");
}
if (ids.has(rule.id)) throw new TypeError(`Ignored-signal rule id ${rule.id} is duplicated.`);
ids.add(rule.id);
if (!FATAL_SIGNAL_TYPES.has(rule.type)) {
throw new TypeError(`Ignored-signal rule ${rule.id} has an unsupported exact type.`);
}
if (typeof rule.message !== "string" || rule.message.length === 0) {
throw new TypeError(`Ignored-signal rule ${rule.id} requires a non-empty exact message.`);
}
if (typeof rule.url !== "string" || rule.url.length === 0) {
throw new TypeError(`Ignored-signal rule ${rule.id} requires a non-empty exact url.`);
}
return Object.freeze({ ...rule });
});
}
function signalMessage(signal) {
return signal.message ?? signal.error?.message ?? "Unknown browser error";
}
function signalUrl(signal) {
return signal.workerUrl ?? signal.location?.url ?? null;
}
function ignoredSignalRuleId(signal, rules) {
const message = signalMessage(signal);
const url = signalUrl(signal);
const rule = rules.find((candidate) => {
if (candidate.type !== signal.type) return false;
return candidate.message === message && candidate.url === url;
});
return rule?.id ?? null;
}
function hostnameCategory(value) {
let hostname;
try {
const url = new URL(value);
if (url.protocol !== "https:" || url.port || url.username || url.password) return null;
hostname = url.hostname.toLowerCase();
} catch {
return null;
}
for (const [category, domain] of Object.entries(EXTERNAL_RESOURCE_HOSTS)) {
if (hostname === domain || hostname.endsWith(`.${domain}`)) return category;
}
return null;
}
function environmentalConsoleWarningId(signal) {
if (signal?.type !== "console.error" || signal.source !== "page") return null;
const message = signalMessage(signal);
const locationUrl = signal.location?.url;
const locationCategory = hostnameCategory(locationUrl);
if (message === SANDBOXED_ABOUT_BLANK_SCRIPT_MESSAGE && locationUrl === "about:blank") {
return "sandboxed-about-blank-script";
}
if (message === YOUTUBE_ACCOUNTS_REPORT_ONLY_FRAME_ANCESTORS_MESSAGE && locationUrl === "") {
return "youtube-accounts-report-only-frame-ancestors";
}
if (EXTERNAL_RESOURCE_FAILURE_PATTERN.test(message)) {
if (locationCategory === "googlevideo") return "googlevideo-resource-failure";
if (locationCategory === "doubleclick") return "doubleclick-resource-failure";
return null;
}
if (YOUTUBE_ROTATE_COOKIES_RATE_LIMIT_PATTERN.test(message)) {
try {
const url = new URL(locationUrl);
if (
url.protocol === "https:" &&
url.hostname.toLowerCase() === "accounts.youtube.com" &&
url.pathname === "/RotateCookies" &&
!url.port &&
!url.username &&
!url.password
) {
return "youtube-rotate-cookies-rate-limit";
}
} catch {}
}
const corsMatch = message.match(DOUBLECLICK_CORS_PATTERN);
if (!corsMatch || hostnameCategory(corsMatch[1]) !== "doubleclick") return null;
const [, , redirectedFrom, origin] = corsMatch;
if (redirectedFrom) {
try {
const redirectUrl = new URL(redirectedFrom);
if (
redirectUrl.origin !== origin ||
!redirectUrl.pathname.startsWith("/pagead/viewthroughconversion/") ||
redirectUrl.username ||
redirectUrl.password
) {
return null;
}
} catch {
return null;
}
}
return "doubleclick-cors";
}
function extensionServiceWorkerUrl(extensionId) {
if (!EXTENSION_ID_PATTERN.test(extensionId ?? "")) {
throw new TypeError("selectedExtensionId must be a valid Chrome extension ID.");
}
return `chrome-extension://${extensionId}/ryd.background.js`;
}
function installUnhandledRejectionListener(prefix) {
@@ -34,6 +187,211 @@ function installUnhandledRejectionListener(prefix) {
});
}
class LiveFatalSignalGuard {
constructor(
page,
context,
{
clock = () => new Date(),
ignoredSignalRules = [],
log = console.log,
onSignal = null,
selectedExtensionId = null,
} = {},
) {
this.clock = clock;
this.context = context;
this.ignoredSignalRules = normalizeIgnoredSignalRules(ignoredSignalRules);
this.log = log;
this.onSignal = onSignal;
this.page = page;
this.selectedExtensionWorkerUrl = selectedExtensionId ? extensionServiceWorkerUrl(selectedExtensionId) : null;
this.serviceWorkerListeners = new Map();
this.signals = [];
this.started = false;
this.onPageConsole = this.onPageConsole.bind(this);
this.onPageError = this.onPageError.bind(this);
this.onServiceWorker = this.onServiceWorker.bind(this);
}
now() {
return this.clock().toISOString();
}
recordSignal(type, details) {
const baseSignal = { at: this.now(), type, ...details };
const ignoredBy = ignoredSignalRuleId(baseSignal, this.ignoredSignalRules);
const environmentalWarning = environmentalConsoleWarningId(baseSignal);
const severity = ignoredBy || environmentalWarning ? "warning" : "fatal";
const signal = { ...baseSignal, environmentalWarning, ignoredBy, severity };
this.signals.push(signal);
this.log(
`LIVE_BROWSER_SIGNAL ${type} ${JSON.stringify({ ...details, environmentalWarning, ignoredBy, severity })}`,
);
this.onSignal?.(signal);
return signal;
}
onPageError(error) {
this.recordSignal("pageerror", { error: serializeError(error), source: "page" });
}
readConsoleMessage(message) {
let location = {};
try {
location = message.location?.() ?? {};
} catch {}
return { location, message: message.text() };
}
onPageConsole(consoleMessage) {
if (consoleMessage.type() !== "error") return;
const { location, message } = this.readConsoleMessage(consoleMessage);
if (message.startsWith(UNHANDLED_REJECTION_PREFIX)) {
this.recordSignal("unhandledrejection", {
location,
message: message.slice(UNHANDLED_REJECTION_PREFIX.length),
source: "page",
});
return;
}
this.recordSignal("console.error", { location, message, source: "page" });
}
async attachServiceWorker(worker) {
if (!this.selectedExtensionWorkerUrl || worker.url() !== this.selectedExtensionWorkerUrl) return;
if (this.serviceWorkerListeners.has(worker)) return;
const listener = (consoleMessage) => {
if (consoleMessage.type() !== "error") return;
const { location, message } = this.readConsoleMessage(consoleMessage);
if (message.startsWith(UNHANDLED_REJECTION_PREFIX)) {
this.recordSignal("extension-service-worker.unhandledrejection", {
location,
message: message.slice(UNHANDLED_REJECTION_PREFIX.length),
source: "extension-service-worker",
workerUrl: this.selectedExtensionWorkerUrl,
});
return;
}
this.recordSignal("extension-service-worker.console.error", {
location,
message,
source: "extension-service-worker",
workerUrl: this.selectedExtensionWorkerUrl,
});
};
this.serviceWorkerListeners.set(worker, listener);
worker.on("console", listener);
try {
await worker.evaluate(installUnhandledRejectionListener, UNHANDLED_REJECTION_PREFIX);
} catch (error) {
this.recordSignal("extension-service-worker.instrumentation-error", {
error: serializeError(error),
source: "extension-service-worker",
workerUrl: this.selectedExtensionWorkerUrl,
});
}
}
onServiceWorker(worker) {
void this.attachServiceWorker(worker);
}
async start() {
if (this.started) return;
this.started = true;
this.page.on("console", this.onPageConsole);
this.page.on("pageerror", this.onPageError);
await this.page.addInitScript(installUnhandledRejectionListener, UNHANDLED_REJECTION_PREFIX);
if (!this.page.isClosed()) await this.page.evaluate(installUnhandledRejectionListener, UNHANDLED_REJECTION_PREFIX);
if (this.selectedExtensionWorkerUrl) {
this.context.on("serviceworker", this.onServiceWorker);
await Promise.all(this.context.serviceWorkers().map((worker) => this.attachServiceWorker(worker)));
}
}
stop() {
if (!this.started) return;
this.started = false;
this.page.off("console", this.onPageConsole);
this.page.off("pageerror", this.onPageError);
if (this.selectedExtensionWorkerUrl) this.context.off("serviceworker", this.onServiceWorker);
for (const [worker, listener] of this.serviceWorkerListeners) worker.off("console", listener);
this.serviceWorkerListeners.clear();
}
mark() {
return this.signals.length;
}
fatalSignalsBetween(startIndex = 0, endIndex = this.signals.length) {
return this.signals.slice(startIndex, endIndex).filter((signal) => signal.severity !== "warning");
}
assertNoFatalSignalsBetween(startIndex = 0, endIndex = this.signals.length, label = "live scenario") {
const signals = this.fatalSignalsBetween(startIndex, endIndex);
if (signals.length === 0) return;
const summary = signals.map((signal) => `${signal.type}: ${signalMessage(signal)}`).join(" | ");
const error = new Error(`${label} emitted ${signals.length} fatal browser signal(s): ${summary}`);
error.name = "LiveFatalSignalError";
error.signals = signals;
throw error;
}
}
class LiveReadOnlyGate {
constructor(requiredScenarioIds, { allowedSkippedScenarioIds = [] } = {}) {
if (!Array.isArray(requiredScenarioIds) || requiredScenarioIds.length === 0) {
throw new TypeError("The live read-only gate requires at least one scenario ID.");
}
if (requiredScenarioIds.some((scenarioId) => typeof scenarioId !== "string" || scenarioId.trim() === "")) {
throw new TypeError("Every live read-only gate scenario ID must be a non-empty string.");
}
if (new Set(requiredScenarioIds).size !== requiredScenarioIds.length) {
throw new TypeError("Live read-only gate scenario IDs must be unique.");
}
if (
!Array.isArray(allowedSkippedScenarioIds) ||
allowedSkippedScenarioIds.some((scenarioId) => !requiredScenarioIds.includes(scenarioId))
) {
throw new TypeError("Allowed skipped scenarios must be a subset of the required live read-only scenarios.");
}
this.allowedSkipped = new Set(allowedSkippedScenarioIds);
this.completed = new Set();
this.failed = new Set();
this.requiredScenarioIds = Object.freeze([...requiredScenarioIds]);
}
record(scenarioId, outcome) {
if (!this.requiredScenarioIds.includes(scenarioId)) {
throw new TypeError(`Unknown live read-only scenario: ${scenarioId}`);
}
if (!["failed", "passed", "skipped"].includes(outcome)) {
throw new TypeError(`Unsupported live read-only outcome for ${scenarioId}: ${outcome}`);
}
if (outcome === "failed" || (outcome === "skipped" && !this.allowedSkipped.has(scenarioId))) {
this.completed.delete(scenarioId);
this.failed.add(scenarioId);
return;
}
if (this.failed.has(scenarioId)) return;
this.completed.add(scenarioId);
}
assertPassed() {
const missing = this.requiredScenarioIds.filter((scenarioId) => !this.completed.has(scenarioId));
if (this.failed.size === 0 && missing.length === 0) return;
throw new Error(
`Production reactions are blocked because this worker did not pass every read-only scenario. Missing: ${
missing.join(", ") || "none"
}; failed: ${[...this.failed].join(", ") || "none"}.`,
);
}
}
function readLivePageState() {
const readRect = (element) => {
const rect = element.getBoundingClientRect();
@@ -149,9 +507,11 @@ class LiveRunDiagnostics {
{
clock = () => new Date(),
fileSystem = fs,
ignoredSignalRules = [],
log = console.log,
outputDirectory = path.resolve(__dirname, "../../../../test-results/live-youtube/diagnostics"),
runtime = null,
selectedExtensionId = null,
} = {},
) {
this.browserSignals = [];
@@ -166,13 +526,19 @@ class LiveRunDiagnostics {
this.recentApiRequests = [];
this.requestRecords = new WeakMap();
this.runtime = runtime;
this.fatalSignalCursor = 0;
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);
this.fatalSignalGuard = new LiveFatalSignalGuard(page, context, {
clock,
ignoredSignalRules,
log: () => {},
onSignal: (signal) => this.recordBrowserSignal(signal),
selectedExtensionId,
});
}
now() {
@@ -184,34 +550,16 @@ class LiveRunDiagnostics {
if (collection.length > maximum) collection.splice(0, collection.length - maximum);
}
recordBrowserSignal(type, details) {
const signal = {
at: this.now(),
recordBrowserSignal(signal) {
const { at, ...details } = signal;
const enrichedSignal = {
at,
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 });
this.appendCapped(this.browserSignals, enrichedSignal, MAX_BROWSER_SIGNALS);
this.log(`LIVE_BROWSER_SIGNAL ${enrichedSignal.type} ${JSON.stringify(details)}`);
}
onRequest(request) {
@@ -246,22 +594,16 @@ class LiveRunDiagnostics {
async start() {
if (this.started) return;
this.started = true;
this.page.on("console", this.onConsole);
this.page.on("pageerror", this.onPageError);
await this.fatalSignalGuard.start();
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.fatalSignalGuard.stop();
this.context.off("request", this.onRequest);
this.context.off("requestfailed", this.onRequestFailed);
this.context.off("response", this.onResponse);
@@ -291,6 +633,13 @@ class LiveRunDiagnostics {
);
}
consumeFatalSignals(label) {
const startIndex = this.fatalSignalCursor;
const endIndex = this.fatalSignalGuard.mark();
this.fatalSignalCursor = endIndex;
this.fatalSignalGuard.assertNoFatalSignalsBetween(startIndex, endIndex, label);
}
async snapshot(error) {
let pageState = null;
let pageStateError = null;
@@ -336,23 +685,71 @@ class LiveRunDiagnostics {
async function runLoggedStage(diagnostics, name, action) {
const startedAt = Date.now();
diagnostics.stageStarted(name);
let result;
let failure = null;
try {
const result = await action();
diagnostics.stageCompleted(name, startedAt);
return result;
result = await action();
} catch (error) {
diagnostics.stageFailed(name, startedAt, error);
failure = error;
}
await Promise.resolve();
try {
diagnostics.consumeFatalSignals?.(name);
} catch (signalError) {
failure = failure
? new AggregateError([failure, signalError], `${name} failed and emitted fatal browser signals.`)
: signalError;
}
if (failure) {
diagnostics.stageFailed(name, startedAt, failure);
throw failure;
}
diagnostics.stageCompleted(name, startedAt);
return result;
}
async function runIndependentLoggedStages(diagnostics, stages) {
if (!Array.isArray(stages)) throw new TypeError("Independent live stages must be an array.");
const results = [];
const failures = [];
for (const stage of stages) {
if (!stage || typeof stage.name !== "string" || typeof stage.action !== "function") {
throw new TypeError("Each independent live stage requires a name and action function.");
}
try {
results.push({ name: stage.name, result: await runLoggedStage(diagnostics, stage.name, stage.action) });
} catch (error) {
failures.push(error);
results.push({ error: serializeError(error), name: stage.name });
}
}
if (failures.length > 0) {
const error = new AggregateError(
failures,
`${failures.length} of ${stages.length} independent live read-only stages failed.`,
);
error.stageResults = results;
throw error;
}
return results;
}
module.exports = {
API_ORIGIN,
LiveFatalSignalGuard,
LiveReadOnlyGate,
LiveRunDiagnostics,
UNHANDLED_REJECTION_PREFIX,
diagnosticApiUrl,
environmentalConsoleWarningId,
extensionServiceWorkerUrl,
ignoredSignalRuleId,
installUnhandledRejectionListener,
normalizeIgnoredSignalRules,
readLivePageState,
runIndependentLoggedStages,
runLoggedStage,
serializeError,
};
@@ -0,0 +1,297 @@
const path = require("node:path");
const EXTENSION_ID_PATTERN = /^[a-p]{32}$/;
const LIVE_BUILD_ID_PATTERN = /^[a-f0-9]{32}$/;
const BACKGROUND_SCRIPT = "ryd.background.js";
const DEFAULT_RELOAD_TIMEOUT_MS = 15_000;
const EXTENSION_INVENTORY_POLL_INTERVAL_MS = 50;
const LIVE_EXTENSION_PATH = path.resolve(__dirname, "../../../combined/dist/chrome");
const delay = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
function normalizeExtensionPath(value, { pathImpl = path, platform = process.platform } = {}) {
if (typeof value !== "string" || value.trim() === "") return null;
let normalized = pathImpl.resolve(value.trim()).replace(/[\\/]+$/u, "");
if (platform === "win32") normalized = normalized.toLowerCase();
return normalized;
}
function selectEnabledUnpackedExtension(extensions, expectedPath, { requireEnabled = true, ...pathOptions } = {}) {
if (!Array.isArray(extensions)) throw new Error("Chrome returned a malformed unpacked-extension inventory.");
const normalizedExpectedPath = normalizeExtensionPath(expectedPath, pathOptions);
if (!normalizedExpectedPath) throw new Error("An absolute unpacked-extension path is required.");
const matching = extensions.filter(
(extension) => normalizeExtensionPath(extension?.path, pathOptions) === normalizedExpectedPath,
);
if (matching.length === 0) {
throw new Error(`The unpacked extension at ${expectedPath} is not installed in the attached Chrome profile.`);
}
if (matching.length > 1) {
throw new Error(`Chrome reported more than one unpacked extension at ${expectedPath}; refusing to choose one.`);
}
const [extension] = matching;
if (!EXTENSION_ID_PATTERN.test(extension.id ?? "")) {
throw new Error(`Chrome reported an invalid extension ID for ${expectedPath}.`);
}
if (requireEnabled && extension.enabled !== true) {
throw new Error(`The unpacked extension at ${expectedPath} is installed but disabled.`);
}
return extension;
}
function isOrdinaryYoutubePage(page) {
if (!page || typeof page.url !== "function" || page.isClosed?.()) return false;
try {
const url = new URL(page.url());
return url.protocol === "https:" && ["youtube.com", "www.youtube.com", "m.youtube.com"].includes(url.hostname);
} catch {
return false;
}
}
function assertSelectedSessionPage(sessionPage, context) {
if (!isOrdinaryYoutubePage(sessionPage)) {
throw new Error("The selected extension-reload session page must be an open ordinary HTTPS YouTube page.");
}
if (typeof sessionPage.context !== "function" || sessionPage.context() !== context) {
throw new Error("The selected extension-reload session page does not belong to the attached Chrome context.");
}
return sessionPage;
}
function extensionServiceWorkerUrl(extensionId) {
if (!EXTENSION_ID_PATTERN.test(extensionId ?? "")) throw new Error("A valid Chrome extension ID is required.");
return `chrome-extension://${extensionId}/${BACKGROUND_SCRIPT}`;
}
function matchingExtensionWorkers(context, extensionId, excludedWorker = null) {
const expectedUrl = extensionServiceWorkerUrl(extensionId);
return context.serviceWorkers().filter((worker) => worker !== excludedWorker && worker.url() === expectedUrl);
}
async function waitForExtensionWorker(
context,
serviceWorkerSession,
extensionId,
{ delayImpl = delay, excludedWorker = null, now = Date.now, timeoutMilliseconds = DEFAULT_RELOAD_TIMEOUT_MS } = {},
) {
let workers = matchingExtensionWorkers(context, extensionId, excludedWorker);
if (workers.length > 1) throw new Error(`Chrome exposed duplicate service workers for extension ${extensionId}.`);
if (workers.length === 1) return workers[0];
await serviceWorkerSession.send("ServiceWorker.enable");
await serviceWorkerSession.send("ServiceWorker.startWorker", {
scopeURL: `chrome-extension://${extensionId}/`,
});
const deadline = now() + timeoutMilliseconds;
while (now() < deadline) {
workers = matchingExtensionWorkers(context, extensionId, excludedWorker);
if (workers.length > 1) throw new Error(`Chrome exposed duplicate service workers for extension ${extensionId}.`);
if (workers.length === 1) return workers[0];
await delayImpl(Math.min(50, Math.max(1, deadline - now())));
}
throw new Error(`Chrome did not start ${extensionServiceWorkerUrl(extensionId)} within ${timeoutMilliseconds}ms.`);
}
async function readWorkerIdentity(worker) {
return worker.evaluate(async () => {
const runtime = globalThis.chrome?.runtime;
const manifest = runtime?.getManifest?.();
let resourceBuildId = null;
try {
const response = await fetch(runtime.getURL("live-build.json"), { cache: "no-store" });
const marker = response.ok ? await response.json() : null;
resourceBuildId = marker?.buildId ?? null;
} catch {}
return {
backgroundScript: manifest?.background?.service_worker ?? null,
compiledBuildId: globalThis.__RYD_LIVE_EXTENSION_BUILD__?.buildId ?? null,
extensionId: runtime?.id ?? null,
resourceBuildId,
version: manifest?.version ?? null,
};
});
}
function assertWorkerIdentity(identity, extensionId, { expectedBuildId = null, expectedVersion = null } = {}) {
if (!identity || typeof identity !== "object") throw new Error("The extension service worker returned no identity.");
if (identity.extensionId !== extensionId) {
throw new Error(
`The service worker belongs to ${identity.extensionId ?? "an unknown extension"}, not ${extensionId}.`,
);
}
if (identity.backgroundScript !== BACKGROUND_SCRIPT) {
throw new Error(`Extension ${extensionId} does not use the expected ${BACKGROUND_SCRIPT} service worker.`);
}
if (expectedVersion !== null && identity.version !== expectedVersion) {
throw new Error(
`Reloaded extension ${extensionId} has version ${identity.version ?? "<missing>"}, expected ${expectedVersion}.`,
);
}
if (expectedBuildId !== null) {
if (!LIVE_BUILD_ID_PATTERN.test(expectedBuildId)) throw new Error("The expected live-build ID is malformed.");
if (identity.compiledBuildId !== expectedBuildId) {
throw new Error(
`Reloaded extension ${extensionId} is running build ${identity.compiledBuildId ?? "<missing>"}, expected ${expectedBuildId}.`,
);
}
if (identity.resourceBuildId !== expectedBuildId) {
throw new Error(
`Extension ${extensionId} exposes resource build ${identity.resourceBuildId ?? "<missing>"}, expected ${expectedBuildId}.`,
);
}
}
return identity;
}
async function requestWorkerReload(worker, timeoutMilliseconds = DEFAULT_RELOAD_TIMEOUT_MS) {
const closed = worker.waitForEvent("close", { timeout: timeoutMilliseconds });
await worker.evaluate(() => {
const runtime = chrome.runtime;
setTimeout(() => runtime.reload(), 0);
return true;
});
await closed;
}
async function readUnpackedExtensions(browserSession) {
try {
const result = await browserSession.send("Extensions.getExtensions");
return result?.extensions;
} catch (error) {
throw new Error(
"Chrome did not expose Extensions.getExtensions on this CDP connection, so the unpacked extension cannot be identified safely. Reload it manually; no extension was changed.",
{ cause: error },
);
}
}
async function waitForReloadedExtensionEnabled(
browserSession,
expectedExtensionPath,
expectedExtensionId,
{ delayImpl = delay, now = Date.now, pathOptions, timeoutMilliseconds = DEFAULT_RELOAD_TIMEOUT_MS } = {},
) {
const deadline = now() + timeoutMilliseconds;
while (true) {
const inventory = await readUnpackedExtensions(browserSession);
const extension = selectEnabledUnpackedExtension(inventory, expectedExtensionPath, {
...pathOptions,
requireEnabled: false,
});
if (extension.id !== expectedExtensionId) {
throw new Error(
`The unpacked extension ID changed from ${expectedExtensionId} to ${extension.id} during reload.`,
);
}
if (extension.enabled === true) return extension;
const remainingMilliseconds = deadline - now();
if (remainingMilliseconds <= 0) {
throw new Error(
`The unpacked extension at ${expectedExtensionPath} remained disabled for ${timeoutMilliseconds}ms after reload.`,
);
}
await delayImpl(Math.min(EXTENSION_INVENTORY_POLL_INTERVAL_MS, remainingMilliseconds));
}
}
async function reloadLiveExtension({
browserSession,
context,
expectedBuildId,
expectedExtensionPath,
expectedVersion,
pathOptions,
serviceWorkerSession,
timeoutMilliseconds = DEFAULT_RELOAD_TIMEOUT_MS,
waitOptions = {},
}) {
if (!LIVE_BUILD_ID_PATTERN.test(expectedBuildId ?? "")) throw new Error("The expected live-build ID is malformed.");
const beforeInventory = await readUnpackedExtensions(browserSession);
const before = selectEnabledUnpackedExtension(beforeInventory, expectedExtensionPath, pathOptions);
const oldWorker = await waitForExtensionWorker(context, serviceWorkerSession, before.id, {
...waitOptions,
timeoutMilliseconds,
});
assertWorkerIdentity(await readWorkerIdentity(oldWorker), before.id);
await requestWorkerReload(oldWorker, timeoutMilliseconds);
const after = await waitForReloadedExtensionEnabled(browserSession, expectedExtensionPath, before.id, {
...waitOptions,
pathOptions,
timeoutMilliseconds,
});
const newWorker = await waitForExtensionWorker(context, serviceWorkerSession, after.id, {
...waitOptions,
excludedWorker: oldWorker,
timeoutMilliseconds,
});
const identity = assertWorkerIdentity(await readWorkerIdentity(newWorker), after.id, {
expectedBuildId,
expectedVersion,
});
return {
buildId: identity.compiledBuildId,
extensionId: after.id,
previousVersion: before.version ?? null,
version: identity.version,
};
}
async function reloadLiveExtensionInBrowser({
browser,
context,
expectedBuildId,
expectedExtensionPath = LIVE_EXTENSION_PATH,
expectedVersion,
reloadImpl = reloadLiveExtension,
sessionPage,
timeoutMilliseconds = DEFAULT_RELOAD_TIMEOUT_MS,
}) {
assertSelectedSessionPage(sessionPage, context);
const browserSession = await browser.newBrowserCDPSession();
let serviceWorkerSession = null;
try {
serviceWorkerSession = await context.newCDPSession(sessionPage);
return await reloadImpl({
browserSession,
context,
expectedBuildId,
expectedExtensionPath,
expectedVersion,
serviceWorkerSession,
timeoutMilliseconds,
});
} finally {
await serviceWorkerSession?.detach().catch(() => {});
await browserSession.detach().catch(() => {});
}
}
module.exports = {
BACKGROUND_SCRIPT,
DEFAULT_RELOAD_TIMEOUT_MS,
LIVE_EXTENSION_PATH,
assertSelectedSessionPage,
assertWorkerIdentity,
extensionServiceWorkerUrl,
normalizeExtensionPath,
readUnpackedExtensions,
readWorkerIdentity,
reloadLiveExtension,
reloadLiveExtensionInBrowser,
requestWorkerReload,
selectEnabledUnpackedExtension,
waitForReloadedExtensionEnabled,
waitForExtensionWorker,
};
@@ -0,0 +1,94 @@
const { spawnSync } = require("node:child_process");
const path = require("node:path");
const REQUIRED_PRODUCTION_REACTION_SCENARIOS = Object.freeze(["post-navigation-vote", "reaction-matrix"]);
const SUPPORTED_LIVE_RUNTIMES = new Set(["extension", "userscript"]);
const REPOSITORY_ROOT = path.resolve(__dirname, "../../../..");
class LiveValidationIncompleteError extends Error {
constructor(message) {
super(message);
this.code = "LIVE_VALIDATION_INCOMPLETE";
this.name = "LiveValidationIncompleteError";
}
}
function readFullLiveRuntime(environment = process.env) {
const runtime = environment.RYD_LIVE_RUNTIME?.trim();
if (!SUPPORTED_LIVE_RUNTIMES.has(runtime)) {
throw new Error('RYD_LIVE_RUNTIME must be either "userscript" or "extension" before building a full live run.');
}
return runtime;
}
function buildLiveRuntimeArtifact(
runtime,
{
environment = process.env,
nodeExecutable = process.execPath,
platform = process.platform,
repositoryRoot = REPOSITORY_ROOT,
spawnSyncImpl = spawnSync,
} = {},
) {
if (!SUPPORTED_LIVE_RUNTIMES.has(runtime)) throw new TypeError(`Unsupported live build runtime: ${runtime}`);
const script = `build:live:${runtime}`;
const npmExecutable = environment.npm_execpath?.trim();
const command = npmExecutable ? nodeExecutable : platform === "win32" ? "npm.cmd" : "npm";
const args = npmExecutable ? [npmExecutable, "run", script] : ["run", script];
const result = spawnSyncImpl(command, args, {
cwd: repositoryRoot,
env: environment,
...(platform === "win32" && !npmExecutable ? { shell: true } : {}),
stdio: "inherit",
});
if (result.error) throw new Error(`Could not execute npm run ${script}.`, { cause: result.error });
if (result.status !== 0) throw new Error(`npm run ${script} failed with exit code ${result.status}.`);
return { runtime, script };
}
function requireUserscriptInstallAcknowledgement(value) {
if (value?.trim() !== "INSTALLED") {
throw new LiveValidationIncompleteError(
"Full userscript validation is incomplete because the freshly built userscript was not acknowledged as installed.",
);
}
return true;
}
function requireProductionReactionApproval(value) {
if (value?.trim().toUpperCase() === "SKIP") {
throw new LiveValidationIncompleteError(
"Full live validation is incomplete because production reaction scenarios were skipped.",
);
}
return value?.trim() ?? "";
}
function assertFullLiveReactionCompletion(results) {
if (!results || typeof results !== "object") {
throw new LiveValidationIncompleteError("Full live validation has no production reaction results.");
}
const missing = REQUIRED_PRODUCTION_REACTION_SCENARIOS.filter((scenarioId) => results[scenarioId] == null);
if (missing.length > 0) {
throw new LiveValidationIncompleteError(
`Full live validation did not complete production reaction scenarios: ${missing.join(", ")}.`,
);
}
return Object.freeze({
classification: "full",
completedScenarioIds: REQUIRED_PRODUCTION_REACTION_SCENARIOS,
productionReactionsCompleted: true,
releaseReady: true,
});
}
module.exports = {
LiveValidationIncompleteError,
REQUIRED_PRODUCTION_REACTION_SCENARIOS,
assertFullLiveReactionCompletion,
buildLiveRuntimeArtifact,
readFullLiveRuntime,
requireProductionReactionApproval,
requireUserscriptInstallAcknowledgement,
};
@@ -0,0 +1,104 @@
const { runIndependentLoggedStages } = require("./live-diagnostics");
const {
assertInteractiveReadOnlyScenarioCompletions,
assertInteractiveReadOnlyScenarioDefinitions,
} = require("./live-interactive-scenario-contract");
const INTERACTIVE_READ_ONLY_SCENARIOS = Object.freeze([
Object.freeze({ key: "short", name: "read-only.short-render", scenarioId: "shorts-render" }),
Object.freeze({
key: "channelShorts",
name: "read-only.channel-to-shorts-and-ten-plus-valid-next",
scenarioId: "channel-shorts-navigation",
}),
Object.freeze({ key: "channelWatch", name: "read-only.channel-to-watch", scenarioId: "channel-watch-navigation" }),
Object.freeze({ key: "reload", name: "read-only.reload", scenarioId: "reload" }),
Object.freeze({ key: "responsive", name: "read-only.responsive-visual", scenarioId: "responsive-visual" }),
Object.freeze({ key: "spa", name: "read-only.playlist-spa", scenarioId: "spa-navigation" }),
Object.freeze({
key: "watchActionTopology",
name: "read-only.watch-action-topology",
scenarioId: "watch-action-topology",
}),
Object.freeze({
key: "sidebarStress",
name: "read-only.sidebar-stress",
scenarioId: "sidebar-navigation-stress",
}),
Object.freeze({ key: "watch", name: "read-only.watch-render", scenarioId: "watch-render" }),
]);
assertInteractiveReadOnlyScenarioDefinitions(INTERACTIVE_READ_ONLY_SCENARIOS);
function createInteractiveReadOnlyStages({ diagnostics, options, readOnly, runtimeAdapter, scenarioRunner }) {
if (!options || typeof options !== "object") throw new TypeError("Live options are required.");
if (!options.navigation?.watch) {
throw new TypeError("The full interactive read-only gate requires a configured channel Watch target.");
}
if (!readOnly || typeof readOnly !== "object") throw new TypeError("A read-only result object is required.");
if (!runtimeAdapter) throw new TypeError("A live runtime adapter is required.");
if (!scenarioRunner || typeof scenarioRunner.run !== "function") {
throw new TypeError("A shared live scenario runner is required.");
}
return INTERACTIVE_READ_ONLY_SCENARIOS.map(({ key, name, scenarioId }) => ({
action: async () => {
readOnly[key] = await scenarioRunner.run(runtimeAdapter, scenarioId, options);
return readOnly[key];
},
name,
scenarioId,
}));
}
async function runInteractiveReadOnlyAfterRuntimeReload({
initializeRuntime,
options,
reloadExtension,
runStages = runIndependentLoggedStages,
scenarioRunner,
}) {
if (typeof initializeRuntime !== "function") throw new TypeError("A live runtime initializer is required.");
if (typeof runStages !== "function") throw new TypeError("A live stage executor is required.");
let reloadResult = null;
if (options?.runtime === "extension") {
if (typeof reloadExtension !== "function") throw new TypeError("An extension reload function is required.");
reloadResult = await reloadExtension();
}
const initialized = await initializeRuntime(reloadResult);
if (!initialized?.diagnostics || !initialized?.runtimeAdapter) {
throw new TypeError("The live runtime initializer must return diagnostics and a runtime adapter.");
}
const readOnly = {};
const stages = createInteractiveReadOnlyStages({
diagnostics: initialized.diagnostics,
options,
readOnly,
runtimeAdapter: initialized.runtimeAdapter,
scenarioRunner,
});
const completedScenarioIds = [];
const completionTrackedStages = stages.map((stage) => ({
...stage,
action: async () => {
const result = await stage.action();
if (result === undefined) {
throw new Error(`Interactive read-only scenario ${stage.scenarioId} completed without a result.`);
}
completedScenarioIds.push(stage.scenarioId);
return result;
},
}));
await runStages(initialized.diagnostics, completionTrackedStages);
assertInteractiveReadOnlyScenarioCompletions(completedScenarioIds);
return { readOnly, reloadResult };
}
module.exports = {
INTERACTIVE_READ_ONLY_SCENARIOS,
createInteractiveReadOnlyStages,
runInteractiveReadOnlyAfterRuntimeReload,
};
@@ -1,3 +1,5 @@
const fs = require("node:fs");
const path = require("node:path");
const readline = require("node:readline");
const { chromium } = require("@playwright/test");
const {
@@ -6,133 +8,540 @@ const {
} = require("../../../e2e/live-runtime-adapter");
const { createSharedLiveScenarioRunner } = require("../../../e2e/shared-live-scenarios");
const { consumeLiveVoteApproval, hasFreshVoteApproval, readLiveOptions } = require("../../live/live-options");
const { selectAuthenticatedYoutubeContext } = require("./live-authenticated-context");
const { LiveRunDiagnostics, runLoggedStage } = require("./live-diagnostics");
const { LiveYoutubeDriver, VoteTrafficRecorder } = require("./live-youtube-driver");
const { reloadLiveExtensionInBrowser } = require("./live-extension-reloader");
const {
assertFullLiveReactionCompletion,
buildLiveRuntimeArtifact,
readFullLiveRuntime,
requireProductionReactionApproval,
requireUserscriptInstallAcknowledgement,
} = require("./live-full-run-contract");
const { runInteractiveReadOnlyAfterRuntimeReload } = require("./live-interactive-readonly");
const { LiveYoutubeDriver, VoteTrafficRecorder, withOperationTimeout } = require("./live-youtube-driver");
const REPOSITORY_DIRECTORY = path.resolve(__dirname, "../../../..");
const LIVE_EVIDENCE_DIRECTORY = path.resolve(__dirname, "../../../../test-results/live-youtube");
const LIVE_EVIDENCE_PARENT_DIRECTORY = path.dirname(LIVE_EVIDENCE_DIRECTORY);
const LIVE_ATTEMPT_PAGE_CLOSE_TIMEOUT_MS = 10_000;
const LIVE_SESSION_COMMANDS = new Set(["EXIT", "RERUN"]);
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());
});
function createInputLineReader(input = process.stdin) {
const lineInterface = readline.createInterface({ input, terminal: false });
const bufferedLines = [];
const waiters = [];
let closed = false;
lineInterface.on("line", (line) => {
const value = line.trim();
const waiter = waiters.shift();
if (waiter) waiter(value);
else bufferedLines.push(value);
});
lineInterface.once("close", () => {
closed = true;
for (const waiter of waiters.splice(0)) waiter(null);
});
return {
close: () => lineInterface.close(),
readLine: () => {
if (bufferedLines.length > 0) return Promise.resolve(bufferedLines.shift());
if (closed) return Promise.resolve(null);
return new Promise((resolve) => waiters.push(resolve));
},
};
}
function readPersistentSessionMode(environment = process.env) {
const configured = environment.RYD_LIVE_KEEP_CDP_SESSION?.trim();
if (!configured) return false;
if (configured !== "1") {
throw new Error("RYD_LIVE_KEEP_CDP_SESSION must be 1 or unset.");
}
return true;
}
function normalizeLiveSessionCommand(value) {
if (value === null) return "EXIT";
const command = value?.trim().toUpperCase();
return LIVE_SESSION_COMMANDS.has(command) ? command : null;
}
function assertReusableConnectionOptions(initialOptions, nextOptions) {
const immutableFields = ["cdpEndpoint", "expectedChannel", "runtime"];
for (const field of immutableFields) {
if (nextOptions?.[field] !== initialOptions?.[field]) {
throw new Error(
`Cannot reuse the live browser connection after ${field} changed from ${initialOptions?.[field] ?? "<missing>"} to ${nextOptions?.[field] ?? "<missing>"}. Exit and start a new live runner instead.`,
);
}
}
return nextOptions;
}
function copyDirectoryEntry(source, destination, fsImpl = fs) {
const stats = fsImpl.statSync(source);
if (stats.isDirectory()) {
fsImpl.mkdirSync(destination, { recursive: true });
for (const entry of fsImpl.readdirSync(source)) {
copyDirectoryEntry(path.join(source, entry), path.join(destination, entry), fsImpl);
}
return;
}
fsImpl.mkdirSync(path.dirname(destination), { recursive: true });
fsImpl.copyFileSync(source, destination);
}
function canonicalizeExistingPath(targetPath, fsImpl = fs) {
const resolvedPath = path.resolve(targetPath);
if (!fsImpl.existsSync(resolvedPath)) return resolvedPath;
const realpath = fsImpl.realpathSync?.native ?? fsImpl.realpathSync;
return realpath ? realpath(resolvedPath) : resolvedPath;
}
function pathsAreEqual(left, right) {
const normalize = (value) => {
const normalized = path.normalize(value);
return process.platform === "win32" ? normalized.toLowerCase() : normalized;
};
return normalize(left) === normalize(right);
}
function isAttemptsEvidenceEntry(entry) {
return entry.toLowerCase() === "attempts";
}
function resolveSafeLiveEvidenceDirectory(
evidenceDirectory = LIVE_EVIDENCE_DIRECTORY,
fsImpl = fs,
allowedParentDirectory = LIVE_EVIDENCE_PARENT_DIRECTORY,
) {
const resolvedEvidenceDirectory = canonicalizeExistingPath(evidenceDirectory, fsImpl);
const resolvedRepositoryDirectory = canonicalizeExistingPath(REPOSITORY_DIRECTORY, fsImpl);
const resolvedAllowedParentDirectory = canonicalizeExistingPath(allowedParentDirectory, fsImpl);
const filesystemRoot = path.parse(resolvedEvidenceDirectory).root;
if (
pathsAreEqual(resolvedEvidenceDirectory, filesystemRoot) ||
pathsAreEqual(resolvedEvidenceDirectory, resolvedRepositoryDirectory) ||
!pathsAreEqual(path.dirname(resolvedEvidenceDirectory), resolvedAllowedParentDirectory) ||
!pathsAreEqual(path.basename(resolvedEvidenceDirectory), "live-youtube")
) {
throw new Error(
`Refusing to reset unsafe live evidence directory ${resolvedEvidenceDirectory}. The target must be the dedicated live-youtube directory directly under ${resolvedAllowedParentDirectory}, never a filesystem or repository root.`,
);
}
if (fsImpl.existsSync(resolvedEvidenceDirectory) && !fsImpl.statSync(resolvedEvidenceDirectory).isDirectory()) {
throw new Error(`Refusing to reset live evidence path because it is not a directory: ${resolvedEvidenceDirectory}`);
}
return resolvedEvidenceDirectory;
}
function resetLiveAttemptEvidenceWorkspace(
{ attemptNumber } = {},
{
allowedParentDirectory = LIVE_EVIDENCE_PARENT_DIRECTORY,
evidenceDirectory = LIVE_EVIDENCE_DIRECTORY,
fsImpl = fs,
} = {},
) {
const resolvedEvidenceDirectory = resolveSafeLiveEvidenceDirectory(evidenceDirectory, fsImpl, allowedParentDirectory);
if (!fsImpl.existsSync(resolvedEvidenceDirectory)) {
return { attemptNumber, evidenceDirectory: resolvedEvidenceDirectory, removedEntries: [] };
}
const removedEntries = [];
for (const entry of fsImpl.readdirSync(resolvedEvidenceDirectory).sort()) {
if (isAttemptsEvidenceEntry(entry)) continue;
const entryPath = path.resolve(resolvedEvidenceDirectory, entry);
const relativeEntryPath = path.relative(resolvedEvidenceDirectory, entryPath);
if (
!relativeEntryPath ||
relativeEntryPath === ".." ||
relativeEntryPath.startsWith(`..${path.sep}`) ||
path.isAbsolute(relativeEntryPath)
) {
throw new Error(`Refusing to remove live evidence entry outside the dedicated directory: ${entryPath}`);
}
fsImpl.rmSync(entryPath, { force: true, recursive: true });
removedEntries.push(entry);
}
return { attemptNumber, evidenceDirectory: resolvedEvidenceDirectory, removedEntries };
}
function preserveLiveAttemptEvidence(
{ attemptNumber, outcome },
{ evidenceDirectory = LIVE_EVIDENCE_DIRECTORY, fsImpl = fs, now = () => new Date() } = {},
) {
const timestamp = now().toISOString().replace(/[:.]/gu, "-");
const attemptDirectory = path.join(
evidenceDirectory,
"attempts",
`${String(attemptNumber).padStart(2, "0")}-${timestamp}-${outcome}`,
);
fsImpl.mkdirSync(attemptDirectory, { recursive: true });
if (fsImpl.existsSync(evidenceDirectory)) {
for (const entry of fsImpl.readdirSync(evidenceDirectory)) {
if (isAttemptsEvidenceEntry(entry)) continue;
copyDirectoryEntry(path.join(evidenceDirectory, entry), path.join(attemptDirectory, entry), fsImpl);
}
}
fsImpl.writeFileSync(
path.join(attemptDirectory, "attempt.json"),
`${JSON.stringify({ attemptNumber, capturedAt: now().toISOString(), outcome }, null, 2)}\n`,
);
return attemptDirectory;
}
function createInteractiveLiveDriver({ context, diagnostics, options, page, selectedExtensionId }) {
return new LiveYoutubeDriver(page, context, {
authenticatedHandle: options.expectedChannel,
expectedBuildId: options.expectedBuildId,
reportProgress: (name, details) => diagnostics.checkpoint(name, details),
selectedExtensionId,
});
}
async function main() {
const options = readLiveOptions();
function createInteractiveVoteRecorderFactory({ context, driver, options, page, selectedExtensionId }) {
return (videoId) =>
new VoteTrafficRecorder(context, videoId, {
page,
runtime: options.runtime,
selectedExtensionId,
trafficLedger: driver.trafficLedger,
});
}
function createReactionTargetDetails(options) {
return {
reactionShort: options.reactionShort,
reactionWatch: options.watchB,
};
}
function formatReactionApprovalReadiness(options) {
return `READY_FOR_REACTION_APPROVAL runtime=${options.runtime} watch=${options.watchB} reactionShort=${options.reactionShort} readOnlyShort=${options.short}`;
}
async function closeLiveAttemptPage(page, { timeoutMs = LIVE_ATTEMPT_PAGE_CLOSE_TIMEOUT_MS } = {}) {
if (!page || page.isClosed()) return false;
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
throw new TypeError("The live-attempt page-close timeout must be positive.");
}
await withOperationTimeout(
() => page.close(),
timeoutMs,
`Timed out after ${timeoutMs}ms while closing the live-attempt test page.`,
);
return true;
}
async function prepareLiveAttempt({ buildArtifact, environment, output, readLine, readOptions, requestedRuntime }) {
const build = buildArtifact(requestedRuntime);
output.log(`LIVE_ARTIFACT_BUILT ${JSON.stringify(build)}`);
if (requestedRuntime === "userscript") {
output.log(
"LIVE_USERSCRIPT_INSTALL_REQUIRED Reinstall test-results/live-build/userscript/Return Youtube Dislike.user.js, then enter INSTALLED.",
);
requireUserscriptInstallAcknowledgement(await readLine());
}
const options = readOptions(environment);
if (!options) throw new Error("Set RYD_LIVE_YOUTUBE=1 and the documented allowlist variables to opt in.");
if (options.runtime !== requestedRuntime) {
throw new Error(`The built live runtime ${requestedRuntime} does not match configured runtime ${options.runtime}.`);
}
return options;
}
let browser;
async function runConnectedLiveAttempt({
browser,
options,
output,
pageCloseTimeoutMs = LIVE_ATTEMPT_PAGE_CLOSE_TIMEOUT_MS,
readLine,
selectedSession,
}) {
let attemptError;
let diagnostics;
let driver;
let page;
let selectedExtensionId = null;
try {
console.log("WAITING_FOR_BRAVE_DEBUG_APPROVAL");
browser = await chromium.connectOverCDP(options.cdpEndpoint, {
isLocal: true,
noDefaults: true,
timeout: 120_000,
const { context } = selectedSession;
let runtimeAdapter;
const { readOnly } = await runInteractiveReadOnlyAfterRuntimeReload({
initializeRuntime: async (reloaded) => {
selectedExtensionId = reloaded?.extensionId ?? null;
page = await context.newPage();
diagnostics = new LiveRunDiagnostics(page, context, { runtime: options.runtime, selectedExtensionId });
await diagnostics.start();
driver = createInteractiveLiveDriver({
context,
diagnostics,
options,
page,
selectedExtensionId,
});
const createAdapter =
options.runtime === "extension" ? createExtensionLiveRuntimeAdapter : createUserscriptLiveRuntimeAdapter;
runtimeAdapter = createAdapter({
driver,
expectedBuildId: options.expectedBuildId,
expectedVersion: options.expectedVersion,
});
output.log("CHROMIUM_CONNECTED");
return { diagnostics, runtimeAdapter };
},
options,
reloadExtension: async () => {
const reloaded = await reloadLiveExtensionInBrowser({
browser,
context,
expectedBuildId: options.expectedBuildId,
expectedVersion: options.expectedVersion,
sessionPage: selectedSession.sessionPage,
});
output.log(`LIVE_EXTENSION_RELOADED ${JSON.stringify(reloaded)}`);
return reloaded;
},
scenarioRunner,
});
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}`,
);
await driver.assertNoUnclaimedAttributedTraffic("interactive read-only run");
output.log(`READ_ONLY_COMPLETE ${JSON.stringify(readOnly)}`);
output.log(formatReactionApprovalReadiness(options));
diagnostics.checkpoint("reaction-approval.waiting", {
instruction: "Enter SKIP to close the live-test tab without production reactions",
instruction: "Enter the fresh production-reaction approval token",
});
const approval = await readApprovalLine();
if (approval === "SKIP") {
console.log("REACTION_MATRIX_SKIPPED");
return;
}
const approval = requireProductionReactionApproval(await readLine());
diagnostics.consumeFatalSignals("reaction approval wait");
await driver.assertNoUnclaimedAttributedTraffic("reaction approval wait");
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.");
}
},
}),
let approvalConsumed = false;
const reactionServices = {
createRecorder: createInteractiveVoteRecorderFactory({ context, driver, options, page, selectedExtensionId }),
consumeVoteApproval: async () => {
driver.assertCurrentVideo(options.watchB);
await driver.assertRuntime(options.runtime, options.expectedVersion, options.expectedBuildId);
if (approvalConsumed) return;
if (!consumeLiveVoteApproval(approval, options.runtime, options.watchB)) {
throw new Error("The live reaction approval expired or was already used. No reaction was clicked.");
}
approvalConsumed = true;
},
};
const reaction = await runLoggedStage(diagnostics, "production-reactions", async () => ({
"post-navigation-vote": await scenarioRunner.run(
runtimeAdapter,
"post-navigation-vote",
options,
reactionServices,
),
"reaction-matrix": await scenarioRunner.run(runtimeAdapter, "reaction-matrix", options, reactionServices),
}));
await driver.assertNoUnclaimedAttributedTraffic("interactive production reactions");
const completion = assertFullLiveReactionCompletion(reaction);
output.log(`PRODUCTION_REACTIONS_COMPLETE ${JSON.stringify(reaction)}`);
output.log(
`LIVE_VALIDATION_COMPLETE ${JSON.stringify({
...completion,
...createReactionTargetDetails(options),
})}`,
);
console.log(`REACTION_MATRIX_COMPLETE ${JSON.stringify(reaction)}`);
return { classification: completion.classification, reaction, readOnly };
} catch (error) {
attemptError = error;
if (diagnostics) {
try {
const snapshotPath = await diagnostics.persistFailureSnapshot(error);
console.error(`LIVE_FAILURE_SNAPSHOT ${snapshotPath}`);
output.error(`LIVE_FAILURE_SNAPSHOT ${snapshotPath}`);
} catch (snapshotError) {
console.error(`LIVE_FAILURE_SNAPSHOT_FAILED ${snapshotError.message}`);
output.error(`LIVE_FAILURE_SNAPSHOT_FAILED ${snapshotError.message}`);
}
}
throw error;
} finally {
driver?.stop();
diagnostics?.stop();
if (page && !page.isClosed()) await page.close();
if (browser) await browser.close();
let pageCloseError;
try {
if (page && !page.isClosed()) {
output.log(`LIVE_ATTEMPT_PAGE_CLOSE_START ${JSON.stringify({ timeoutMs: pageCloseTimeoutMs })}`);
await closeLiveAttemptPage(page, { timeoutMs: pageCloseTimeoutMs });
output.log("LIVE_ATTEMPT_PAGE_CLOSE_COMPLETE");
}
} catch (error) {
pageCloseError = error;
output.error(`LIVE_ATTEMPT_PAGE_CLOSE_FAILED ${JSON.stringify({ message: error.message })}`);
}
if (attemptError && pageCloseError) {
throw new AggregateError(
[attemptError, pageCloseError],
"The live attempt and its test-page cleanup both failed.",
);
}
if (attemptError) throw attemptError;
if (pageCloseError) throw pageCloseError;
}
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
async function runLiveAttemptLoop({
initialOptions,
output,
persistentSession,
prepareNextAttempt,
preserveEvidence,
readLine,
resetEvidenceWorkspace,
runAttempt,
}) {
let attemptNumber = 1;
let options = initialOptions;
while (true) {
const reset = await resetEvidenceWorkspace({ attemptNumber });
output.log(
`LIVE_ATTEMPT_EVIDENCE_RESET ${JSON.stringify({
attemptNumber,
removedEntries: reset?.removedEntries ?? [],
})}`,
);
output.log(
`LIVE_ATTEMPT_START ${JSON.stringify({ attemptNumber, persistentSession, runtime: options?.runtime ?? null })}`,
);
let attemptError = null;
let attemptResult = null;
try {
if (attemptNumber > 1) {
const nextOptions = await prepareNextAttempt();
options = assertReusableConnectionOptions(initialOptions, nextOptions);
}
attemptResult = await runAttempt(options, attemptNumber);
output.log(`LIVE_ATTEMPT_COMPLETE ${JSON.stringify({ attemptNumber, outcome: "passed" })}`);
} catch (error) {
attemptError = error;
output.error(
`LIVE_ATTEMPT_FAILED ${JSON.stringify({ attemptNumber, message: error.message, outcome: "failed" })}`,
);
}
if (!persistentSession) {
if (attemptError) throw attemptError;
return attemptResult;
}
const outcome = attemptError ? "failed" : "passed";
try {
const evidencePath = await preserveEvidence({ attemptNumber, outcome });
output.log(`LIVE_ATTEMPT_EVIDENCE ${JSON.stringify({ attemptNumber, outcome, path: evidencePath })}`);
} catch (evidenceError) {
attemptError = attemptError
? new AggregateError([attemptError, evidenceError], "The live attempt and its evidence snapshot both failed.")
: evidenceError;
output.error(`LIVE_ATTEMPT_EVIDENCE_FAILED ${JSON.stringify({ attemptNumber, message: evidenceError.message })}`);
}
while (true) {
output.log(`READY_FOR_LIVE_RERUN_OR_EXIT attempt=${attemptNumber} outcome=${attemptError ? "failed" : "passed"}`);
const command = normalizeLiveSessionCommand(await readLine());
if (command === "RERUN") {
attemptNumber += 1;
break;
}
if (command === "EXIT") {
if (attemptError) throw attemptError;
return attemptResult;
}
output.error("LIVE_RERUN_COMMAND_REJECTED expected=RERUN_OR_EXIT");
}
}
}
async function main({
buildArtifact = buildLiveRuntimeArtifact,
connectBrowser = (options) =>
chromium.connectOverCDP(options.cdpEndpoint, {
isLocal: true,
noDefaults: true,
timeout: options.cdpConnectTimeoutMilliseconds,
}),
environment = process.env,
executeAttempt = runConnectedLiveAttempt,
output = console,
preserveEvidence = preserveLiveAttemptEvidence,
readLine,
readOptions = readLiveOptions,
resetEvidenceWorkspace = resetLiveAttemptEvidenceWorkspace,
selectContext = selectAuthenticatedYoutubeContext,
} = {}) {
const persistentSession = readPersistentSessionMode(environment);
const requestedRuntime = readFullLiveRuntime(environment);
const ownedLineReader = readLine ? null : createInputLineReader();
const nextLine = readLine ?? ownedLineReader.readLine;
const prepareAttempt = () =>
prepareLiveAttempt({
buildArtifact,
environment,
output,
readLine: nextLine,
readOptions,
requestedRuntime,
});
let browser;
try {
const initialOptions = await prepareAttempt();
output.log("WAITING_FOR_CHROMIUM_DEBUG_APPROVAL");
browser = await connectBrowser(initialOptions);
const selectedSession = await selectContext(browser, initialOptions.expectedChannel);
output.log(
`AUTHENTICATED_CHROMIUM_CONTEXT_SELECTED context=${selectedSession.contextIndex + 1} page=${selectedSession.pageIndex + 1} handle=${initialOptions.expectedChannel}`,
);
return await runLiveAttemptLoop({
initialOptions,
output,
persistentSession,
prepareNextAttempt: prepareAttempt,
preserveEvidence,
readLine: nextLine,
resetEvidenceWorkspace,
runAttempt: (options, attemptNumber) =>
executeAttempt({ browser, options, output, readLine: nextLine, selectedSession, attemptNumber }),
});
} finally {
if (browser) await browser.close();
ownedLineReader?.close();
}
}
if (require.main === module) {
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
}
module.exports = {
assertReusableConnectionOptions,
closeLiveAttemptPage,
createInputLineReader,
createInteractiveLiveDriver,
createInteractiveVoteRecorderFactory,
createReactionTargetDetails,
formatReactionApprovalReadiness,
main,
normalizeLiveSessionCommand,
prepareLiveAttempt,
preserveLiveAttemptEvidence,
readPersistentSessionMode,
resetLiveAttemptEvidenceWorkspace,
resolveSafeLiveEvidenceDirectory,
runConnectedLiveAttempt,
runLiveAttemptLoop,
};
@@ -0,0 +1,73 @@
const REQUIRED_INTERACTIVE_READ_ONLY_SCENARIO_IDS = Object.freeze([
"shorts-render",
"channel-shorts-navigation",
"channel-watch-navigation",
"reload",
"responsive-visual",
"spa-navigation",
"watch-action-topology",
"sidebar-navigation-stress",
"watch-render",
]);
function assertExactScenarioOrder(actualScenarioIds, label) {
if (!Array.isArray(actualScenarioIds)) throw new TypeError(`${label} must be an array.`);
if (actualScenarioIds.some((scenarioId) => typeof scenarioId !== "string" || scenarioId.trim() === "")) {
throw new TypeError(`${label} must contain only non-empty scenario IDs.`);
}
const duplicates = actualScenarioIds.filter((scenarioId, index) => actualScenarioIds.indexOf(scenarioId) !== index);
if (duplicates.length > 0) {
throw new Error(`${label} contains duplicate scenario IDs: ${[...new Set(duplicates)].join(", ")}.`);
}
const actual = new Set(actualScenarioIds);
const required = new Set(REQUIRED_INTERACTIVE_READ_ONLY_SCENARIO_IDS);
const missing = REQUIRED_INTERACTIVE_READ_ONLY_SCENARIO_IDS.filter((scenarioId) => !actual.has(scenarioId));
const unexpected = actualScenarioIds.filter((scenarioId) => !required.has(scenarioId));
if (missing.length > 0 || unexpected.length > 0) {
throw new Error(
`${label} does not match the required interactive read-only set. Missing: ${
missing.join(", ") || "none"
}; unexpected: ${unexpected.join(", ") || "none"}.`,
);
}
const firstMismatch = actualScenarioIds.findIndex(
(scenarioId, index) => scenarioId !== REQUIRED_INTERACTIVE_READ_ONLY_SCENARIO_IDS[index],
);
if (actualScenarioIds.length !== REQUIRED_INTERACTIVE_READ_ONLY_SCENARIO_IDS.length || firstMismatch !== -1) {
throw new Error(`${label} does not follow the required interactive read-only order.`);
}
return true;
}
function assertUniqueDefinitionField(definitions, field) {
const values = definitions.map((definition) => definition?.[field]);
if (values.some((value) => typeof value !== "string" || value.trim() === "")) {
throw new TypeError(`Every interactive read-only scenario requires a non-empty ${field}.`);
}
if (new Set(values).size !== values.length) {
throw new Error(`Interactive read-only scenario ${field} values must be unique.`);
}
}
function assertInteractiveReadOnlyScenarioDefinitions(definitions) {
if (!Array.isArray(definitions)) throw new TypeError("Interactive read-only scenario definitions must be an array.");
assertUniqueDefinitionField(definitions, "key");
assertUniqueDefinitionField(definitions, "name");
return assertExactScenarioOrder(
definitions.map((definition) => definition?.scenarioId),
"Interactive read-only scenario definitions",
);
}
function assertInteractiveReadOnlyScenarioCompletions(completedScenarioIds) {
return assertExactScenarioOrder(completedScenarioIds, "Completed interactive read-only scenarios");
}
module.exports = {
REQUIRED_INTERACTIVE_READ_ONLY_SCENARIO_IDS,
assertInteractiveReadOnlyScenarioCompletions,
assertInteractiveReadOnlyScenarioDefinitions,
};
+587 -66
View File
@@ -1,7 +1,16 @@
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const { MIN_LIVE_SHORTS_NEXT_HOPS } = require("../../live/live-navigation-constants");
const { WATCH_RATIO_SOAK_DURATION_MS } = require("./live-youtube-driver");
const {
DISLIKED_STATE,
LIKED_STATE,
NEUTRAL_STATE,
applyVoteTransitionCounts,
resolveVoteTransition,
} = require("../../../common/vote-transition-core");
const { assertExpectedWatchCounts } = require("./watch-ratio-audit");
const RESPONSIVE_VIEWPORTS = [
{ height: 720, width: 1280 },
@@ -9,16 +18,38 @@ const RESPONSIVE_VIEWPORTS = [
{ height: 844, width: 390 },
];
const WATCH_ACTION_TOPOLOGY_VIEWPORTS = [
{ height: 900, name: "wide-constrained", width: 1536 },
{ height: 800, name: "stacked", width: 1300 },
{ height: 800, name: "narrow", width: 820 },
];
const REACTION_CYCLES = {
neutral: ["like", "like", "dislike", "like", "dislike", "dislike"],
liked: ["like", "dislike", "like", "dislike", "dislike", "like"],
disliked: ["like", "dislike", "dislike", "like", "like", "dislike"],
};
const LIVE_TO_SHARED_REACTION_STATE = {
disliked: DISLIKED_STATE,
liked: LIKED_STATE,
neutral: NEUTRAL_STATE,
};
const SHARED_TO_LIVE_REACTION_STATE = Object.fromEntries(
Object.entries(LIVE_TO_SHARED_REACTION_STATE).map(([liveState, sharedState]) => [sharedState, liveState]),
);
function resolveLiveVoteTransition(state, action) {
const sharedState = LIVE_TO_SHARED_REACTION_STATE[state];
assert.ok(sharedState, `Unsupported live reaction state: ${state}`);
const transition = resolveVoteTransition(sharedState, action);
const nextState = SHARED_TO_LIVE_REACTION_STATE[transition.nextState];
assert.ok(nextState, `Unsupported shared reaction state: ${transition.nextState}`);
return { ...transition, nextState };
}
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}`);
return resolveLiveVoteTransition(state, action).nextState;
}
function reactionValue(state) {
@@ -35,39 +66,40 @@ async function assertLivePreconditions(driver, options) {
async function runWatchRenderScenario(driver, options) {
return driver.withNoProductionInteractions(async () => {
await driver.openPlaylist(options.playlistUrl, options.watchA);
const navigation = await driver.openPlaylist(options.playlistUrl, options.watchA);
await assertLivePreconditions(driver, options);
return driver.waitForDislikeText();
return driver.assertCurrentWatchResult(options.watchA, options.runtime, navigation.body);
});
}
async function runReloadScenario(driver, options) {
return driver.withNoProductionInteractions(async () => {
await driver.openPlaylist(options.playlistUrl, options.watchA);
const initialNavigation = await driver.openPlaylist(options.playlistUrl, options.watchA);
await assertLivePreconditions(driver, options);
await driver.waitForDislikeText();
await driver.reload(options.watchA);
const initial = await driver.assertCurrentWatchResult(options.watchA, options.runtime, initialNavigation.body);
const reloadNavigation = await driver.reload(options.watchA);
await assertLivePreconditions(driver, options);
return driver.waitForDislikeText();
const reloaded = await driver.assertCurrentWatchResult(options.watchA, options.runtime, reloadNavigation.body);
return { initial, reloaded };
});
}
async function runSpaNavigationScenario(driver, options) {
return driver.withNoProductionInteractions(async () => {
await driver.openPlaylist(options.playlistUrl, options.watchA);
const watchANavigation = await driver.openPlaylist(options.playlistUrl, options.watchA);
await assertLivePreconditions(driver, options);
const watchACount = await driver.waitForDislikeText();
await driver.navigateWithinPlaylist(options.watchB);
const watchA = await driver.assertCurrentWatchResult(options.watchA, options.runtime, watchANavigation.body);
const watchBNavigation = await driver.navigateWithinPlaylist({ excludedVideoIds: [options.watchA] });
const destinationVideoId = watchBNavigation.videoId;
assert.match(destinationVideoId, /^[A-Za-z0-9_-]{11}$/, "Playlist navigation returned an invalid video ID.");
assert.notEqual(destinationVideoId, options.watchA, "Playlist navigation returned the source video.");
await assertLivePreconditions(driver, options);
const watchBCount = await driver.waitForDislikeText({ differentFrom: watchACount });
const watchB = await driver.assertCurrentWatchResult(destinationVideoId, options.runtime, watchBNavigation.body);
const watchACount = watchA.count;
const watchBCount = watchB.count;
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 };
return { destinationVideoId, watchA, watchACount, watchB, watchBCount };
});
}
@@ -106,26 +138,31 @@ async function runSidebarStressScenario(
outputDirectory,
`${options.runtime}-sidebar-hop-${String(index + 1).padStart(2, "0")}.png`,
);
const readinessStartedAt = Date.now();
const visual = await driver.captureWatchRatioVisual(options.runtime, screenshotPath, {
expectedCounts: body,
expectedVideoId: videoId,
presenceTimeoutMs: readyTimeoutMs,
});
const readyLatencyMs = Date.now() - readinessStartedAt;
const readyLatencyMs = visual.presenceLatencyMs;
assert.ok(
readyLatencyMs <= readyTimeoutMs,
Number.isFinite(readyLatencyMs) && 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 countAudit = await driver.assertRenderedDislikeCount(visual.count, body.dislikes, options.runtime);
const soak = await driver.soakWatchRatioVisual(options.runtime, {
durationMs: soakDurationMs,
expectedCount: visual.count,
expectedCounts: body,
videoId,
});
visitedVideoIds.push(videoId);
hops.push({
apiDislikes: body.dislikes,
apiLikes: body.likes,
count: visual.count,
countAudit,
readyLatencyMs,
readyTimeoutMs,
screenshotPath,
@@ -138,43 +175,402 @@ async function runSidebarStressScenario(
});
}
async function runShortsRenderScenario(driver, options) {
async function runWatchActionTopologyScenario(
driver,
options,
{
makeDirectory = (directory) => fs.mkdirSync(directory, { recursive: true }),
outputDirectory = path.resolve(__dirname, "../../../../test-results/live-youtube/watch-actions"),
} = {},
) {
makeDirectory(outputDirectory);
return driver.withNoProductionInteractions(async () => {
await driver.openShort(options.short);
await assertLivePreconditions(driver, options);
return driver.waitForDislikeText();
const originalViewport = await driver.readViewportSize();
const coldLayouts = [];
let baseline;
try {
for (const viewport of WATCH_ACTION_TOPOLOGY_VIEWPORTS) {
await driver.setViewportSize(viewport);
const navigation = await driver.openWatch(options.watchA);
await assertLivePreconditions(driver, options);
const count = await driver.waitForDislikeText();
await driver.assertRenderedDislikeCount(count, navigation.body.dislikes, options.runtime);
const topology = await driver.captureWatchActionTopologyVisual(
options.runtime,
path.join(outputDirectory, `${options.runtime}-cold-${viewport.name}-${viewport.width}.png`),
{
expectedCounts: navigation.body,
expectedInventorySignatures: baseline?.inventorySignatures ?? null,
minimumTopLevelOptionalActions: baseline ? 0 : 1,
},
);
if (!baseline) {
baseline = topology;
}
coldLayouts.push(topology);
}
const coldLayoutRecords = coldLayouts.map((topology, index) => ({
topology,
viewport: WATCH_ACTION_TOPOLOGY_VIEWPORTS[index],
}));
const expansionTransitions = coldLayoutRecords
.flatMap((source) => coldLayoutRecords.map((destination) => ({ destination, source })))
.filter(
({ destination, source }) =>
destination.topology.topLevelOptionalSignatures.length >
source.topology.topLevelOptionalSignatures.length &&
source.topology.topLevelOptionalSignatures.every((signature) =>
destination.topology.topLevelOptionalSignatures.includes(signature),
),
)
.sort(
(left, right) =>
right.destination.topology.topLevelOptionalSignatures.length -
left.destination.topology.topLevelOptionalSignatures.length ||
left.source.topology.topLevelOptionalSignatures.length -
right.source.topology.topLevelOptionalSignatures.length,
);
const { destination: resizeDestination, source: resizeSource } = expansionTransitions[0] ?? {};
assert.ok(
resizeDestination,
"The configured cold Watch widths did not expose a layout with a strict superset of top-level actions.",
);
await driver.setViewportSize(resizeSource.viewport);
const resizeNavigation = await driver.openWatch(options.watchA);
await assertLivePreconditions(driver, options);
const resizeCount = await driver.waitForDislikeText();
await driver.assertRenderedDislikeCount(resizeCount, resizeNavigation.body.dislikes, options.runtime);
const resizeRoundTripSource = await driver.captureWatchActionTopologyVisual(
options.runtime,
path.join(
outputDirectory,
`${options.runtime}-resize-source-${resizeSource.viewport.name}-${resizeSource.viewport.width}.png`,
),
{
expectedCounts: resizeNavigation.body,
expectedInventorySignatures: baseline.inventorySignatures,
expectedTopLevelOptionalSignatures: resizeSource.topology.topLevelOptionalSignatures,
minimumTopLevelOptionalActions: resizeSource.topology.topLevelOptionalSignatures.length,
},
);
await driver.setViewportSize(resizeDestination.viewport);
await driver.waitForDislikeText();
const resizeRoundTripDestination = await driver.captureWatchActionTopologyVisual(
options.runtime,
path.join(
outputDirectory,
`${options.runtime}-resize-destination-${resizeDestination.viewport.name}-${resizeDestination.viewport.width}.png`,
),
{
expectedCounts: resizeNavigation.body,
expectedInventorySignatures: baseline.inventorySignatures,
expectedTopLevelOptionalSignatures: resizeDestination.topology.topLevelOptionalSignatures,
minimumTopLevelOptionalActions: resizeDestination.topology.topLevelOptionalSignatures.length,
},
);
await driver.setViewportSize(WATCH_ACTION_TOPOLOGY_VIEWPORTS[0]);
const { body: sidebarBody, videoId: sidebarVideoId } = await driver.navigateToRelatedWatch([options.watchA]);
await assertLivePreconditions(driver, options);
const sidebarCount = await driver.waitForDislikeText();
await driver.assertRenderedDislikeCount(sidebarCount, sidebarBody.dislikes, options.runtime);
const sidebar = await driver.captureWatchActionTopologyVisual(
options.runtime,
path.join(outputDirectory, `${options.runtime}-sidebar-${sidebarVideoId}.png`),
{ expectedCounts: sidebarBody, minimumTopLevelOptionalActions: 1 },
);
return {
coldLayouts,
outputDirectory,
resizeRoundTrip: {
destination: resizeRoundTripDestination,
destinationViewport: resizeDestination.viewport,
source: resizeRoundTripSource,
sourceViewport: resizeSource.viewport,
},
sidebar,
sidebarVideoId,
};
} finally {
await driver.setViewportSize(originalViewport);
}
});
}
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);
async function runShortsRenderScenario(
driver,
options,
{
makeDirectory = (directory) => fs.mkdirSync(directory, { recursive: true }),
outputDirectory = path.resolve(__dirname, "../../../../test-results/live-youtube/specific-short"),
} = {},
) {
const captureShortsVisual = {
"native-pair": (screenshotPath) => driver.captureNativeShortsVisual(options.short, screenshotPath),
"strict-synthetic": (screenshotPath) => driver.captureSyntheticShortsVisual(options.short, screenshotPath),
}[options.capabilities?.shortsVisualModel];
assert.equal(
typeof captureShortsVisual,
"function",
`Unsupported live Shorts visual model: ${options.capabilities?.shortsVisualModel ?? "missing"}.`,
);
makeDirectory(outputDirectory);
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 driver.withNoProductionInteractions(async () => {
const validate = async (navigation, phase) => {
await assertLivePreconditions(driver, options);
assert.equal(navigation?.videoId, options.short, `${phase} targeted a stale Short.`);
assert.equal(navigation?.status, 200, `${phase} did not receive HTTP 200 for ${options.short}.`);
assert.ok(
Number.isSafeInteger(navigation?.body?.dislikes) && navigation.body.dislikes >= 0,
`${phase} has no valid production dislike count for ${options.short}.`,
);
const control = await driver.assertCurrentShortsControl(options.short, options.runtime, {
expectedDislikes: navigation.body.dislikes,
});
const visual = await captureShortsVisual(
path.join(outputDirectory, `${options.runtime}-${phase.toLowerCase().replace(/[^a-z0-9]+/g, "-")}.png`),
);
const stability = await driver.soakCurrentShortsControl(options.short, options.runtime, navigation.body.dislikes);
return { control, navigation, stability, visual };
};
const direct = await validate(await driver.openShort(options.short), "cold-direct-load");
const reloaded = await validate(await driver.reload(options.short), "reload");
return { direct, outputDirectory, reloaded, videoId: options.short };
});
}
async function runChannelShortsNavigationScenario(
driver,
options,
{
makeDirectory = (directory) => fs.mkdirSync(directory, { recursive: true }),
outputDirectory = path.resolve(__dirname, "../../../../test-results/live-youtube/shorts-navigation"),
writeFile = (filePath, contents) => fs.writeFileSync(filePath, contents, "utf8"),
} = {},
) {
const hopCount = options.navigation?.shortsNextHops;
assert.ok(
Number.isSafeInteger(hopCount) && hopCount >= MIN_LIVE_SHORTS_NEXT_HOPS,
`The Shorts navigation smoke requires at least ${MIN_LIVE_SHORTS_NEXT_HOPS} successful Next samples.`,
);
const captureShortsVisual = {
"native-pair": (videoId, screenshotPath) => driver.captureNativeShortsVisual(videoId, screenshotPath),
"strict-synthetic": (videoId, screenshotPath) => driver.captureSyntheticShortsVisual(videoId, screenshotPath),
}[options.capabilities?.shortsVisualModel];
assert.equal(
typeof captureShortsVisual,
"function",
`Unsupported live Shorts visual model: ${options.capabilities?.shortsVisualModel ?? "missing"}.`,
);
makeDirectory(outputDirectory);
return driver.withNoProductionInteractions(async () => {
const initialNavigation = await driver.navigateFromColdChannelToShort(
options.navigation.channelUrl,
options.navigation.short,
);
return { initial, next };
await assertLivePreconditions(driver, options);
const visitedVideoIds = [options.navigation.short];
const hops = [];
const skipped = [];
let previousVideoId = options.navigation.short;
const recordBlankSample = async (navigation, source, attemptNumber) => {
const evidenceName = `${options.runtime}-shorts-blank-${source}-${String(attemptNumber).padStart(2, "0")}-${
navigation.videoId
}`;
const screenshotPath = path.join(outputDirectory, `${evidenceName}.png`);
const inventoryPath = path.join(outputDirectory, `${evidenceName}.json`);
const diagnostics = await driver.captureBlankShortsDiagnostics(navigation.videoId, screenshotPath);
if (diagnostics.nativeControlsAfterEvidence.status !== "blank") {
navigation.nativeControls = diagnostics.nativeControlsAfterEvidence;
driver.reportProgress("shorts-sample.recovered-after-evidence", {
attemptNumber,
source,
status: diagnostics.nativeControlsAfterEvidence.status,
videoId: navigation.videoId,
});
return null;
}
const record = {
attemptNumber,
durationMs: navigation.nativeControls.observedForMs,
inventoryPath,
reason: navigation.nativeControls.reason,
screenshotPath,
source,
videoId: navigation.videoId,
votesRequestObserved: navigation.request !== null,
};
writeFile(
inventoryPath,
`${JSON.stringify(
{
diagnostics,
nativeControls: navigation.nativeControls,
sample: record,
},
null,
2,
)}\n`,
);
skipped.push(record);
driver.reportBlankShortsSample(record);
return record;
};
const assertReadyNavigation = (navigation, label) => {
assert.equal(
navigation?.nativeControls?.status,
"present",
`${label} did not provide a native Shorts control classification.`,
);
assert.equal(navigation.status, 200, `${label} did not receive HTTP 200 for ${navigation.videoId}.`);
assert.equal(navigation.request?.method, "GET", `${label} did not issue a GET /votes request.`);
assert.equal(
navigation.request?.videoId,
navigation.videoId,
`${label} requested votes for stale video ${navigation.request?.videoId} instead of ${navigation.videoId}.`,
);
assert.ok(
Number.isSafeInteger(navigation.body?.dislikes) && navigation.body.dislikes >= 0,
`${label} has no valid production dislike count.`,
);
};
let initial = null;
let initialStability = null;
let initialVisual = null;
if (initialNavigation.nativeControls?.status === "blank") {
await recordBlankSample(initialNavigation, "channel", 0);
}
if (initialNavigation.nativeControls?.status !== "blank") {
assertReadyNavigation(initialNavigation, "The initial channel-to-Short transition");
initial = await driver.assertCurrentShortsControl(options.navigation.short, options.runtime, {
expectedDislikes: initialNavigation.body.dislikes,
});
initialStability = await driver.soakCurrentShortsControl(
options.navigation.short,
options.runtime,
initialNavigation.body.dislikes,
);
initialVisual = await captureShortsVisual(
options.navigation.short,
path.join(outputDirectory, `${options.runtime}-shorts-hop-00-${options.navigation.short}.png`),
);
}
const maximumNextAttempts = hopCount * 2;
const expectedValidSamples = hopCount + (initial === null ? 0 : 1);
const maximumBlankSamples = expectedValidSamples - 1;
let attemptedNextHops = 0;
while (hops.length < hopCount && attemptedNextHops < maximumNextAttempts && skipped.length <= maximumBlankSamples) {
attemptedNextHops += 1;
const navigation = await driver.navigateToNextShort(previousVideoId);
const { videoId } = navigation;
assert.match(
videoId,
/^[A-Za-z0-9_-]{11}$/,
`Shorts Next attempt ${attemptedNextHops} produced an invalid video ID.`,
);
assert.ok(
!visitedVideoIds.includes(videoId),
`Shorts Next attempt ${attemptedNextHops} revisited an earlier video ${videoId}.`,
);
visitedVideoIds.push(videoId);
previousVideoId = videoId;
if (navigation.nativeControls?.status === "blank") {
const skippedNavigation = await recordBlankSample(navigation, "next", attemptedNextHops);
if (skippedNavigation !== null) continue;
}
const hopNumber = hops.length + 1;
const label = `Shorts successful Next sample ${hopNumber} (attempt ${attemptedNextHops})`;
assertReadyNavigation(navigation, label);
const { body, request, status } = navigation;
await driver.assertRuntime(options.runtime, options.expectedVersion, options.expectedBuildId);
const control = await driver.assertCurrentShortsControl(videoId, options.runtime, {
expectedDislikes: body.dislikes,
});
assert.equal(control.videoId, videoId, `${label} returned a stale control owner.`);
assert.equal(control.syntheticVideoId, videoId, `${label} retained a stale synthetic ID.`);
assert.equal(
control.activeActionBarSyntheticControls,
1,
`${label} has duplicate controls in its active action stack.`,
);
assert.equal(control.currentReelSyntheticControls, 1, `${label} has duplicate controls in its current reel.`);
assert.equal(control.visibleDocumentSyntheticControls, 1, `${label} has duplicate visible controls.`);
assert.equal(control.visibleStaleSyntheticControls, 0, `${label} has a visible stale-video control.`);
assert.match(control.count, /\p{Number}/u, `${label} did not render its dislike count.`);
const visual = await captureShortsVisual(
videoId,
path.join(
outputDirectory,
`${options.runtime}-shorts-hop-${String(hopNumber).padStart(2, "0")}-${videoId}.png`,
),
);
const stability = await driver.soakCurrentShortsControl(videoId, options.runtime, body.dislikes);
hops.push({
attemptNumber: attemptedNextHops,
body,
control,
nativeControls: navigation.nativeControls,
request,
stability,
status,
videoId,
visual,
});
}
assert.equal(
hops.length,
hopCount,
`The Shorts navigation smoke required ${hopCount} successful Next samples, but found ${hops.length} valid and ${skipped.length} blank samples across ${attemptedNextHops} Next attempts (limit ${maximumNextAttempts}).`,
);
const validSampleCount = hops.length + (initial === null ? 0 : 1);
assert.ok(
validSampleCount > skipped.length,
`The Shorts navigation smoke found too many broken YouTube samples: ${validSampleCount} valid versus ${skipped.length} blank.`,
);
await driver.pausePlayback();
return {
attemptedNextHops,
hops,
initial,
initialNavigation,
initialStability,
initialVisual,
maximumNextAttempts,
outputDirectory,
skipped,
successfulNextSamples: hops.length,
validSampleCount,
visitedVideoIds,
};
});
}
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.");
throw new Error("RYD_LIVE_NAV_WATCH is required for the channel-to-watch scenario.");
}
return driver.withNoProductionInteractions(async () => {
await driver.navigateFromColdChannelToWatch(options.navigation.channelUrl, options.navigation.watch);
const navigation = await driver.navigateFromColdChannelToWatch(
options.navigation.channelUrl,
options.navigation.watch,
);
await assertLivePreconditions(driver, options);
const count = await driver.waitForDislikeText();
return { count, videoId: options.navigation.watch };
return driver.assertCurrentWatchResult(options.navigation.watch, options.runtime, navigation.body);
});
}
@@ -186,6 +582,16 @@ async function runResponsiveVisualScenario(
outputDirectory = path.resolve(__dirname, "../../../../test-results/live-youtube/responsive"),
} = {},
) {
const shortsVisualModel = options.capabilities?.shortsVisualModel;
const captureShortsVisual = {
"native-pair": (screenshotPath) => driver.captureNativeShortsVisual(options.short, screenshotPath),
"strict-synthetic": (screenshotPath) => driver.captureSyntheticShortsVisual(options.short, screenshotPath),
}[shortsVisualModel];
assert.equal(
typeof captureShortsVisual,
"function",
`Unsupported live Shorts visual model: ${shortsVisualModel ?? "missing"}.`,
);
makeDirectory(outputDirectory);
return driver.withNoProductionInteractions(async () => {
const originalViewport = await driver.readViewportSize();
@@ -194,34 +600,38 @@ async function runResponsiveVisualScenario(
try {
await driver.setViewportSize(RESPONSIVE_VIEWPORTS[0]);
await driver.openWatch(options.watchA);
const watchNavigation = 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();
const count = await driver.waitForDislikeText();
await driver.assertRenderedDislikeCount(count, watchNavigation.body.dislikes, options.runtime);
watch.push(
await driver.captureWatchRatioVisual(
options.runtime,
path.join(outputDirectory, `${options.runtime}-watch-ratio-${viewport.width}.png`),
{ expectedCounts: watchNavigation.body, expectedVideoId: options.watchA },
),
);
}
await driver.setViewportSize(RESPONSIVE_VIEWPORTS[0]);
await driver.openShort(options.short);
const shortNavigation = 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 control = await driver.assertCurrentShortsControl(options.short, options.runtime, {
expectedDislikes: shortNavigation.body.dislikes,
});
const count = await driver.waitForDislikeText();
await driver.assertRenderedDislikeCount(count, shortNavigation.body.dislikes, options.runtime);
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),
);
shorts.push({
control,
visual: await captureShortsVisual(screenshotPath),
});
}
} finally {
await driver.setViewportSize(originalViewport);
@@ -333,24 +743,40 @@ async function runReactionCycle(
options,
{ beforeFirstAction, captureReactionVisual = null, isShort = false, videoId },
) {
if (isShort) await driver.openShort(videoId);
else await driver.openWatch(videoId);
const navigation = isShort ? await driver.openShort(videoId) : await driver.openWatch(videoId);
assertExpectedWatchCounts(navigation?.body);
await assertLivePreconditions(driver, options);
await driver.waitForDislikeText();
const baselineCount = await driver.waitForDislikeText();
await driver.assertRenderedDislikeCount(baselineCount, navigation.body.dislikes, options.runtime);
const initialState = await driver.readReactionState();
const actions = REACTION_CYCLES[initialState];
assert.ok(actions, `Unsupported initial YouTube reaction state: ${initialState}`);
let projectedState = initialState;
let projectedCounts = { dislikes: navigation.body.dislikes, likes: navigation.body.likes };
const observableDislikeChanges = [];
for (const action of actions) {
const transition = resolveLiveVoteTransition(projectedState, action);
const nextCounts = applyVoteTransitionCounts(projectedCounts.likes, projectedCounts.dislikes, transition);
if (transition.dislikesDelta !== 0) {
observableDislikeChanges.push({ after: nextCounts.dislikes, before: projectedCounts.dislikes });
}
projectedCounts = nextCounts;
projectedState = transition.nextState;
}
await driver.assertDislikeCountChangesObservable(observableDislikeChanges, options.runtime);
let authorized = false;
let completed = false;
let expectedUserId;
let failedAttempt;
let currentState = initialState;
const initialCounts = { dislikes: navigation.body.dislikes, likes: navigation.body.likes };
let expectedCounts = { ...initialCounts };
const evidencePaths = [];
const captureEvidence = async (index) => {
if (!captureReactionVisual) return;
const screenshotPath = await captureReactionVisual({ index, state: currentState });
const screenshotPath = await captureReactionVisual({ counts: { ...expectedCounts }, index, state: currentState });
assert.equal(typeof screenshotPath, "string", "The reaction visual capture did not return an evidence path.");
evidencePaths.push(screenshotPath);
};
@@ -363,9 +789,11 @@ async function runReactionCycle(
for (let index = 0; index < actions.length; index += 1) {
const action = actions[index];
const expectedState = nextReactionState(currentState, action);
const transition = resolveLiveVoteTransition(currentState, action);
const expectedState = transition.nextState;
const nextCounts = applyVoteTransitionCounts(expectedCounts.likes, expectedCounts.dislikes, transition);
const mark = recorder.mark();
const value = reactionValue(expectedState);
const value = transition.value;
failedAttempt = { mark, userId: undefined, value };
await driver.clickAction(videoId, action);
await driver.waitForReactionState(expectedState);
@@ -375,13 +803,15 @@ async function runReactionCycle(
expectedUserId = userId;
failedAttempt = undefined;
currentState = expectedState;
await driver.waitForDislikeText();
expectedCounts = nextCounts;
const renderedCount = await driver.waitForDislikeText();
await driver.assertRenderedDislikeCount(renderedCount, expectedCounts.dislikes, options.runtime);
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 };
return { evidencePaths, finalCounts: expectedCounts, initialCounts, initialState, userId: expectedUserId };
} finally {
if (authorized && !completed) {
if (failedAttempt && !failedAttempt.userId && typeof recorder.voteUserId === "function") {
@@ -416,16 +846,19 @@ async function runProductionReactionMatrixScenario(
const outputDirectory =
visualOptions.outputDirectory ??
path.resolve(__dirname, "../../../../test-results/live-youtube/reactions", options.runtime);
const reactionShort = options.reactionShort ?? options.short;
makeDirectory(outputDirectory);
const captureFor =
(kind, videoId, isShort) =>
async ({ index, state }) => {
async ({ counts, index, state }) => {
const screenshotPath = path.join(outputDirectory, `${kind}-${index}-${state}.png`);
const evidence = await driver.captureReactionStateVisual({
expectedCounts: counts,
expectedState: state,
isShort,
runtime: options.runtime,
screenshotPath,
shortsVisualModel: options.capabilities?.shortsVisualModel,
videoId,
});
assert.equal(
@@ -448,12 +881,12 @@ async function runProductionReactionMatrixScenario(
watchRecorder.stop();
}
const shortRecorder = createRecorder(options.short);
const shortRecorder = createRecorder(reactionShort);
try {
const shortResult = await runReactionCycle(driver, shortRecorder, options, {
captureReactionVisual: captureFor("short", options.short, true),
captureReactionVisual: captureFor("short", reactionShort, true),
isShort: true,
videoId: options.short,
videoId: reactionShort,
});
assert.equal(
shortResult.userId,
@@ -471,6 +904,91 @@ async function runProductionReactionMatrixScenario(
}
}
async function runPostNavigationVoteScenario(driver, options, createRecorder, consumeVoteApproval) {
const recorder = createRecorder(options.watchB);
try {
await driver.openPlaylist(options.playlistUrl, options.watchA);
await assertLivePreconditions(driver, options);
driver.assertCurrentVideo(options.watchA);
const watchACount = await driver.waitForDislikeText();
const navigationMark = recorder.mark();
await driver.navigateWithinPlaylist(options.watchB);
await assertLivePreconditions(driver, options);
driver.assertCurrentVideo(options.watchB);
const watchBCount = await driver.waitForDislikeText({ differentFrom: watchACount });
assert.notEqual(
watchBCount,
watchACount,
"Choose allowlisted playlist videos whose rendered dislike counts differ so stale SPA UI can be detected.",
);
assert.equal(
recorder.mark(),
navigationMark,
"SPA navigation emitted unexpected production interaction traffic before any reaction click.",
);
const initialState = await driver.readReactionState();
const action = "dislike";
const selectedState = nextReactionState(initialState, action);
const selectedValue = reactionValue(selectedState);
const voteMark = navigationMark;
let authorized = false;
let failedAttempt = { mark: voteMark, userId: undefined, value: selectedValue };
let userId;
try {
driver.assertCurrentVideo(options.watchB);
await assertLivePreconditions(driver, options);
await consumeVoteApproval();
authorized = true;
await driver.clickAction(options.watchB, action);
await driver.waitForReactionState(selectedState);
userId = await recorder.waitForHandshake(selectedValue, voteMark);
failedAttempt = undefined;
} finally {
if (authorized) {
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,
options.watchB,
initialState,
userId,
false,
failedAttempt,
);
}
}
driver.assertCurrentVideo(options.watchB);
assert.equal(
await driver.readReactionState(),
initialState,
"The post-navigation vote scenario did not restore the destination video's initial reaction state.",
);
await driver.waitForDislikeText();
return {
action,
initialState,
selectedState,
userId,
videoId: options.watchB,
watchACount,
watchBCount,
};
} finally {
recorder.stop();
}
}
async function restoreNeutralStateUnchecked(driver, recorder, options, dislikeMark, expectedUserId) {
let state;
try {
@@ -554,8 +1072,10 @@ async function runReversibleVoteScenario(driver, recorder, options, consumeVoteA
module.exports = {
RESPONSIVE_VIEWPORTS,
WATCH_ACTION_TOPOLOGY_VIEWPORTS,
runChannelShortsNavigationScenario,
runChannelWatchNavigationScenario,
runPostNavigationVoteScenario,
runProductionReactionMatrixScenario,
runReactionCycle,
runReloadScenario,
@@ -565,4 +1085,5 @@ module.exports = {
runShortsRenderScenario,
runSpaNavigationScenario,
runWatchRenderScenario,
runWatchActionTopologyScenario,
};
File diff suppressed because it is too large Load Diff
@@ -4,39 +4,79 @@ const {
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 { readLiveOptions } = require("../../live/live-options");
const { selectAuthenticatedYoutubeContext } = require("./live-authenticated-context");
const { LiveReadOnlyGate, LiveRunDiagnostics, runLoggedStage } = require("./live-diagnostics");
const { reloadLiveExtensionInBrowser } = require("./live-extension-reloader");
const { REQUIRED_INTERACTIVE_READ_ONLY_SCENARIO_IDS } = require("./live-interactive-scenario-contract");
const { LiveYoutubeDriver } = require("./live-youtube-driver");
const options = readLiveOptions();
const scenarioRunner = createSharedLiveScenarioRunner();
const REQUIRED_READ_ONLY_SCENARIOS = REQUIRED_INTERACTIVE_READ_ONLY_SCENARIO_IDS;
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" });
if (options === null) {
throw new Error(
"No live YouTube scenarios ran. Set RYD_LIVE_YOUTUBE=1 and every documented runtime/allowlist variable.",
);
}
test.describe("live YouTube RYD read-only smoke", () => {
let browser;
let context;
let diagnostics;
let page;
let driver;
let runtimeAdapter;
let selectedExtensionId = null;
let sessionPage;
let currentReadOnlyScenarioId = null;
const readOnlyGate = new LiveReadOnlyGate(REQUIRED_READ_ONLY_SCENARIOS);
test.beforeAll(async () => {
test.beforeAll(async ({}, testInfo) => {
testInfo.setTimeout(options.cdpConnectTimeoutMilliseconds + 15_000);
try {
browser = await chromium.connectOverCDP(options.cdpEndpoint, {
isLocal: true,
noDefaults: true,
timeout: 15_000,
timeout: options.cdpConnectTimeoutMilliseconds,
});
} 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}`,
`Could not attach to the running Chrome/Chromium profile. Enable remote debugging in the target browser and set RYD_CDP_ENDPOINT to its WebSocket, HTTP, host:port, or browser-alias endpoint, then retry. ${error.message}`,
);
}
[context] = browser.contexts();
if (!context) throw new Error("The attached Brave/Chromium browser has no default context.");
const selectedSession = await selectAuthenticatedYoutubeContext(browser, options.expectedChannel);
context = selectedSession.context;
sessionPage = selectedSession.sessionPage;
console.log(
`AUTHENTICATED_CHROMIUM_CONTEXT_SELECTED context=${selectedSession.contextIndex + 1} page=${selectedSession.pageIndex + 1} handle=${options.expectedChannel}`,
);
if (options.runtime === "extension") {
const reloaded = await reloadLiveExtensionInBrowser({
browser,
context,
expectedBuildId: options.expectedBuildId,
expectedVersion: options.expectedVersion,
sessionPage,
});
selectedExtensionId = reloaded.extensionId;
console.log(`LIVE_EXTENSION_RELOADED ${JSON.stringify(reloaded)}`);
}
});
test.beforeEach(async () => {
currentReadOnlyScenarioId = null;
page = await context.newPage();
driver = new LiveYoutubeDriver(page, context);
diagnostics = new LiveRunDiagnostics(page, context, { runtime: options.runtime, selectedExtensionId });
await diagnostics.start();
driver = new LiveYoutubeDriver(page, context, {
authenticatedHandle: options.expectedChannel,
expectedBuildId: options.expectedBuildId,
reportProgress: (name, details) => diagnostics.checkpoint(name, details),
selectedExtensionId,
});
const createAdapter =
options.runtime === "extension" ? createExtensionLiveRuntimeAdapter : createUserscriptLiveRuntimeAdapter;
runtimeAdapter = createAdapter({
@@ -46,68 +86,119 @@ test.describe("live YouTube RYD smoke", () => {
});
});
test.afterEach(async ({}, testInfo) => {
if (!diagnostics) return;
let lateSignalError = null;
let snapshotError = null;
try {
if (driver?.trafficLedger) {
await driver.assertNoUnclaimedAttributedTraffic(`Playwright scenario ${testInfo.title}`);
}
} catch (error) {
lateSignalError = error;
}
try {
diagnostics.consumeFatalSignals(`after ${testInfo.title}`);
} catch (error) {
lateSignalError = lateSignalError
? new AggregateError([lateSignalError, error], "Late traffic and browser diagnostics both failed.")
: error;
}
try {
if (lateSignalError || testInfo.status !== testInfo.expectedStatus) {
const error =
lateSignalError ?? testInfo.errors.at(-1) ?? new Error(`${testInfo.title} failed without an attached error.`);
const snapshotPath = await diagnostics.persistFailureSnapshot(error);
await testInfo.attach("live-failure-snapshot", { contentType: "application/json", path: snapshotPath });
}
} catch (error) {
snapshotError = error;
} finally {
if (currentReadOnlyScenarioId) {
const completed = !lateSignalError && !snapshotError && testInfo.status === "passed";
readOnlyGate.record(currentReadOnlyScenarioId, completed ? testInfo.status : "failed");
}
driver?.stop();
diagnostics.stop();
if (page && !page.isClosed()) await page.close();
diagnostics = null;
driver = null;
page = null;
runtimeAdapter = null;
}
if (lateSignalError && snapshotError) {
throw new AggregateError(
[lateSignalError, snapshotError],
"A late fatal browser signal and snapshot failure occurred.",
);
}
if (lateSignalError) throw lateSignalError;
if (snapshotError) throw snapshotError;
});
test.afterAll(async () => {
if (page && !page.isClosed()) await page.close();
if (browser) await browser.close();
try {
readOnlyGate.assertPassed();
console.log(
`LIVE_VALIDATION_READ_ONLY_COMPLETE ${JSON.stringify({
classification: "read-only",
productionReactionsCompleted: false,
releaseReady: false,
})}`,
);
} finally {
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.",
const runScenario = (scenarioId, services) =>
runLoggedStage(diagnostics, `playwright.${scenarioId}`, () =>
scenarioRunner.run(runtimeAdapter, scenarioId, options, services),
);
await scenarioRunner.run(runtimeAdapter, "channel-watch-navigation", options);
const runReadOnlyScenario = (scenarioId) => {
currentReadOnlyScenarioId = scenarioId;
return runScenario(scenarioId);
};
test(`${options?.runtime || "runtime"}: renders on an allowlisted Short`, async () => {
await runReadOnlyScenario("shorts-render");
});
test(`${options?.runtime || "runtime"}: audits ten-plus successful Shorts Next-video SPA samples`, async () => {
test.setTimeout(120_000 + options.navigation.shortsNextHops * 2 * 45_000);
await runReadOnlyScenario("channel-shorts-navigation");
});
test(`${options?.runtime || "runtime"}: initializes after cold channel-to-watch SPA navigation`, async () => {
test.setTimeout(120_000);
await runReadOnlyScenario("channel-watch-navigation");
});
test(`${options?.runtime || "runtime"}: renders on a signed-in watch page`, async () => {
await scenarioRunner.run(runtimeAdapter, "watch-render", options);
await runReadOnlyScenario("watch-render");
});
test(`${options?.runtime || "runtime"}: reinitializes after reload`, async () => {
await scenarioRunner.run(runtimeAdapter, "reload", options);
await runReadOnlyScenario("reload");
});
test(`${options?.runtime || "runtime"}: reinitializes after real playlist SPA navigation`, async () => {
await scenarioRunner.run(runtimeAdapter, "spa-navigation", options);
await runReadOnlyScenario("spa-navigation");
});
test(`${options?.runtime || "runtime"}: preserves the complete Watch action topology`, async () => {
test.setTimeout(240_000);
await runReadOnlyScenario("watch-action-topology");
});
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);
await runReadOnlyScenario("sidebar-navigation-stress");
});
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.");
}
},
});
await runReadOnlyScenario("responsive-visual");
});
});
@@ -0,0 +1,63 @@
const fs = require("node:fs");
const path = require("node:path");
const { chromium } = require("@playwright/test");
const { resolveCdpEndpoint } = require("../../live/live-cdp-endpoint");
const { selectAuthenticatedYoutubeContext } = require("./live-authenticated-context");
const { LIVE_EXTENSION_PATH, reloadLiveExtensionInBrowser } = require("./live-extension-reloader");
const extensionPath = LIVE_EXTENSION_PATH;
function readJson(filePath) {
return JSON.parse(fs.readFileSync(filePath, "utf8"));
}
function connectTimeoutMilliseconds(environment) {
const configured = Number(environment.RYD_CDP_CONNECT_TIMEOUT_MS || 120_000);
if (!Number.isSafeInteger(configured) || configured < 15_000 || configured > 300_000) {
throw new Error("RYD_CDP_CONNECT_TIMEOUT_MS must be an integer from 15000 through 300000.");
}
return configured;
}
async function main() {
const expectedBuildId = readJson(path.join(extensionPath, "live-build.json")).buildId;
const expectedVersion = readJson(path.join(extensionPath, "manifest.json")).version;
const timeoutMilliseconds = connectTimeoutMilliseconds(process.env);
const cdpEndpoint = resolveCdpEndpoint(process.env.RYD_CDP_ENDPOINT);
let browser;
try {
browser = await chromium.connectOverCDP(cdpEndpoint, {
isLocal: true,
noDefaults: true,
timeout: timeoutMilliseconds,
});
const selectedSession = await selectAuthenticatedYoutubeContext(browser, process.env.RYD_LIVE_EXPECTED_CHANNEL);
const { context, sessionPage } = selectedSession;
console.log(
`AUTHENTICATED_CHROMIUM_CONTEXT_SELECTED context=${selectedSession.contextIndex + 1} page=${selectedSession.pageIndex + 1} handle=${process.env.RYD_LIVE_EXPECTED_CHANNEL}`,
);
const result = await reloadLiveExtensionInBrowser({
browser,
context,
expectedBuildId,
expectedExtensionPath: extensionPath,
expectedVersion,
sessionPage,
timeoutMilliseconds,
});
console.log(`LIVE_EXTENSION_RELOADED ${JSON.stringify(result)}`);
} finally {
if (browser) await browser.close();
}
}
if (require.main === module) {
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
}
module.exports = { connectTimeoutMilliseconds, main };
@@ -0,0 +1,236 @@
const assert = require("node:assert/strict");
const WATCH_NEGATIVE_TRACK_MINIMUM_CONTRAST = 3;
const WATCH_RATIO_GEOMETRY_TOLERANCE = 0.015;
const WATCH_RATIO_INLINE_TOLERANCE = 0.000_1;
function normalizeTooltipText(value) {
return String(value ?? "")
.normalize("NFKC")
.replace(/[\u200e\u200f\u202a-\u202e\u2066-\u2069]/giu, "")
.replace(/\s+/gu, " ")
.trim();
}
function formattedWatchTooltipCandidates(expectedCounts, locale) {
assertExpectedWatchCounts(expectedCounts);
const formatter = new Intl.NumberFormat(locale || undefined);
const total = expectedCounts.likes + expectedCounts.dislikes;
const widthPercent = total > 0 ? (expectedCounts.likes / total) * 100 : 50;
const likePercentageValue = Number.parseFloat(widthPercent.toFixed(1));
const dislikePercentageValue = 100 - likePercentageValue;
const likes = formatter.format(expectedCounts.likes);
const dislikes = formatter.format(expectedCounts.dislikes);
const likePercentage = formatter.format(likePercentageValue);
const dislikePercentage = formatter.format(dislikePercentageValue);
const countPair = `${likes} / ${dislikes}`;
return [
{ form: "counts", text: countPair },
{ form: "counts-with-like-percentage", text: `${countPair} - ${likePercentage}%` },
{ form: "counts-with-dislike-percentage", text: `${countPair} - ${dislikePercentage}%` },
{ form: "both-percentages", text: `${likePercentage}% / ${dislikePercentage}%` },
{ form: "like-percentage", text: `${likePercentage}%` },
{ form: "dislike-percentage", text: `${dislikePercentage}%` },
];
}
function clampColorChannel(value) {
return Math.min(255, Math.max(0, value));
}
function parseAlpha(value) {
if (value === undefined) return 1;
const parsed = value.endsWith("%") ? Number.parseFloat(value) / 100 : Number.parseFloat(value);
return Number.isFinite(parsed) ? Math.min(1, Math.max(0, parsed)) : null;
}
function parseRgbChannel(value) {
const parsed = value.endsWith("%") ? (Number.parseFloat(value) / 100) * 255 : Number.parseFloat(value);
return Number.isFinite(parsed) ? clampColorChannel(parsed) : null;
}
function parseCssColor(value) {
const color = String(value ?? "")
.trim()
.toLowerCase();
if (!color || color === "transparent") return color === "transparent" ? { a: 0, b: 0, g: 0, r: 0 } : null;
const hex = color.match(/^#([a-f0-9]{3,8})$/iu)?.[1];
if (hex) {
const expanded = hex.length === 3 || hex.length === 4 ? [...hex].map((digit) => digit + digit).join("") : hex;
if (expanded.length !== 6 && expanded.length !== 8) return null;
return {
a: expanded.length === 8 ? Number.parseInt(expanded.slice(6, 8), 16) / 255 : 1,
b: Number.parseInt(expanded.slice(4, 6), 16),
g: Number.parseInt(expanded.slice(2, 4), 16),
r: Number.parseInt(expanded.slice(0, 2), 16),
};
}
const rgb = color.match(/^rgba?\((.*)\)$/iu)?.[1];
if (rgb) {
const commaParts = rgb.includes(",") ? rgb.split(",").map((part) => part.trim()) : null;
const legacyAlpha = commaParts?.length === 4 ? commaParts.pop() : undefined;
const normalized = (commaParts ?? [rgb]).join(" ").replace(/\s*\/\s*/gu, " / ");
const [channelsPart, modernAlpha] = normalized.split(/\s+\/\s+/u);
const channels = channelsPart.trim().split(/\s+/u);
if (channels.length !== 3) return null;
const [r, g, b] = channels.map(parseRgbChannel);
const a = parseAlpha(modernAlpha ?? legacyAlpha);
return [r, g, b, a].every((channel) => channel !== null) ? { a, b, g, r } : null;
}
const srgb = color.match(/^color\(srgb\s+(.+)\)$/iu)?.[1];
if (srgb) {
const [channelsPart, alphaPart] = srgb.split(/\s+\/\s+/u);
const channels = channelsPart.trim().split(/\s+/u).map(Number.parseFloat);
const a = parseAlpha(alphaPart);
if (channels.length !== 3 || !channels.every(Number.isFinite) || a === null) return null;
return {
a,
b: clampColorChannel(channels[2] * 255),
g: clampColorChannel(channels[1] * 255),
r: clampColorChannel(channels[0] * 255),
};
}
return null;
}
function compositeColor(foreground, background) {
const alpha = foreground.a + background.a * (1 - foreground.a);
if (alpha === 0) return { a: 0, b: 0, g: 0, r: 0 };
const channel = (key) =>
(foreground[key] * foreground.a + background[key] * background.a * (1 - foreground.a)) / alpha;
return { a: alpha, b: channel("b"), g: channel("g"), r: channel("r") };
}
function relativeLuminance(color) {
const linear = (channel) => {
const value = channel / 255;
return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
};
return linear(color.r) * 0.2126 + linear(color.g) * 0.7152 + linear(color.b) * 0.0722;
}
function colorContrastRatio(first, second) {
const firstLuminance = relativeLuminance(first);
const secondLuminance = relativeLuminance(second);
const lighter = Math.max(firstLuminance, secondLuminance);
const darker = Math.min(firstLuminance, secondLuminance);
return (lighter + 0.05) / (darker + 0.05);
}
function assertExpectedWatchCounts(expectedCounts) {
assert.ok(expectedCounts && typeof expectedCounts === "object", "Exact Watch API counts are required.");
for (const key of ["likes", "dislikes"]) {
assert.ok(
Number.isSafeInteger(expectedCounts[key]) && expectedCounts[key] >= 0,
`The Watch API ${key} count must be a non-negative integer.`,
);
}
return expectedCounts;
}
function assertWatchRatioData(
measurement,
{
expectedCounts = null,
expectedTooltipCandidates = [],
minimumNegativeTrackContrast = WATCH_NEGATIVE_TRACK_MINIMUM_CONTRAST,
runtime,
videoId,
},
) {
assert.ok(measurement?.appearance, "The Watch ratio-bar appearance audit is missing.");
assert.equal(
measurement.appearance.wrapperVideoId,
videoId,
`The ${runtime} Watch ratio bar is owned by ${measurement.appearance.wrapperVideoId ?? "no video"}, not ${videoId}.`,
);
const pageBackground = parseCssColor(measurement.appearance.pageBackgroundColor);
const negativeTrack = parseCssColor(measurement.appearance.negativeTrackColor);
assert.ok(
pageBackground,
`Cannot resolve the Watch page background color: ${measurement.appearance.pageBackgroundColor}.`,
);
assert.ok(
negativeTrack,
`Cannot resolve the Watch negative-track color: ${measurement.appearance.negativeTrackColor}.`,
);
assert.equal(
measurement.appearance.negativeTrackBackgroundImage ?? "none",
"none",
"The Watch negative track uses a background image whose effective contrast cannot be audited.",
);
assert.ok(pageBackground.a >= 0.99, "The resolved Watch page background is not opaque enough for a contrast audit.");
const negativeTrackOpacity = Number(measurement.appearance.negativeTrackOpacity ?? 1);
assert.ok(
Number.isFinite(negativeTrackOpacity) && negativeTrackOpacity >= 0 && negativeTrackOpacity <= 1,
`The Watch negative-track effective opacity is invalid: ${measurement.appearance.negativeTrackOpacity}.`,
);
negativeTrack.a *= negativeTrackOpacity;
const effectiveNegativeTrack = compositeColor(negativeTrack, pageBackground);
const negativeTrackContrast = colorContrastRatio(effectiveNegativeTrack, pageBackground);
assert.ok(
negativeTrack.a > 0 && negativeTrackContrast >= minimumNegativeTrackContrast,
`The Watch negative track is effectively invisible against the page background: contrast ${negativeTrackContrast.toFixed(2)}:1 is below ${minimumNegativeTrackContrast}:1.`,
);
const result = { negativeTrackContrast, wrapperVideoId: measurement.appearance.wrapperVideoId };
if (expectedCounts === null) return result;
assertExpectedWatchCounts(expectedCounts);
const total = expectedCounts.likes + expectedCounts.dislikes;
const expectedRatio = total > 0 ? expectedCounts.likes / total : 0.5;
const inlineRatio = Number.parseFloat(measurement.appearance.inlineFillWidth) / 100;
assert.ok(Number.isFinite(inlineRatio), "The Watch ratio fill has no numeric inline percentage width.");
assert.ok(
Math.abs(inlineRatio - expectedRatio) <= WATCH_RATIO_INLINE_TOLERANCE,
`The Watch ratio fill inline width represents ${(inlineRatio * 100).toFixed(3)}%, expected ${(expectedRatio * 100).toFixed(3)}% from exact API counts.`,
);
const { bar, container } = measurement.geometry ?? {};
assert.ok(container?.width > 0 && bar?.width >= 0, "The Watch ratio geometry cannot represent an exact ratio.");
const renderedRatio = bar.width / container.width;
const pixelTolerance = 1 / container.width;
assert.ok(
Math.abs(renderedRatio - expectedRatio) <= Math.max(WATCH_RATIO_GEOMETRY_TOLERANCE, pixelTolerance),
`The rendered Watch ratio is ${(renderedRatio * 100).toFixed(2)}%, expected ${(expectedRatio * 100).toFixed(2)}% from exact API counts.`,
);
const normalizedTooltip = normalizeTooltipText(measurement.appearance.tooltipText);
const normalizedCandidates = expectedTooltipCandidates.map((candidate) => ({
form: typeof candidate === "string" ? "unspecified" : candidate.form,
text: normalizeTooltipText(typeof candidate === "string" ? candidate : candidate.text),
}));
assert.ok(normalizedCandidates.length > 0, "Exact Watch tooltip expectations are required with API counts.");
const tooltipMatch = normalizedCandidates.find((candidate) => candidate.text === normalizedTooltip);
assert.ok(
tooltipMatch,
`The Watch ratio tooltip ${JSON.stringify(normalizedTooltip)} does not exactly match the API counts and configured percentage form.`,
);
return {
...result,
expectedRatio,
inlineRatio,
renderedRatio,
tooltipForm: tooltipMatch.form,
tooltipText: normalizedTooltip,
};
}
module.exports = {
WATCH_NEGATIVE_TRACK_MINIMUM_CONTRAST,
WATCH_RATIO_GEOMETRY_TOLERANCE,
WATCH_RATIO_INLINE_TOLERANCE,
assertExpectedWatchCounts,
assertWatchRatioData,
colorContrastRatio,
formattedWatchTooltipCandidates,
normalizeTooltipText,
parseCssColor,
};
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,77 @@
const { test, expect } = require("@playwright/test");
const {
CREDENTIAL_KEY,
VIDEO_A,
createFakeBackend,
injectGeneratedUserscript,
installGmEnvironment,
installHermeticRoutes,
openWatchFixture,
} = require("./harness");
const EXISTING_CREDENTIALS = {
registrationConfirmed: true,
userId: "ExistingUserscriptCredential00000001",
};
test("the first synchronous click on a replaced current control votes once while a hidden stale control is ignored", async ({
context,
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));
await context.addInitScript(() => {
globalThis.__unhandledRejections = [];
addEventListener("unhandledrejection", (event) => {
globalThis.__unhandledRejections.push(String(event.reason));
});
});
const backend = createFakeBackend();
await installGmEnvironment(context, { [CREDENTIAL_KEY]: EXISTING_CREDENTIALS });
await installHermeticRoutes(context, backend);
await openWatchFixture(page, VIDEO_A);
await injectGeneratedUserscript(page);
const currentDislike = page.locator('[data-ryd-role="buttons"] [data-ryd-role="dislike"]');
await expect(currentDislike.locator("#text")).toHaveText("25");
await expect(page.locator("#return-youtube-dislike-bar-container")).toBeVisible();
await page.evaluate(() => {
const current = document.querySelector('[data-ryd-role="buttons"] [data-ryd-role="dislike"]');
const stale = current.cloneNode(true);
stale.hidden = true;
stale.setAttribute("data-fixture-stale-dislike", "true");
document.body.append(stale);
stale.querySelector("button").click();
});
await page.waitForTimeout(100);
expect(backend.requestsFor("POST", "/interact/vote")).toHaveLength(0);
expect(backend.requestsFor("POST", "/interact/confirmVote")).toHaveLength(0);
await expect(currentDislike.locator("#text")).toHaveText("25");
await page.evaluate(() => {
const current = document.querySelector('[data-ryd-role="buttons"] [data-ryd-role="dislike"]');
const replacement = current.cloneNode(true);
replacement.setAttribute("data-fixture-replaced-current-dislike", "true");
current.replaceWith(replacement);
replacement.querySelector("button").click();
});
await expect.poll(() => backend.requestsFor("POST", "/interact/confirmVote").length).toBe(1);
await expect(page.locator('[data-fixture-replaced-current-dislike="true"] #text')).toHaveText("26");
await page.waitForTimeout(650);
expect(backend.requestsFor("POST", "/interact/vote").map(({ body }) => body)).toEqual([
{ userId: EXISTING_CREDENTIALS.userId, value: -1, videoId: VIDEO_A },
]);
expect(backend.requestsFor("POST", "/interact/confirmVote")).toHaveLength(1);
expect(backend.blockedRequests).toEqual([]);
expect(consoleErrors).toEqual([]);
expect(pageErrors).toEqual([]);
expect(await page.evaluate(() => globalThis.__unhandledRejections)).toEqual([]);
});
@@ -49,8 +49,8 @@ async function expectWatchInitialized(page, videoId, expectedDislikes = COUNTS[v
const expectedCount = String(expectedDislikes);
await expect(page).toHaveURL((url) => url.pathname === "/watch" && url.searchParams.get("v") === videoId);
await expect(page.locator(`ytd-watch-flexy[video-id="${videoId}"]`)).toHaveCount(1);
await expect(page.locator('[data-fixture-page-kind="watch"] [data-ryd-role="buttons"]')).toHaveCount(1);
await expect(page.locator('[data-fixture-page-kind="watch"] [data-ryd-role="dislike"] #text')).toHaveText(
await expect(page.locator('[data-fixture-page-kind="watch"] [data-fixture-role="buttons"]')).toHaveCount(1);
await expect(page.locator('[data-fixture-page-kind="watch"] [data-fixture-role="dislike"] #text')).toHaveText(
expectedCount,
);
await expect(page.locator("#return-youtube-dislike-bar-container")).toHaveCount(1);
@@ -66,7 +66,7 @@ async function expectShortInitialized(page, videoId, expectedDislikes = COUNTS[v
await expect(page.locator("ytd-shorts")).toHaveCount(1);
await expect(activeRenderer).toHaveCount(1);
await expect(
page.locator('ytd-reel-video-renderer[is-active] reel-action-bar-view-model[data-ryd-role="buttons"]'),
page.locator('ytd-reel-video-renderer[is-active] reel-action-bar-view-model[data-fixture-role="buttons"]'),
).toHaveCount(1);
const syntheticDislike = activeRenderer.locator("[data-ryd-synthetic-shorts-dislike]");
await expect(syntheticDislike).toHaveCount(1);
@@ -85,9 +85,9 @@ async function expectMobileShortInitialized(page, videoId, expectedDislikes = CO
await expect(activeShort).toHaveCount(1);
await expect(activeShort).toBeVisible();
await expect(page.locator("[data-fixture-mobile-short][is-active]:visible")).toHaveCount(1);
await expect(activeShort.locator('ytm-like-button-renderer[data-ryd-role="buttons"]')).toHaveCount(1);
await expect(activeShort.locator('[data-ryd-role="dislike"] #text')).toHaveText(String(expectedDislikes));
await expect(activeShort.locator('[data-ryd-role="dislike"] button')).toBeEnabled();
await expect(activeShort.locator('ytm-like-button-renderer[data-fixture-role="buttons"]')).toHaveCount(1);
await expect(activeShort.locator('[data-fixture-role="dislike"] #text')).toHaveText(String(expectedDislikes));
await expect(activeShort.locator('[data-fixture-role="dislike"] button')).toBeEnabled();
await expect(page.locator("[data-ryd-synthetic-shorts-dislike]")).toHaveCount(0);
await expect(page.locator("#return-youtube-dislike-bar-container")).toHaveCount(0);
}
@@ -95,7 +95,9 @@ async function expectMobileShortInitialized(page, videoId, expectedDislikes = CO
async function expectOneActivation(page, backend, videoId) {
const voteCount = backend.requestsFor("POST", "/interact/vote").length;
const confirmationCount = backend.requestsFor("POST", "/interact/confirmVote").length;
await page.locator('[data-ryd-role="dislike"]:visible button').click();
await page
.locator('[data-fixture-role="dislike"]:visible button, [data-ryd-synthetic-shorts-dislike]:visible button')
.click();
await expect.poll(() => backend.requestsFor("POST", "/interact/confirmVote").length).toBe(confirmationCount + 1);
await page.waitForTimeout(600);
@@ -125,7 +127,7 @@ test("cold channel reload then immediate Short link initializes delayed Shorts c
await page.locator("#channel-short").click();
await expect(page).toHaveURL((url) => url.pathname === `/shorts/${VIDEO_A}`);
const decoyDislikeCount = page.locator('[data-fixture-decoy-controls] [data-ryd-role="dislike"] #text');
const decoyDislikeCount = page.locator('[data-fixture-decoy-controls] [data-fixture-role="dislike"] #text');
await page.waitForTimeout(250);
await expect(decoyDislikeCount).toHaveText("");
await expect(page.locator("[data-ryd-synthetic-shorts-dislike]")).toHaveCount(0);
@@ -320,9 +322,9 @@ test("watch to Short link switches page kind, identity, and controls", async ({
await page.locator("#watch-to-short").click();
await expect(page).toHaveURL((url) => url.pathname === `/shorts/${VIDEO_B}`);
await expect(page.locator('[data-fixture-page-kind="watch"] [data-ryd-role="dislike"] #text')).toHaveText("11");
await expect(page.locator('[data-fixture-page-kind="watch"] [data-fixture-role="dislike"] #text')).toHaveText("11");
await page.waitForTimeout(50);
await expect(page.locator('[data-fixture-page-kind="watch"] [data-ryd-role="dislike"] #text')).toHaveText("11");
await expect(page.locator('[data-fixture-page-kind="watch"] [data-fixture-role="dislike"] #text')).toHaveText("11");
await expect(page.locator("[data-ryd-synthetic-shorts-dislike]")).toHaveCount(0);
await expectShortInitialized(page, VIDEO_B);
@@ -415,8 +417,8 @@ test("delayed mobile Short navigation never binds the outgoing renderer to the t
await expect(outgoingRenderer.locator(`a[href="/shorts/${VIDEO_B}"]`)).toHaveCount(1);
await expect(page.locator(`ytm-reel-video-renderer[video-id="${VIDEO_B}"]`)).toHaveCount(0);
await page.waitForTimeout(600);
await expect(outgoingRenderer.locator('[data-ryd-role="dislike"] #text')).toHaveText("11");
await outgoingRenderer.locator('[data-ryd-role="dislike"] button').click();
await expect(outgoingRenderer.locator('[data-fixture-role="dislike"] #text')).toHaveText("11");
await outgoingRenderer.locator('[data-fixture-role="dislike"] button').click();
await page.waitForTimeout(100);
expect(
harness.backend.requestsFor("GET", "/votes").filter((request) => request.query.videoId === VIDEO_B),
@@ -561,7 +563,7 @@ test("watch SPA navigation refreshes one aligned ratio bar with the target video
await expect(wrapper).toBeVisible();
await expect(container).toBeVisible();
await expect(bar).toBeVisible();
await expect(currentControls.locator('[data-ryd-role="dislike"] #text')).toHaveText("22");
await expect(currentControls.locator('[data-fixture-role="dislike"] #text')).toHaveText("22");
await expect(tooltip).toContainText("200 / 22");
await expect(tooltip).toBeHidden();
@@ -578,8 +580,8 @@ test("watch SPA navigation refreshes one aligned ratio bar with the target video
};
};
const controls = reactionRegion.querySelector("[data-fixture-control-video-id]");
const like = controls.querySelector('[data-ryd-role="like"] button');
const dislike = controls.querySelector('[data-ryd-role="dislike"] button');
const like = controls.querySelector('[data-fixture-role="like"] button');
const dislike = controls.querySelector('[data-fixture-role="dislike"] button');
const ratioWrapper = reactionRegion.querySelector(":scope > .ryd-tooltip");
const ratioContainer = ratioWrapper.querySelector("#return-youtube-dislike-bar-container");
const ratioFill = ratioContainer.querySelector("#return-youtube-dislike-bar");
@@ -651,7 +653,7 @@ test("a pruned current watch ratio bar is restored once with the current video's
await expect(wrapper).toBeVisible();
await expect(container).toBeVisible();
await expect(bar).toBeVisible();
await expect(currentControls.locator('[data-ryd-role="dislike"] #text')).toHaveText("22");
await expect(currentControls.locator('[data-fixture-role="dislike"] #text')).toHaveText("22");
await expect(tooltip).toContainText("200 / 22");
await expect(tooltip).toBeHidden();
expect(await wrapper.evaluate((restoredWrapper) => restoredWrapper !== globalThis.__fixturePrunedWatchBar)).toBe(
@@ -787,8 +789,8 @@ test("delayed watch navigation never binds outgoing controls to the target video
await expect(outgoingControls.locator('[data-fixture-irrelevant-watch-count-mutation="true"]')).toHaveCount(1);
await expect(page.locator('[data-fixture-irrelevant-watch-tooltip-mutation="true"]')).toHaveCount(1);
await page.waitForTimeout(600);
await expect(outgoingControls.locator('[data-ryd-role="dislike"] #text')).toHaveText("11");
await outgoingControls.locator('[data-ryd-role="dislike"] button').click();
await expect(outgoingControls.locator('[data-fixture-role="dislike"] #text')).toHaveText("11");
await outgoingControls.locator('[data-fixture-role="dislike"] button').click();
await page.waitForTimeout(100);
expect(
harness.backend.requestsFor("GET", "/votes").filter((request) => request.query.videoId === VIDEO_B),
@@ -796,7 +798,7 @@ test("delayed watch navigation never binds outgoing controls to the target video
expect(harness.backend.requestsFor("POST", "/interact/vote")).toHaveLength(0);
expect(harness.backend.requestsFor("POST", "/interact/confirmVote")).toHaveLength(0);
const outgoingDislike = outgoingControls.locator('[data-ryd-role="dislike"]');
const outgoingDislike = outgoingControls.locator('[data-fixture-role="dislike"]');
await page.evaluate(() => window.__navigationFixture.replaceDelayedWatchControl("like"));
await expect(outgoingControls.locator('[data-fixture-watch-replacement="like"]')).toHaveCount(1);
await expect(outgoingDislike).toBeVisible();
@@ -826,8 +828,8 @@ test("same-node watch reuse requires independent native refresh on both activati
const harness = await launchNavigationHarness({ context, page }, { pageKind: "watch", videoId: VIDEO_A });
await expectWatchInitialized(page, VIDEO_A);
const reusedControls = page.locator(`[data-fixture-control-video-id="${VIDEO_A}"]`);
const reusedLikeButton = reusedControls.locator('[data-ryd-role="like"] button');
const reusedDislikeButton = reusedControls.locator('[data-ryd-role="dislike"] button');
const reusedLikeButton = reusedControls.locator('[data-fixture-role="like"] button');
const reusedDislikeButton = reusedControls.locator('[data-fixture-role="dislike"] button');
await reusedLikeButton.evaluate((button) => button.setAttribute("data-fixture-same-node", "like"));
await reusedDislikeButton.evaluate((button) => button.setAttribute("data-fixture-same-node", "dislike"));
@@ -836,7 +838,7 @@ test("same-node watch reuse requires independent native refresh on both activati
await expect(page).toHaveURL((url) => url.pathname === "/watch" && url.searchParams.get("v") === VIDEO_B);
await page.waitForTimeout(600);
await expect(reusedControls.locator('[data-ryd-role="dislike"] #text')).toHaveText("11");
await expect(reusedControls.locator('[data-fixture-role="dislike"] #text')).toHaveText("11");
await reusedDislikeButton.click();
await page.waitForTimeout(100);
expect(
@@ -875,8 +877,8 @@ test("pre-navigation native drift cannot satisfy same-node watch refresh for the
const harness = await launchNavigationHarness({ context, page }, { pageKind: "watch", videoId: VIDEO_A });
await expectWatchInitialized(page, VIDEO_A);
const reusedControls = page.locator(`[data-fixture-control-video-id="${VIDEO_A}"]`);
const reusedLikeButton = reusedControls.locator('[data-ryd-role="like"] button');
const reusedDislikeButton = reusedControls.locator('[data-ryd-role="dislike"] button');
const reusedLikeButton = reusedControls.locator('[data-fixture-role="like"] button');
const reusedDislikeButton = reusedControls.locator('[data-fixture-role="dislike"] button');
await reusedLikeButton.evaluate((button) => button.setAttribute("data-fixture-pre-navigation-node", "like"));
await reusedDislikeButton.evaluate((button) => button.setAttribute("data-fixture-pre-navigation-node", "dislike"));
@@ -896,9 +898,10 @@ test("pre-navigation native drift cannot satisfy same-node watch refresh for the
await expect(page).toHaveURL((url) => url.pathname === "/watch" && url.searchParams.get("v") === VIDEO_B);
await page.waitForTimeout(600);
await expect(reusedControls.locator('[data-ryd-role="dislike"] #text')).toHaveText("11");
await expect(reusedControls.locator('[data-fixture-role="dislike"] #text')).toHaveText("11");
await expect(page.locator("#ryd-dislike-tooltip")).toContainText("100 / 11");
await reusedDislikeButton.click();
await expect(reusedDislikeButton).toHaveAttribute("aria-pressed", "true");
await page.waitForTimeout(100);
expect(
harness.backend.requestsFor("GET", "/votes").filter((request) => request.query.videoId === VIDEO_B),
@@ -915,6 +918,7 @@ test("pre-navigation native drift cannot satisfy same-node watch refresh for the
await page.evaluate((videoId) => window.__navigationFixture.refreshReusedWatchControl("dislike", videoId), VIDEO_B);
await expect(reusedDislikeButton).toHaveAttribute("aria-label", `dislike refreshed for ${VIDEO_B}`);
await expect(reusedDislikeButton).toHaveAttribute("aria-pressed", "false");
await expect(reusedDislikeButton).toHaveAttribute("data-fixture-pre-navigation-node", "dislike");
await expectWatchInitialized(page, VIDEO_B);
await expectOneActivation(page, harness.backend, VIDEO_B);
@@ -933,8 +937,8 @@ test("navigation-start snapshots same-node watch refreshes that occur before nav
const harness = await launchNavigationHarness({ context, page }, { pageKind: "watch", videoId: VIDEO_A });
await expectWatchInitialized(page, VIDEO_A);
const reusedControls = page.locator(`[data-fixture-control-video-id="${VIDEO_A}"]`);
const reusedLikeButton = reusedControls.locator('[data-ryd-role="like"] button');
const reusedDislikeButton = reusedControls.locator('[data-ryd-role="dislike"] button');
const reusedLikeButton = reusedControls.locator('[data-fixture-role="like"] button');
const reusedDislikeButton = reusedControls.locator('[data-fixture-role="dislike"] button');
await reusedLikeButton.evaluate((button) => button.setAttribute("data-fixture-pre-finish-node", "like"));
await reusedDislikeButton.evaluate((button) => button.setAttribute("data-fixture-pre-finish-node", "dislike"));
@@ -982,8 +986,8 @@ test("navigation-start combines a replaced Like target with a reused Dislike ref
const harness = await launchNavigationHarness({ context, page }, { pageKind: "watch", videoId: VIDEO_A });
await expectWatchInitialized(page, VIDEO_A);
const controls = page.locator(`[data-fixture-control-video-id="${VIDEO_A}"]`);
const originalLikeButton = controls.locator('[data-ryd-role="like"] button');
const reusedDislikeButton = controls.locator('[data-ryd-role="dislike"] button');
const originalLikeButton = controls.locator('[data-fixture-role="like"] button');
const reusedDislikeButton = controls.locator('[data-fixture-role="dislike"] button');
await originalLikeButton.evaluate((button) => {
window.__fixtureOriginalMixedLikeTarget = button;
});
@@ -1037,7 +1041,7 @@ test("mobile channel to Short ignores stale channel controls until the target mo
await page.locator("#channel-short").click();
await expect(page).toHaveURL((url) => url.pathname === `/shorts/${VIDEO_A}`);
const decoyDislikeCount = page.locator('[data-fixture-decoy-controls] [data-ryd-role="dislike"] #text');
const decoyDislikeCount = page.locator('[data-fixture-decoy-controls] [data-fixture-role="dislike"] #text');
await page.waitForTimeout(250);
await expect(decoyDislikeCount).toHaveText("");
expect(harness.backend.requestsFor("GET", "/votes")).toHaveLength(0);
@@ -6,62 +6,72 @@ const {
installGmEnvironment,
installHermeticRoutes,
openNavigationFixture,
readGmValue,
} = require("./harness");
const { USERSCRIPT_MATRIX_RUNTIME, installNavigationMatrixFixture } = require("./navigation-matrix");
const {
NAVIGATION_MATRIX,
USERSCRIPT_MATRIX_RUNTIME,
installNavigationMatrixFixture,
runNavigationMatrixScenario,
} = require("./navigation-matrix");
createNavigationRuntimeContractAdapter,
registerNavigationRuntimeContractScenarios,
runNavigationRuntimeContract,
} = require("../../e2e/navigation-runtime-contract");
const EXISTING_CREDENTIALS = {
registrationConfirmed: true,
userId: "ExistingUserscriptCredential000000000001",
userId: "ExistingUserscriptCredential00000001",
};
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));
registerNavigationRuntimeContractScenarios({
runtimeName: "userscript",
register: ({ scenario, title }) => {
test(title, 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));
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 context.addInitScript(() => {
globalThis.__unhandledRejections = [];
addEventListener("unhandledrejection", (event) => {
const reason = event.reason;
globalThis.__unhandledRejections.push(reason instanceof Error ? reason.message : String(reason));
});
});
await runNavigationMatrixScenario({
backend,
page,
runtime: USERSCRIPT_MATRIX_RUNTIME,
scenario,
});
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);
expect(backend.blockedRequests).toEqual([]);
expect(consoleErrors).toEqual([]);
expect(pageErrors).toEqual([]);
expect(await page.evaluate(() => globalThis.__unhandledRejections)).toEqual([]);
});
}
const adapter = createNavigationRuntimeContractAdapter({
backend,
expectedCredentials: EXISTING_CREDENTIALS,
matrixRuntime: USERSCRIPT_MATRIX_RUNTIME,
page,
readCredentials: () => readGmValue(page, CREDENTIAL_KEY),
readInteractionRecords: () => backend.requests,
runtimeName: "userscript",
});
await runNavigationRuntimeContract({
adapter,
scenario,
});
expect(backend.blockedRequests).toEqual([]);
expect(consoleErrors).toEqual([]);
expect(pageErrors).toEqual([]);
expect(await page.evaluate(() => globalThis.__unhandledRejections)).toEqual([]);
});
},
});
@@ -0,0 +1,43 @@
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { test, expect } = require("@playwright/test");
const { VIDEO_A } = require("./harness");
const { HermeticUserscriptArtifactAdapter } = require("../../e2e/hermetic-artifact-smoke");
const PRODUCTION_API_ORIGIN = "https://returnyoutubedislikeapi.com";
function removeOwnedOracleDirectory(directory) {
const resolved = path.resolve(directory);
expect(path.dirname(resolved)).toBe(path.resolve(os.tmpdir()));
expect(path.basename(resolved)).toMatch(/^ryd-userscript-render-oracle-/);
fs.rmSync(resolved, { force: true, recursive: true });
}
test("the render oracle rejects a generated userscript whose runtime body does nothing", async () => {
const oracleRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ryd-userscript-render-oracle-"));
const brokenArtifact = path.join(oracleRoot, "Return Youtube Dislike.user.js");
fs.writeFileSync(brokenArtifact, `void ${JSON.stringify(PRODUCTION_API_ORIGIN)};`);
const adapter = new HermeticUserscriptArtifactAdapter({ artifactPath: brokenArtifact });
try {
await adapter.start();
adapter.page.setDefaultTimeout(1_500);
await adapter.openWatch(VIDEO_A);
await expect(adapter.waitForWatchResult(VIDEO_A)).rejects.toThrow(/Timeout|did not render/i);
await expect(adapter.page.locator("#return-youtube-dislike-bar-container")).toHaveCount(0);
await expect(adapter.page.locator("#return-youtube-dislike-bar")).toHaveCount(0);
await expect(
adapter.page.locator(
"dislike-button-view-model #text, " +
"dislike-button-view-model [role='text'], " +
"dislike-button-view-model .ytSpecButtonShapeNextButtonTextContent",
),
).toHaveCount(0);
} finally {
await adapter.close().catch(() => {});
removeOwnedOracleDirectory(oracleRoot);
}
});
@@ -0,0 +1,25 @@
const { test } = require("@playwright/test");
const { HermeticUserscriptArtifactAdapter, SPA_COUNTS } = require("../../e2e/hermetic-artifact-smoke");
const {
registerRuntimeResilienceContractScenarios,
runRuntimeResilienceContract,
} = require("../../e2e/runtime-resilience-contract");
registerRuntimeResilienceContractScenarios({
runtimeName: "userscript",
register: ({ scenario, title }) => {
test(title, async () => {
await runRuntimeResilienceContract({
scenario,
createAdapter: ({ gateMode }) =>
new HermeticUserscriptArtifactAdapter({
backendOptions: {
countsByVideo: SPA_COUNTS,
fixture: { signedIn: gateMode !== "signed-out" },
},
disableVoteSubmission: gateMode === "disabled",
}),
});
});
},
});
@@ -0,0 +1,64 @@
const { test, expect } = require("@playwright/test");
const {
CREDENTIAL_KEY,
createFakeBackend,
injectGeneratedUserscript,
installGmEnvironment,
installHermeticRoutes,
} = require("./harness");
const {
SHORTS_PLACEHOLDER_POOL_COUNTS,
installShortsPlaceholderPoolRoute,
runShortsPlaceholderPoolContract,
shortsPlaceholderPoolUrl,
} = require("../../e2e/shorts-placeholder-pool-contract");
const EXISTING_CREDENTIALS = Object.freeze({
registrationConfirmed: true,
userId: "ExistingUserscriptCredential00000001",
});
test("userscript preserves every Shorts action through ten pre-rendered and recycled Next transitions", async ({
context,
page,
}) => {
test.setTimeout(30_000);
const consoleErrors = [];
const pageErrors = [];
const unexpectedFixtureRequests = [];
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: SHORTS_PLACEHOLDER_POOL_COUNTS });
await installGmEnvironment(context, { [CREDENTIAL_KEY]: EXISTING_CREDENTIALS });
await installHermeticRoutes(context, backend);
await installShortsPlaceholderPoolRoute(context, {
onUnexpectedRequest: (request) => unexpectedFixtureRequests.push(request),
});
await page.setViewportSize({ height: 720, width: 1280 });
await page.goto(shortsPlaceholderPoolUrl(), { waitUntil: "domcontentloaded" });
await injectGeneratedUserscript(page);
const results = await runShortsPlaceholderPoolContract({
page,
readRequests: () => backend.requests,
runtimeName: "userscript",
});
expect(results).toHaveLength(11);
expect(results.filter((result) => result.pixelOracle).map((result) => result.logicalIndex)).toEqual([0, 1, 10]);
expect(unexpectedFixtureRequests).toEqual([]);
expect(backend.blockedRequests).toEqual([]);
expect(consoleErrors).toEqual([]);
expect(pageErrors).toEqual([]);
expect(await page.evaluate(() => globalThis.__unhandledRejections)).toEqual([]);
});
@@ -1,3 +1,5 @@
const fs = require("node:fs");
const path = require("node:path");
const { test, expect } = require("@playwright/test");
const {
CREDENTIAL_KEY,
@@ -8,67 +10,38 @@ const {
installHermeticRoutes,
openWatchFixture,
} = require("./harness");
const {
USERSCRIPT_WATCH_VISUAL_PROFILE,
attachVisualFailureScreenshot,
expectNoStructuralLayoutShift,
expectWatchVisualState,
readWatchVisualContract,
waitForBarRatio,
} = require("../../e2e/watch-visual-contract");
const { getWatchNeutralBaselineId, registerWatchVisualContract } = require("../../e2e/watch-visual-scenarios");
const { annotateVisualEvidence, captureOptionalVisualEvidence } = require("../../e2e/visual-evidence");
const REPOSITORY_ROOT = path.resolve(__dirname, "../../..");
const VISUAL_REVIEW_DIRECTORY = path.join(REPOSITORY_ROOT, "test-results", "visual-review", "userscript");
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,
},
];
async function captureVisualReview(page, name) {
const outputPath = path.join(VISUAL_REVIEW_DIRECTORY, `${name}.png`);
return captureOptionalVisualEvidence({
capture: async (screenshotPath) => {
fs.mkdirSync(VISUAL_REVIEW_DIRECTORY, { recursive: true });
await page.locator("#top-row").screenshot({
animations: "disabled",
path: screenshotPath,
});
},
outputPath,
});
}
function monitorRuntime(page) {
const consoleErrors = [];
@@ -92,335 +65,52 @@ async function launchVisualFixture({ context, page }, initialState) {
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();
await expect(page.locator(USERSCRIPT_WATCH_VISUAL_PROFILE.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,
registerWatchVisualContract({
runtime: "userscript",
test,
async runScenario({ fixtures: { context, page }, scenario, testInfo }) {
const { transition, viewport } = scenario;
await page.setViewportSize({ width: viewport.width, height: viewport.height });
try {
const harness = await launchVisualFixture({ context, page }, transition.initialState);
const before = await readWatchVisualContract(page, USERSCRIPT_WATCH_VISUAL_PROFILE);
expectWatchVisualState(before, transition.initialState, BASE_COUNTS, viewport, USERSCRIPT_WATCH_VISUAL_PROFILE);
const neutralBaselineId = getWatchNeutralBaselineId(scenario);
if (neutralBaselineId) {
annotateVisualEvidence(testInfo, await captureVisualReview(page, neutralBaselineId));
}
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,
};
};
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),
};
};
await waitForBarRatio(page, nextCounts, USERSCRIPT_WATCH_VISUAL_PROFILE);
const after = await readWatchVisualContract(page, USERSCRIPT_WATCH_VISUAL_PROFILE);
expectWatchVisualState(after, transition.nextState, nextCounts, viewport, USERSCRIPT_WATCH_VISUAL_PROFILE);
expectNoStructuralLayoutShift(before, after);
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;
}
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([]);
annotateVisualEvidence(testInfo, await captureVisualReview(page, scenario.id));
} catch (error) {
await attachVisualFailureScreenshot(testInfo, page, scenario.id);
throw error;
}
});
}
},
});