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