Fix Shorts support and add userscript voting

Harden Shorts controls, reaction state, and navigation; share voting logic
with the extension; improve ratio-bar rendering; and add comprehensive Jest,
Playwright, artifact, and live-browser regression coverage.
This commit is contained in:
Anarios
2026-09-01 23:41:28 +02:00
parent 4e6dfd1977
commit 3657de9b2c
74 changed files with 23328 additions and 588 deletions
+3
View File
@@ -1,2 +1,5 @@
# Auto detect text files and perform LF normalization
* text=auto
# Generated output must remain byte-for-byte stable across platforms.
"Extensions/UserScript/Return Youtube Dislike.user.js" text eol=lf
+58 -8
View File
@@ -1,25 +1,45 @@
name: Test Build Extension
name: Test Builds
on:
push:
branches:
- main
paths:
- Extensions/combined/**
- "Extensions/combined/**"
- "Extensions/common/**"
- "Extensions/UserScript/**"
- "Extensions/e2e/**"
- ".github/workflows/build.yaml"
- ".babelrc"
- ".gitattributes"
- ".nvmrc"
- "jest.config.js"
- "package.json"
- "package-lock.json"
- "playwright*.js"
- "webpack*.js"
pull_request:
branches:
- main
paths:
- Extensions/combined/**
- "Extensions/combined/**"
- "Extensions/common/**"
- "Extensions/UserScript/**"
- "Extensions/e2e/**"
- ".github/workflows/build.yaml"
- ".babelrc"
- ".gitattributes"
- ".nvmrc"
- "jest.config.js"
- "package.json"
- "package-lock.json"
- "playwright*.js"
- "webpack*.js"
jobs:
build:
runs-on: ubuntu-24.04
defaults:
run:
working-directory: ./Extensions/combined
steps:
- name: Checkout repository
uses: actions/checkout@v4
@@ -27,14 +47,33 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20.x'
node-version-file: .nvmrc
cache: npm
- name: Install dependencies
run: npm ci
- name: Run Jest tests
run: npm test -- --runInBand
- name: Build extension
run: npm run build
- name: Build userscript
run: npm run build:userscript
- name: Check generated userscript
run: git diff --exit-code -- "Extensions/UserScript/Return Youtube Dislike.user.js"
- name: Install Chromium
run: npx playwright install --with-deps chromium
- name: Run generated-artifact browser tests
run: npm run test:e2e:artifacts
- name: Run userscript browser tests
run: npx playwright test --config playwright.userscript.config.js
- name: Upload Chrome artifact
uses: actions/upload-artifact@v4
with:
@@ -52,3 +91,14 @@ jobs:
with:
name: ryd-safari
path: ./Extensions/combined/dist/safari
- name: Upload Playwright failure artifacts
if: failure()
uses: actions/upload-artifact@v4
with:
name: userscript-playwright-failure
path: |
./test-results
./playwright-report
if-no-files-found: ignore
retention-days: 7
+4
View File
@@ -40,3 +40,7 @@ Extensions/combined/bundled-content-script.js
# Dist Files
Extensions/combined/dist/*
Website/package-lock.json
# Playwright output
/playwright-report/
/test-results/
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,645 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Userscript navigation browser-test fixture</title>
<style>
html,
body {
margin: 0;
min-height: 768px;
width: 100%;
}
[hidden] {
display: none !important;
}
#fixture-account,
#fixture-navigation,
#fixture-page {
display: block;
}
#fixture-navigation a,
#fixture-page > a {
display: inline-block;
margin: 4px;
min-height: 24px;
min-width: 96px;
}
ytd-watch-flexy,
ytd-menu-renderer,
segmented-like-dislike-button-view-model,
like-button-view-model,
dislike-button-view-model,
ytd-shorts,
ytd-reel-video-renderer,
ytm-reel-video-renderer,
ytm-reel-player-overlay-renderer,
reel-action-bar-view-model,
ytm-like-button-renderer {
display: block;
}
#top-row,
ytd-menu-renderer.ytd-watch-metadata > div {
width: min(320px, 100%);
}
segmented-like-dislike-button-view-model {
display: flex;
gap: 8px;
min-height: 48px;
width: 320px;
}
segmented-like-dislike-button-view-model button {
min-height: 36px;
min-width: 96px;
}
ytd-reel-video-renderer {
display: block;
min-height: 640px;
width: 420px;
}
ytd-reel-video-renderer[hidden] {
display: none;
}
ytm-reel-video-renderer[hidden] {
display: none;
}
.slim-video-action-bar-actions,
.segmented-buttons {
display: flex;
gap: 8px;
}
ytm-like-button-renderer button {
min-height: 36px;
min-width: 96px;
}
reel-action-bar-view-model {
align-items: flex-start;
display: flex;
flex-direction: column;
min-height: 320px;
width: 48px;
}
.ytwReelActionBarViewModelHostDesktopActionButton,
reel-action-bar-view-model > button-view-model {
box-sizing: content-box;
display: block;
height: 70px;
margin: 0;
padding: 0 0 8px;
width: 48px;
}
.ytSpecButtonShapeWithLabelHost {
align-items: center;
display: flex;
flex-direction: column;
height: 70px;
margin: 0;
padding: 0;
width: 48px;
}
reel-action-bar-view-model button {
align-items: center;
border: 0;
box-sizing: border-box;
display: flex;
height: 48px;
justify-content: center;
margin: 0;
min-height: 0;
min-width: 0;
padding: 12px;
width: 48px;
}
.ytSpecButtonShapeNextIcon,
.ytSpecButtonShapeNextIcon svg {
display: block;
height: 24px;
width: 24px;
}
.ytSpecButtonShapeWithLabelLabel {
align-items: center;
display: flex;
font:
12px/18px Arial,
sans-serif;
height: 22px;
justify-content: center;
width: 48px;
}
</style>
</head>
<body>
<div id="fixture-account"><button id="avatar-btn" aria-label="Account menu">Account</button></div>
<nav id="fixture-navigation" aria-label="Fixture navigation"></nav>
<main id="fixture-page"></main>
<script>
(() => {
const firstVideoId = "__VIDEO_ID__";
const secondVideoId = "__SECOND_VIDEO_ID__";
const marker = "rydNavigationFixture=1";
const navigation = document.getElementById("fixture-navigation");
const page = document.getElementById("fixture-page");
const isMobile = location.hostname === "m.youtube.com";
let pendingDelayedRender = null;
if (!customElements.get("like-button-view-model")) {
customElements.define(
"like-button-view-model",
class extends HTMLElement {
get className() {
return undefined;
}
},
);
}
const hrefFor = (pageKind, videoId) => {
if (pageKind === "shorts") return `/shorts/${videoId}?${marker}`;
if (pageKind === "watch") return `/watch?v=${videoId}&${marker}`;
return `/@FixtureChannel?${marker}`;
};
const linkMarkup = (id, label, pageKind, videoId, options = {}) => `
<a
id="${id}"
href="${hrefFor(pageKind, videoId)}"
data-fixture-page-kind="${pageKind}"
data-fixture-video-id="${videoId || ""}"
data-fixture-control-delay="${options.controlDelay || 0}"
>${label}</a>
`;
function channelMarkup() {
const decoyControls = isMobile
? `
<div class="slim-video-action-bar-actions" data-fixture-decoy-controls>
<div class="segmented-buttons" data-ryd-role="buttons">
<like-button-view-model class="style-text" data-ryd-role="like">
<button type="button" aria-label="Channel preview likes" aria-pressed="false">
<span id="text" role="text">100</span>
</button>
</like-button-view-model>
<dislike-button-view-model class="style-text" data-ryd-role="dislike">
<button type="button" aria-label="Channel preview dislike" aria-pressed="false">
<span id="text" role="text"></span>
</button>
</dislike-button-view-model>
</div>
</div>
`
: `
<div id="menu-container" style="display: none"></div>
<ytd-menu-renderer class="ytd-watch-metadata" data-fixture-decoy-controls>
<div id="top-level-buttons-computed">
<segmented-like-dislike-button-view-model data-ryd-role="buttons">
<like-button-view-model class="style-text" data-ryd-role="like">
<button type="button" aria-label="Channel preview likes" aria-pressed="false">
<span id="text" role="text">100</span>
</button>
</like-button-view-model>
<dislike-button-view-model class="style-text" data-ryd-role="dislike">
<button type="button" aria-label="Channel preview dislike" aria-pressed="false">
<span id="text" role="text"></span>
</button>
</dislike-button-view-model>
</segmented-like-dislike-button-view-model>
</div>
</ytd-menu-renderer>
`;
return `
<section data-fixture-page-kind="channel">
<h1>Fixture channel</h1>
${decoyControls}
${linkMarkup("channel-short", "Open Short", "shorts", firstVideoId, { controlDelay: 600 })}
${linkMarkup("channel-watch", "Open video", "watch", firstVideoId, { controlDelay: 200 })}
</section>
`;
}
function watchMarkup(videoId) {
const nextVideoId = videoId === firstVideoId ? secondVideoId : firstVideoId;
return `
<section data-fixture-page-kind="watch" data-fixture-video-id="${videoId}">
<div id="menu-container" style="display: none"></div>
<div id="player" loading="false"></div>
<ytd-watch-flexy video-id="${videoId}"></ytd-watch-flexy>
<div id="top-row">
<ytd-menu-renderer class="ytd-watch-metadata">
<div id="top-level-buttons-computed">
<segmented-like-dislike-button-view-model
data-ryd-role="buttons"
data-fixture-control-video-id="${videoId}"
>
<like-button-view-model class="style-text" data-ryd-role="like">
<button type="button" aria-label="100 likes" aria-pressed="false">
<span id="text" role="text">100</span>
</button>
</like-button-view-model>
<dislike-button-view-model class="style-text" data-ryd-role="dislike">
<button type="button" aria-label="Dislike this video" aria-pressed="false">
<span id="text" role="text"></span>
</button>
</dislike-button-view-model>
</segmented-like-dislike-button-view-model>
</div>
</ytd-menu-renderer>
</div>
${linkMarkup("watch-to-short", "Open Short", "shorts", nextVideoId, { controlDelay: 200 })}
${linkMarkup("watch-related", "Open related video", "watch", nextVideoId)}
${linkMarkup("watch-next", "Play next video", "watch", nextVideoId)}
<video id="fixture-media" data-fixture-autoplay-kind="watch" data-fixture-autoplay-id="${nextVideoId}"></video>
</section>
`;
}
function shortRendererMarkup(videoId, active) {
return `
<ytd-reel-video-renderer video-id="${videoId}" ${active ? "is-active" : "hidden"}>
<a href="/shorts/${videoId}" aria-label="Short ${videoId}"></a>
<reel-action-bar-view-model data-ryd-role="buttons">
<like-button-view-model
class="ytLikeButtonViewModelHost ytwReelActionBarViewModelHostDesktopActionButton"
data-ryd-role="like"
>
<label class="ytSpecButtonShapeWithLabelHost">
<button class="ytSpecButtonShapeNextHost" type="button" aria-label="100 likes" aria-pressed="false">
<span class="ytSpecButtonShapeNextIcon" aria-hidden="true">
<svg viewBox="0 0 24 24"><path d="M12 21"></path></svg>
</span>
</button>
<div class="ytSpecButtonShapeWithLabelLabel"><span id="text" role="text">100</span></div>
</label>
</like-button-view-model>
<button-view-model data-fixture-control="comments">
<button type="button" aria-label="View comments"><span role="text">12</span></button>
</button-view-model>
<button-view-model data-fixture-control="share">
<button type="button" aria-label="Share">Share</button>
</button-view-model>
<button-view-model data-fixture-control="remix">
<button type="button" aria-label="Remix">Remix</button>
</button-view-model>
</reel-action-bar-view-model>
</ytd-reel-video-renderer>
`;
}
function mobileShortRendererMarkup(videoId, active) {
return `
<ytm-reel-video-renderer
data-fixture-mobile-short="${videoId}"
video-id="${videoId}"
${active ? "is-active" : "hidden"}
>
<a href="/shorts/${videoId}" aria-label="Short ${videoId}"></a>
<ytm-reel-player-overlay-renderer>
<div id="like-button">
<ytm-like-button-renderer data-ryd-role="buttons">
<like-button-view-model class="style-text" data-ryd-role="like">
<button type="button" aria-label="100 likes" aria-pressed="false">
<span id="text" class="button-renderer-text" role="text">100</span>
</button>
</like-button-view-model>
<dislike-button-view-model class="style-text" data-ryd-role="dislike">
<button type="button" aria-label="Dislike this video" aria-pressed="false">
<span id="text" class="button-renderer-text" role="text"></span>
</button>
</dislike-button-view-model>
</ytm-like-button-renderer>
</div>
</ytm-reel-player-overlay-renderer>
</ytm-reel-video-renderer>
`;
}
function shortsMarkup(videoId) {
const nextVideoId = videoId === firstVideoId ? secondVideoId : firstVideoId;
return `
<section data-fixture-page-kind="shorts" data-fixture-video-id="${videoId}">
${
isMobile
? `<div id="fixture-shorts-reels">
${mobileShortRendererMarkup(videoId, true)}
${mobileShortRendererMarkup(nextVideoId, false)}
</div>`
: `<ytd-shorts>
<div id="fixture-shorts-reels">
${shortRendererMarkup(videoId, true)}
${shortRendererMarkup(nextVideoId, false)}
</div>
</ytd-shorts>`
}
${linkMarkup("short-to-watch", "Open video", "watch", nextVideoId, { controlDelay: 200 })}
${linkMarkup("short-next", "Play next Short", "shorts", nextVideoId)}
<video id="fixture-media" data-fixture-autoplay-kind="shorts" data-fixture-autoplay-id="${nextVideoId}"></video>
</section>
`;
}
function pageMarkup(pageKind, videoId) {
if (pageKind === "shorts") return shortsMarkup(videoId);
if (pageKind === "watch") return watchMarkup(videoId);
return channelMarkup();
}
function dispatchNavigation() {
document.dispatchEvent(new Event("yt-navigate-finish", { bubbles: true }));
}
function render(pageKind, videoId) {
page.innerHTML = pageMarkup(pageKind, videoId);
navigation.innerHTML =
pageKind === "channel"
? ""
: linkMarkup("return-to-channel", "Return to channel", "channel", "", { controlDelay: 20 });
}
function navigate(pageKind, videoId, { controlDelay = 0, dispatchEvent = true } = {}) {
history.pushState({}, "", hrefFor(pageKind, videoId));
if (controlDelay > 0) {
page.dataset.fixtureTransitionPending = pageKind;
if (dispatchEvent) dispatchNavigation();
setTimeout(() => {
delete page.dataset.fixtureTransitionPending;
render(pageKind, videoId);
}, controlDelay);
return;
}
render(pageKind, videoId);
if (dispatchEvent) dispatchNavigation();
}
function recycleShort(videoId) {
const section = page.querySelector('[data-fixture-page-kind="shorts"]');
const activeRenderer = section?.querySelector(
isMobile ? "ytm-reel-video-renderer[is-active]" : "ytd-reel-video-renderer[is-active]",
);
const nextRenderer = section?.querySelector(
isMobile
? `ytm-reel-video-renderer[video-id="${videoId}"]`
: `ytd-reel-video-renderer[video-id="${videoId}"]`,
);
if (!section || !activeRenderer || !nextRenderer) return;
history.pushState({}, "", hrefFor("shorts", videoId));
section.dataset.fixtureVideoId = videoId;
activeRenderer.removeAttribute("is-active");
activeRenderer.hidden = true;
nextRenderer.hidden = false;
nextRenderer.setAttribute("is-active", "");
const nextLink = section.querySelector("#short-next");
nextLink?.setAttribute("data-fixture-video-id", firstVideoId);
nextLink?.setAttribute("href", hrefFor("shorts", firstVideoId));
section.querySelector("#fixture-media")?.setAttribute("data-fixture-autoplay-id", firstVideoId);
}
function churnInactiveDesktopShortIdentity(sequence) {
const renderer = page.querySelector("ytd-reel-video-renderer:not([is-active])");
if (!renderer) return;
const churnVideoId = sequence % 2 === 0 ? secondVideoId : "CCCCCCCCCCC";
renderer.setAttribute("video-id", churnVideoId);
renderer.querySelector('a[href*="/shorts/"]')?.setAttribute("href", `/shorts/${churnVideoId}`);
}
function queueDelayedRender(pageKind, videoId) {
page.dataset.fixtureTransitionPending = pageKind;
pendingDelayedRender = () => {
delete page.dataset.fixtureTransitionPending;
render(pageKind, videoId);
};
}
function finishDelayedNavigation() {
if (!pendingDelayedRender) throw new Error("No delayed fixture navigation is pending");
const finish = pendingDelayedRender;
pendingDelayedRender = null;
finish();
}
function navigateDelayedShort(videoId) {
const rendererTag = isMobile ? "ytm-reel-video-renderer" : "ytd-reel-video-renderer";
const activeRenderer = page.querySelector(`${rendererTag}[is-active]`);
for (const renderer of page.querySelectorAll(`${rendererTag}:not([is-active])`)) {
renderer.remove();
}
if (!activeRenderer) return;
activeRenderer.querySelector('a[href*="/shorts/"]')?.setAttribute("href", `/shorts/${videoId}`);
history.pushState({}, "", hrefFor("shorts", videoId));
queueDelayedRender("shorts", videoId);
dispatchNavigation();
}
function anonymizeActiveDesktopShort() {
const activeRenderer = page.querySelector("ytd-reel-video-renderer[is-active]");
if (!activeRenderer) return;
activeRenderer.removeAttribute("video-id");
activeRenderer.querySelector('a[href*="/shorts/"]')?.removeAttribute("href");
}
function navigateDelayedAnonymousShort(videoId) {
const activeRenderer = page.querySelector("ytd-reel-video-renderer[is-active]");
for (const renderer of page.querySelectorAll("ytd-reel-video-renderer:not([is-active])")) {
renderer.remove();
}
if (!activeRenderer) return;
anonymizeActiveDesktopShort();
history.pushState({}, "", hrefFor("shorts", videoId));
queueDelayedRender("shorts", videoId);
dispatchNavigation();
}
function navigateDelayedWatch(videoId) {
const watch = page.querySelector("ytd-watch-flexy");
if (!watch) return;
history.pushState({}, "", hrefFor("watch", videoId));
watch.setAttribute("video-id", videoId);
queueDelayedRender("watch", videoId);
dispatchNavigation();
}
function beginSameNodeWatchNavigation(videoId) {
const watch = page.querySelector("ytd-watch-flexy");
if (!watch) return;
document.dispatchEvent(new Event("yt-navigate-start", { bubbles: true }));
history.pushState({}, "", hrefFor("watch", videoId));
watch.setAttribute("video-id", videoId);
}
function finishSameNodeWatchNavigation() {
dispatchNavigation();
}
function mutateOutgoingWatchDescendant() {
const dislike = page.querySelector('[data-fixture-page-kind="watch"] [data-ryd-role="dislike"]');
if (!dislike) return;
const countMarker = document.createElement("span");
countMarker.hidden = true;
countMarker.setAttribute("data-fixture-irrelevant-watch-count-mutation", "true");
dislike.querySelector("#text")?.appendChild(countMarker);
const tooltipMarker = document.createElement("span");
tooltipMarker.hidden = true;
tooltipMarker.setAttribute("data-fixture-irrelevant-watch-tooltip-mutation", "true");
page.querySelector(".ryd-tooltip")?.appendChild(tooltipMarker);
}
function replaceDelayedWatchControl(role) {
const control = page.querySelector(`[data-fixture-page-kind="watch"] [data-ryd-role="${role}"]`);
if (!control) return;
const replacement = control.cloneNode(true);
replacement.setAttribute("data-fixture-watch-replacement", role);
replacement.classList.remove("style-default-active");
replacement.classList.add("style-text");
replacement.querySelector("button")?.setAttribute("aria-pressed", "false");
if (role === "dislike") {
const count = replacement.querySelector("#text");
if (count) count.textContent = "";
}
control.replaceWith(replacement);
}
function replaceCurrentWatchActions({ retainOutgoing = false } = {}) {
const section = page.querySelector('[data-fixture-page-kind="watch"]');
const currentActions = section?.querySelector("#top-level-buttons-computed");
const videoId = section?.dataset.fixtureVideoId;
if (!section || !currentActions || !videoId) return false;
const replacement = currentActions.cloneNode(true);
replacement.setAttribute("data-fixture-watch-actions-replacement", videoId);
replacement.querySelectorAll(".ryd-tooltip").forEach((tooltip) => tooltip.remove());
const controls = replacement.querySelector('[data-ryd-role="buttons"]');
controls?.setAttribute("data-fixture-control-video-id", videoId);
for (const role of ["like", "dislike"]) {
const control = controls?.querySelector(`[data-ryd-role="${role}"]`);
control?.classList.remove("style-default-active");
control?.classList.add("style-text");
const button = control?.querySelector("button");
button?.setAttribute("aria-pressed", "false");
button?.removeAttribute("disabled");
button?.setAttribute("aria-disabled", "false");
}
const dislikeCount = controls?.querySelector('[data-ryd-role="dislike"] #text');
if (dislikeCount) dislikeCount.textContent = "";
currentActions.replaceWith(replacement);
globalThis.__fixtureReplacedWatchActions = currentActions;
if (retainOutgoing) {
const retained = document.createElement("div");
retained.hidden = true;
retained.setAttribute("data-fixture-retained-settling-watch-actions", videoId);
retained.appendChild(currentActions);
document.body.appendChild(retained);
}
return true;
}
function refreshReusedWatchControl(role, videoId) {
const control = page.querySelector(`[data-fixture-page-kind="watch"] [data-ryd-role="${role}"]`);
if (!control) return;
control.querySelector("button")?.setAttribute("aria-label", `${role} refreshed for ${videoId}`);
}
function driftWatchControlBeforeNavigation(role, videoId) {
const button = page.querySelector(`[data-fixture-page-kind="watch"] [data-ryd-role="${role}"] button`);
if (!button) return;
button.setAttribute("aria-label", `${role} drift while ${videoId}`);
button.setAttribute("title", `${role} title drift while ${videoId}`);
}
function replaceActiveMobileOverlay() {
const activeRenderer = page.querySelector("ytm-reel-video-renderer[is-active]");
const overlay = activeRenderer?.querySelector("ytm-reel-player-overlay-renderer");
if (!overlay) return;
const replacement = overlay.cloneNode(true);
replacement.setAttribute("data-fixture-replacement", "true");
const dislike = replacement.querySelector('[data-ryd-role="dislike"]');
dislike?.classList.remove("style-default-active");
dislike?.classList.add("style-text");
dislike?.querySelector("button")?.setAttribute("aria-pressed", "false");
const dislikeCount = dislike?.querySelector("#text");
if (dislikeCount) dislikeCount.textContent = "";
overlay.replaceWith(replacement);
}
function navigateToShortsDescendant(videoId) {
history.pushState({}, "", `/shorts/${videoId}/extra?${marker}`);
dispatchNavigation();
}
document.addEventListener("click", (event) => {
const link = event.target.closest("a[data-fixture-page-kind]");
if (!link) return;
event.preventDefault();
if (link.id === "short-next") {
recycleShort(link.dataset.fixtureVideoId);
dispatchNavigation();
return;
}
navigate(link.dataset.fixturePageKind, link.dataset.fixtureVideoId, {
controlDelay: Number(link.dataset.fixtureControlDelay || 0),
});
});
document.addEventListener(
"ended",
(event) => {
const media = event.target.closest("#fixture-media");
if (!media) return;
if (media.dataset.fixtureAutoplayKind === "shorts") {
recycleShort(media.dataset.fixtureAutoplayId);
return;
}
navigate(media.dataset.fixtureAutoplayKind, media.dataset.fixtureAutoplayId, {
dispatchEvent: false,
});
},
true,
);
window.__navigationFixture = {
anonymizeActiveDesktopShort,
beginSameNodeWatchNavigation,
churnInactiveDesktopShortIdentity,
dispatchEnded() {
page.querySelector("#fixture-media")?.dispatchEvent(new Event("ended"));
},
finishDelayedNavigation,
finishSameNodeWatchNavigation,
driftWatchControlBeforeNavigation,
navigate,
navigateDelayedAnonymousShort,
navigateDelayedShort,
navigateDelayedWatch,
navigateToShortsDescendant,
mutateOutgoingWatchDescendant,
recycleShort,
replaceActiveMobileOverlay,
replaceCurrentWatchActions,
replaceDelayedWatchControl,
refreshReusedWatchControl,
};
render("__INITIAL_PAGE_KIND__", "__VIDEO_ID__");
})();
</script>
</body>
</html>
@@ -0,0 +1,348 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Userscript Shorts browser-test fixture</title>
<style>
html,
body {
width: 100%;
min-height: 768px;
margin: 0;
}
#shorts-root,
[data-short-video],
ytd-reel-video-renderer,
reel-action-bar-view-model,
ytd-like-button-renderer,
ytm-like-button-renderer,
like-button-view-model,
dislike-button-view-model {
display: block;
}
[data-short-video][hidden] {
display: none;
}
ytd-like-button-renderer,
ytm-like-button-renderer {
width: 240px;
min-height: 96px;
}
ytd-reel-video-renderer {
display: block;
width: 420px;
min-height: 640px;
}
reel-action-bar-view-model {
align-items: flex-start;
display: flex;
flex-direction: column;
width: 48px;
min-height: 320px;
}
.ytwReelActionBarViewModelHostDesktopActionButton,
reel-action-bar-view-model > button-view-model {
box-sizing: content-box;
display: block;
height: 70px;
margin: 0;
padding: 0 0 8px;
width: 48px;
}
.ytSpecButtonShapeWithLabelHost {
align-items: center;
display: flex;
flex-direction: column;
height: 70px;
margin: 0;
padding: 0;
width: 48px;
}
reel-action-bar-view-model button {
align-items: center;
border: 0;
box-sizing: border-box;
display: flex;
height: 48px;
justify-content: center;
margin: 0;
min-height: 0;
min-width: 0;
padding: 12px;
width: 48px;
}
like-button-view-model .ytSpecButtonShapeNextIcon {
display: flex;
height: 24px;
width: 24px;
}
like-button-view-model .ytSpecButtonShapeNextIcon svg {
display: block;
height: 24px;
width: 24px;
}
.ytSpecButtonShapeWithLabelLabel {
align-items: center;
box-sizing: border-box;
display: flex;
font-family: Arial, sans-serif;
font-size: 12px;
height: 22px;
justify-content: center;
line-height: 18px;
width: 48px;
}
ytm-like-button-renderer button,
ytd-like-button-renderer button {
min-height: 36px;
min-width: 96px;
}
</style>
</head>
<body>
<div id="fixture-account"></div>
<div id="shorts-root"></div>
<script>
(() => {
const root = document.getElementById("shorts-root");
const isMobile = location.hostname === "m.youtube.com";
if (!customElements.get("like-button-view-model")) {
customElements.define(
"like-button-view-model",
class extends HTMLElement {
get className() {
return undefined;
}
},
);
}
document.getElementById("fixture-account").innerHTML = __SIGNED_IN__
? '<button id="avatar-btn" aria-label="Account menu">Account</button>'
: '<a id="sign-in" href="https://accounts.google.com/ServiceLogin">Sign in</a>';
function controlsMarkup(videoId) {
if (!isMobile) {
return `
<section data-short-video="${videoId}" hidden>
<ytd-reel-video-renderer video-id="${videoId}">
<a href="/shorts/${videoId}" aria-label="Short ${videoId}"></a>
<reel-action-bar-view-model data-ryd-role="buttons">
<like-button-view-model
class="ytLikeButtonViewModelHost ytwReelActionBarViewModelHostDesktopActionButton"
data-ryd-role="like"
>
<label class="ytSpecButtonShapeWithLabelHost">
<button class="ytSpecButtonShapeNextHost" type="button" aria-label="100 likes" aria-pressed="false">
<span class="ytSpecButtonShapeNextIcon" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M12 21"></path></svg></span>
</button>
<div class="ytSpecButtonShapeWithLabelLabel">
<span
id="text"
class="ytAttributedStringHost ytAttributedStringTextAlignmentCenter"
role="text"
>100</span>
</div>
</label>
</like-button-view-model>
<button-view-model data-fixture-control="comments">
<button type="button" aria-label="View comments"><span role="text">12</span></button>
</button-view-model>
<button-view-model data-fixture-control="share">
<button type="button" aria-label="Share"><span role="text">Share</span></button>
</button-view-model>
<button-view-model data-fixture-control="remix">
<button type="button" aria-label="Remix"><span role="text">Remix</span></button>
</button-view-model>
</reel-action-bar-view-model>
</ytd-reel-video-renderer>
</section>
`;
}
return `
<section data-short-video="${videoId}" hidden>
<div id="like-button">
<__SHORTS_RENDERER_TAG__ data-ryd-role="buttons">
<like-button-view-model class="style-text" data-ryd-role="like">
<button type="button" aria-label="100 likes" aria-pressed="false">
<span id="text" class="button-renderer-text" role="text">100</span>
</button>
</like-button-view-model>
<dislike-button-view-model class="style-text" data-ryd-role="dislike">
<button type="button" aria-label="Dislike this video" aria-pressed="false">
<span id="text" class="button-renderer-text" role="text"></span>
</button>
</dislike-button-view-model>
</__SHORTS_RENDERER_TAG__>
</div>
</section>
`;
}
function applyNativeState(section, state) {
const like = section.querySelector('[data-ryd-role="like"]');
const dislike = section.querySelector('[data-ryd-role="dislike"]');
if (isMobile) {
like.classList.toggle("style-default-active", state === "liked");
like.classList.toggle("style-text", state !== "liked");
dislike?.classList.toggle("style-default-active", state === "disliked");
dislike?.classList.toggle("style-text", state !== "disliked");
} else {
like.classList.toggle("style-default-active", state === "liked");
dislike?.classList.toggle("style-default-active", state === "disliked");
}
like.querySelector("button").setAttribute("aria-pressed", String(state === "liked"));
dislike?.querySelector("button")?.setAttribute("aria-pressed", String(state === "disliked"));
section.dataset.nativeState = state;
}
function ensureVideo(videoId) {
let section = root.querySelector(`[data-short-video="${videoId}"]`);
if (!section) {
root.insertAdjacentHTML("beforeend", controlsMarkup(videoId));
section = root.querySelector(`[data-short-video="${videoId}"]`);
applyNativeState(section, "neutral");
}
return section;
}
function activate(videoId, { dispatchNavigation = true, state = "neutral" } = {}) {
const active = ensureVideo(videoId);
for (const section of root.querySelectorAll("[data-short-video]")) {
section.hidden = section !== active;
}
applyNativeState(active, state);
history.pushState({}, "", `/shorts/${videoId}`);
if (dispatchNavigation) {
document.dispatchEvent(new Event("yt-navigate-finish", { bubbles: true }));
}
}
function activeSection() {
return root.querySelector("[data-short-video]:not([hidden])");
}
function removeSyntheticDislike() {
activeSection()?.querySelector("[data-ryd-synthetic-shorts-dislike]")?.remove();
}
function replaceActionBar() {
const section = activeSection();
const actionBar = section?.querySelector("reel-action-bar-view-model");
if (!actionBar) return;
const replacement = actionBar.cloneNode(true);
replacement.querySelector("[data-ryd-synthetic-shorts-dislike]")?.remove();
actionBar.replaceWith(replacement);
applyNativeState(section, section.dataset.nativeState || "neutral");
}
function installNativeDislike() {
const section = activeSection();
const actionBar = section?.querySelector("reel-action-bar-view-model");
if (!actionBar || actionBar.querySelector("dislike-button-view-model")) return;
const nativeDislike = document.createElement("dislike-button-view-model");
nativeDislike.className = "style-text";
nativeDislike.setAttribute("data-ryd-role", "dislike");
const button = document.createElement("button");
button.type = "button";
button.setAttribute("aria-label", "Dislike this video");
button.setAttribute("aria-pressed", "false");
const text = document.createElement("span");
text.id = "text";
text.setAttribute("role", "text");
button.appendChild(text);
nativeDislike.appendChild(button);
actionBar.insertBefore(nativeDislike, actionBar.children[1] ?? null);
}
function removeNativeDislike() {
activeSection()?.querySelector("dislike-button-view-model")?.remove();
}
function replaceInnerButton(role) {
const control = activeSection()?.querySelector(`[data-ryd-role="${role}"]`);
const button = control?.querySelector("button");
if (!button) return;
const replacement = button.cloneNode(true);
if (control.matches("[data-ryd-synthetic-shorts-dislike]")) {
replacement.disabled = true;
replacement.setAttribute("aria-disabled", "true");
}
button.replaceWith(replacement);
}
function recycleActiveRenderer(videoId, { state = "neutral" } = {}) {
const section = activeSection();
const renderer = section?.querySelector("ytd-reel-video-renderer");
if (!section || !renderer) return;
section.setAttribute("data-short-video", videoId);
renderer.setAttribute("video-id", videoId);
const link = renderer.querySelector('a[href*="/shorts/"]');
link?.setAttribute("href", `/shorts/${videoId}`);
applyNativeState(section, state);
history.pushState({}, "", `/shorts/${videoId}`);
document.dispatchEvent(new Event("yt-navigate-finish", { bubbles: true }));
}
function setClassOnlyLiked() {
const section = activeSection();
if (!section) return;
applyNativeState(section, "liked");
section.querySelector('[data-ryd-role="like"]').classList.add("style-default-active");
section.querySelector('[data-ryd-role="like"] button').setAttribute("aria-pressed", "false");
}
root.addEventListener(
"click",
(event) => {
const actionButton = event.target.closest("button");
if (!actionButton) return;
const section = event.target.closest("[data-short-video]");
if (!section) return;
const current = section.dataset.nativeState || "neutral";
if (actionButton.closest('[data-ryd-role="like"]')) {
applyNativeState(section, current === "liked" ? "neutral" : "liked");
}
if (
actionButton.closest('[data-ryd-role="dislike"]') &&
!actionButton.closest("[data-ryd-synthetic-shorts-dislike]")
) {
applyNativeState(section, current === "disliked" ? "neutral" : "disliked");
}
},
true,
);
window.__shortsFixture = {
activate,
activeSection,
ensureVideo,
installNativeDislike,
recycleActiveRenderer,
replaceInnerButton,
removeNativeDislike,
removeSyntheticDislike,
replaceActionBar,
setClassOnlyLiked,
};
activate("__VIDEO_ID__", { dispatchNavigation: false, state: "__INITIAL_STATE__" });
ensureVideo("__SECOND_VIDEO_ID__");
})();
</script>
</body>
</html>
@@ -0,0 +1,250 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Userscript browser-test fixture</title>
<style>
:root {
--yt-spec-10-percent-layer: rgba(255, 255, 255, 0.1);
--yt-spec-base-background: rgb(15, 15, 15);
--yt-spec-icon-disabled: rgba(255, 255, 255, 0.3);
--yt-spec-text-primary: rgb(241, 241, 241);
--yt-spec-text-secondary: rgb(170, 170, 170);
}
html,
body {
background: rgb(15, 15, 15);
width: 100%;
min-height: 768px;
margin: 0;
}
ytd-watch-flexy,
ytd-menu-renderer,
.slim-video-action-bar-actions,
.segmented-buttons,
segmented-like-dislike-button-view-model,
like-button-view-model,
dislike-button-view-model {
display: block;
}
ytd-menu-renderer.ytd-watch-metadata > div {
display: block;
width: min(320px, 100%);
min-height: 36px;
}
#top-row {
width: min(320px, 100%);
}
segmented-like-dislike-button-view-model {
display: flex;
gap: 0;
}
.segmented-buttons {
display: flex;
gap: 8px;
width: 320px;
min-height: 48px;
}
[data-fixture-shell][hidden] {
display: none;
}
[data-ryd-role="like"],
[data-ryd-role="dislike"] {
box-sizing: border-box;
flex: 0 0 96px;
height: 36px;
margin: 0;
padding: 0;
width: 96px;
}
[data-ryd-role="like"] button,
[data-ryd-role="dislike"] button {
align-items: center;
background: rgb(39, 39, 39);
border: 0;
border-radius: 18px;
box-sizing: border-box;
color: rgb(241, 241, 241);
display: inline-flex;
font:
500 14px/20px Arial,
sans-serif;
gap: 6px;
height: 36px;
justify-content: center;
margin: 0;
min-height: 36px;
min-width: 96px;
padding: 0 12px;
width: 96px;
}
[data-ryd-role].style-default-active button {
background: rgb(241, 241, 241);
color: rgb(15, 15, 15);
}
[data-fixture-icon] {
display: block;
flex: 0 0 20px;
height: 20px;
margin: 0;
padding: 0;
width: 20px;
}
[data-ryd-role] #text {
display: block;
font:
500 14px/20px Arial,
sans-serif;
margin: 0;
padding: 0;
}
</style>
</head>
<body>
<!-- A hidden menu-container selects the desktop metadata layout used by the real page. -->
<div id="menu-container" style="display: none"></div>
<div id="fixture-account"></div>
<div id="player" loading="false"></div>
<ytd-watch-flexy id="fixture-watch" video-id="__VIDEO_ID__"></ytd-watch-flexy>
<div id="top-row" data-fixture-shell="desktop">
<ytd-menu-renderer class="ytd-watch-metadata">
<div id="top-level-buttons-computed"></div>
</ytd-menu-renderer>
</div>
<div class="slim-video-action-bar-actions" data-fixture-shell="mobile">
<div id="fixture-mobile-buttons-host" class="segmented-buttons"></div>
</div>
<script>
(() => {
const account = document.getElementById("fixture-account");
const watch = document.getElementById("fixture-watch");
const isMobile = location.hostname === "m.youtube.com";
const desktopHost = document.getElementById("top-level-buttons-computed");
const mobileHost = document.getElementById("fixture-mobile-buttons-host");
const host = isMobile ? mobileHost : desktopHost;
document.querySelector('[data-fixture-shell="desktop"]').hidden = isMobile;
document.querySelector('[data-fixture-shell="mobile"]').hidden = !isMobile;
function setSignedIn(signedIn) {
account.innerHTML = signedIn
? '<button id="avatar-btn" aria-label="Account menu">Account</button>'
: '<a id="sign-in" href="https://accounts.google.com/ServiceLogin">Sign in</a>';
}
function desktopButtonMarkup() {
return `
<segmented-like-dislike-button-view-model data-ryd-role="buttons">
<like-button-view-model class="style-text" data-ryd-role="like">
<button type="button" aria-label="100 likes" aria-pressed="false">
<svg data-fixture-icon="like" aria-hidden="true" viewBox="0 0 24 24">
<path fill="currentColor" d="M8 21H5V9h3v12Zm2 0V9l4-7 2 1v5h5v5l-3 8h-8Z"></path>
</svg>
<span id="text" role="text">100</span>
</button>
</like-button-view-model>
<dislike-button-view-model class="style-text" data-ryd-role="dislike">
<button type="button" aria-label="Dislike this video" aria-pressed="false">
<svg data-fixture-icon="dislike" aria-hidden="true" viewBox="0 0 24 24">
<path fill="currentColor" d="M16 3h3v12h-3V3Zm-2 0v12l-4 7-2-1v-5H3v-5l3-8h8Z"></path>
</svg>
<span id="text" role="text"></span>
</button>
</dislike-button-view-model>
</segmented-like-dislike-button-view-model>
`;
}
function mobileButtonMarkup() {
return `
<like-button-view-model class="style-text" data-ryd-role="like">
<button type="button" aria-label="100 likes" aria-pressed="false">
<svg data-fixture-icon="like" aria-hidden="true" viewBox="0 0 24 24">
<path fill="currentColor" d="M8 21H5V9h3v12Zm2 0V9l4-7 2 1v5h5v5l-3 8h-8Z"></path>
</svg>
<span id="text" class="button-renderer-text" role="text">100</span>
</button>
</like-button-view-model>
<dislike-button-view-model class="style-text" data-ryd-role="dislike">
<button type="button" aria-label="Dislike this video" aria-pressed="false">
<svg data-fixture-icon="dislike" aria-hidden="true" viewBox="0 0 24 24">
<path fill="currentColor" d="M16 3h3v12h-3V3Zm-2 0v12l-4 7-2-1v-5H3v-5l3-8h8Z"></path>
</svg>
<span id="text" class="button-renderer-text" role="text"></span>
</button>
</dislike-button-view-model>
`;
}
function applyNativeState(state) {
const like = host.querySelector('[data-ryd-role="like"]');
const dislike = host.querySelector('[data-ryd-role="dislike"]');
if (!like || !dislike) return;
like.classList.toggle("style-default-active", state === "liked");
like.classList.toggle("style-text", state !== "liked");
dislike.classList.toggle("style-default-active", state === "disliked");
dislike.classList.toggle("style-text", state !== "disliked");
like.querySelector("button").setAttribute("aria-pressed", String(state === "liked"));
dislike.querySelector("button").setAttribute("aria-pressed", String(state === "disliked"));
host.dataset.nativeState = state;
}
function insertButtons(state = "neutral") {
host.innerHTML = isMobile ? mobileButtonMarkup() : desktopButtonMarkup();
applyNativeState(state);
}
function removeButtons() {
host.replaceChildren();
host.dataset.nativeState = "neutral";
}
function navigate(videoId, { state = "neutral", buttons = true } = {}) {
history.pushState({}, "", `/watch?v=${videoId}`);
watch.setAttribute("video-id", videoId);
if (buttons) insertButtons(state);
else removeButtons();
document.dispatchEvent(new Event("yt-navigate-finish", { bubbles: true }));
}
// Model the native YouTube toggle before userscript listeners observe the bubbled click.
host.addEventListener(
"click",
(event) => {
const like = event.target.closest('[data-ryd-role="like"]');
const dislike = event.target.closest('[data-ryd-role="dislike"]');
const current = host.dataset.nativeState || "neutral";
if (like) applyNativeState(current === "liked" ? "neutral" : "liked");
if (dislike) applyNativeState(current === "disliked" ? "neutral" : "disliked");
},
true,
);
window.__youtubeFixture = {
applyNativeState,
insertButtons,
navigate,
removeButtons,
setSignedIn,
};
setSignedIn(__SIGNED_IN__);
if (__INITIAL_BUTTONS__) insertButtons("__INITIAL_STATE__");
})();
</script>
</body>
</html>
+386
View File
@@ -0,0 +1,386 @@
const fs = require("fs");
const path = require("path");
const REPOSITORY_ROOT = path.resolve(__dirname, "../../..");
const GENERATED_USERSCRIPT =
process.env.RYD_USERSCRIPT_ARTIFACT ||
path.join(REPOSITORY_ROOT, "Extensions", "UserScript", "Return Youtube Dislike.user.js");
const WATCH_FIXTURE = fs.readFileSync(path.join(__dirname, "fixtures", "watch-page.html"), "utf8");
const SHORTS_FIXTURE = fs.readFileSync(path.join(__dirname, "fixtures", "shorts-page.html"), "utf8");
const NAVIGATION_FIXTURE = fs.readFileSync(path.join(__dirname, "fixtures", "navigation-page.html"), "utf8");
const API_ORIGIN = "https://returnyoutubedislikeapi.com";
const CREDENTIAL_KEY = "rydVoteCredentials";
const VIDEO_A = "abcdefghijk";
const VIDEO_B = "zyxwvutsrqp";
const ZERO_DIFFICULTY_PUZZLE = {
challenge: Buffer.alloc(16).toString("base64"),
difficulty: 0,
};
const delay = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
function requestKey(method, pathname) {
return `${method.toUpperCase()} ${pathname}`;
}
function parseRequestBody(request) {
const text = request.postData();
if (!text) return null;
try {
return JSON.parse(text);
} catch {
return text;
}
}
function jsonHeaders() {
return {
"access-control-allow-headers": "Accept, Content-Type",
"access-control-allow-methods": "GET, POST, OPTIONS",
"access-control-allow-origin": "*",
"content-type": "application/json; charset=utf-8",
};
}
function createFakeBackend({ countsByVideo = {}, countDelayByVideo = {}, fixture = {} } = {}) {
const blockedRequests = [];
const requests = [];
const responsePlans = new Map();
const fixtureOptions = {
initialButtons: fixture.initialButtons !== false,
initialState: fixture.initialState || "neutral",
signedIn: fixture.signedIn !== false,
};
function enqueue(method, pathname, response) {
const key = requestKey(method, pathname);
const queue = responsePlans.get(key) || [];
queue.push(response);
responsePlans.set(key, queue);
}
function defer(method, pathname) {
let releaseResponse;
let resolveSeen;
let released = false;
const seen = new Promise((resolve) => {
resolveSeen = resolve;
});
enqueue(method, pathname, (record) => {
resolveSeen(record);
return new Promise((resolve) => {
releaseResponse = resolve;
});
});
return {
get released() {
return released;
},
release(response) {
if (released) return;
if (!releaseResponse) {
throw new Error(`Cannot release ${requestKey(method, pathname)} before its request is seen`);
}
released = true;
releaseResponse(response);
},
seen,
};
}
function requestsFor(method, pathname) {
return requests.filter((entry) => entry.method === method.toUpperCase() && entry.pathname === pathname);
}
function takePlannedResponse(record) {
const queue = responsePlans.get(requestKey(record.method, record.pathname));
if (!queue?.length) return null;
const planned = queue.shift();
return typeof planned === "function" ? planned(record) : planned;
}
function defaultApiResponse(record) {
if (record.method === "GET" && record.pathname === "/configs/selectors") {
return { body: {} };
}
if (record.method === "GET" && record.pathname === "/votes") {
const videoId = record.query.videoId;
const counts = countsByVideo[videoId] || { dislikes: 25, likes: 100 };
return {
body: { ...counts, rating: 4.5 },
delayMs: countDelayByVideo[videoId] || 0,
};
}
if (record.method === "GET" && record.pathname === "/puzzle/registration") {
return { body: ZERO_DIFFICULTY_PUZZLE };
}
if (record.method === "POST" && record.pathname === "/puzzle/registration") {
return { body: true };
}
if (record.method === "POST" && record.pathname === "/interact/vote") {
return { body: ZERO_DIFFICULTY_PUZZLE };
}
if (record.method === "POST" && record.pathname === "/interact/confirmVote") {
return { body: true };
}
return null;
}
async function handle(route) {
const request = route.request();
const url = new URL(request.url());
if (["www.youtube.com", "m.youtube.com"].includes(url.hostname) && request.resourceType() === "document") {
const isShorts = url.pathname.startsWith("/shorts/");
const videoId = isShorts ? url.pathname.slice(8) || VIDEO_A : url.searchParams.get("v") || VIDEO_A;
const isNavigationFixture = url.searchParams.get("rydNavigationFixture") === "1";
const fixtureTemplate = isNavigationFixture ? NAVIGATION_FIXTURE : isShorts ? SHORTS_FIXTURE : WATCH_FIXTURE;
const html = fixtureTemplate
.replaceAll("__VIDEO_ID__", videoId)
.replaceAll("__SECOND_VIDEO_ID__", VIDEO_B)
.replaceAll("__INITIAL_PAGE_KIND__", isShorts ? "shorts" : url.pathname === "/watch" ? "watch" : "channel")
.replaceAll(
"__SHORTS_RENDERER_TAG__",
url.hostname === "m.youtube.com" ? "ytm-like-button-renderer" : "ytd-like-button-renderer",
)
.replaceAll("__SIGNED_IN__", String(fixtureOptions.signedIn))
.replaceAll("__INITIAL_BUTTONS__", String(fixtureOptions.initialButtons))
.replaceAll("__INITIAL_STATE__", fixtureOptions.initialState);
await route.fulfill({
status: 200,
contentType: "text/html; charset=utf-8",
body: html,
});
return;
}
if (url.origin !== API_ORIGIN) {
blockedRequests.push({
method: request.method(),
resourceType: request.resourceType(),
url: request.url(),
});
await route.abort("blockedbyclient");
return;
}
if (request.method() === "OPTIONS") {
await route.fulfill({ status: 204, headers: jsonHeaders(), body: "" });
return;
}
const record = {
at: Date.now(),
body: parseRequestBody(request),
method: request.method(),
pathname: url.pathname,
query: Object.fromEntries(url.searchParams.entries()),
url: url.toString(),
};
requests.push(record);
const plannedResponse = takePlannedResponse(record);
const response = (plannedResponse ? await plannedResponse : plannedResponse) || defaultApiResponse(record);
if (!response) {
blockedRequests.push({
method: request.method(),
resourceType: request.resourceType(),
url: request.url(),
});
await route.abort("blockedbyclient");
return;
}
if (response.delayMs) await delay(response.delayMs);
const body = response.body === undefined ? null : response.body;
await route.fulfill({
status: response.status || 200,
headers: { ...jsonHeaders(), ...(response.headers || {}) },
body: typeof body === "string" ? body : JSON.stringify(body),
});
record.respondedAt = Date.now();
}
return {
blockedRequests,
defer,
enqueue,
handle,
requests,
requestsFor,
};
}
async function installGmEnvironment(context, initialValues = {}) {
await context.addInitScript(
({ initialValues: seededValues, storagePrefix }) => {
if (!location.hostname.endsWith("youtube.com")) return;
const storageKey = (key) => `${storagePrefix}${key}`;
const read = (key, fallbackValue) => {
const stored = localStorage.getItem(storageKey(key));
if (stored === null) return fallbackValue;
try {
return JSON.parse(stored);
} catch {
return fallbackValue;
}
};
const write = (key, value) => {
if (value === undefined) localStorage.removeItem(storageKey(key));
else localStorage.setItem(storageKey(key), JSON.stringify(value));
};
for (const [key, value] of Object.entries(seededValues)) {
if (localStorage.getItem(storageKey(key)) === null) write(key, value);
}
globalThis.__gmCalls = [];
const getValue = async (key, fallbackValue) => {
globalThis.__gmCalls.push({ operation: "get", key });
return read(key, fallbackValue);
};
const setValue = async (key, value) => {
globalThis.__gmCalls.push({ operation: "set", key, value });
write(key, value);
};
const deleteValue = async (key) => {
globalThis.__gmCalls.push({ operation: "delete", key });
localStorage.removeItem(storageKey(key));
};
const addStyle = (css) => {
const style = document.createElement("style");
style.dataset.rydGmStyle = "true";
style.textContent = css;
(document.head || document.documentElement).appendChild(style);
return style;
};
globalThis.GM = { getValue, setValue, deleteValue, addStyle };
globalThis.GM_getValue = getValue;
globalThis.GM_setValue = setValue;
globalThis.GM_deleteValue = deleteValue;
globalThis.GM_addStyle = addStyle;
},
{
initialValues,
storagePrefix: "ryd-e2e-gm:",
},
);
}
async function installHermeticRoutes(context, backend) {
await context.route("**/*", (route) => backend.handle(route));
}
async function openWatchFixture(page, videoId = VIDEO_A, { hostname = "www.youtube.com" } = {}) {
await page.goto(`https://${hostname}/watch?v=${videoId}`, { waitUntil: "domcontentloaded" });
}
async function openShortsFixture(page, videoId = VIDEO_A, { hostname = "www.youtube.com" } = {}) {
await page.goto(`https://${hostname}/shorts/${videoId}`, { waitUntil: "domcontentloaded" });
}
async function openNavigationFixture(
page,
{ hostname = "www.youtube.com", pageKind = "channel", videoId = VIDEO_A } = {},
) {
const marker = "rydNavigationFixture=1";
const path =
pageKind === "shorts"
? `/shorts/${videoId}?${marker}`
: pageKind === "watch"
? `/watch?v=${videoId}&${marker}`
: `/@FixtureChannel?${marker}`;
await page.goto(`https://${hostname}${path}`, { waitUntil: "domcontentloaded" });
}
async function forbidUnsafeHtmlSinks(page) {
await page.evaluate(() => {
const innerHtmlDescriptor = Object.getOwnPropertyDescriptor(Element.prototype, "innerHTML");
if (!innerHtmlDescriptor?.get || !innerHtmlDescriptor?.set) {
throw new Error("Element.innerHTML descriptor is unavailable");
}
globalThis.__rydUnsafeHtmlSinkCalls = [];
const reject = (sink) => {
globalThis.__rydUnsafeHtmlSinkCalls.push(sink);
throw new TypeError(`${sink} is forbidden by the Trusted Types fixture`);
};
Object.defineProperty(Element.prototype, "innerHTML", {
configurable: innerHtmlDescriptor.configurable,
enumerable: innerHtmlDescriptor.enumerable,
get: innerHtmlDescriptor.get,
set() {
reject("Element.innerHTML");
},
});
Element.prototype.insertAdjacentHTML = function () {
reject("Element.insertAdjacentHTML");
};
});
}
function overrideBooleanOption(source, optionName, value) {
const optionPattern = new RegExp(`${optionName}:\\s*(?:true|false)`, "g");
const matches = source.match(optionPattern) || [];
if (matches.length !== 1) {
throw new Error(`Expected one ${optionName} option in generated userscript, found ${matches.length}`);
}
return source.replace(optionPattern, `${optionName}: ${value}`);
}
async function injectGeneratedUserscript(page, { coloredThumbs, disableVoteSubmission = false, rateBarEnabled } = {}) {
if (!fs.existsSync(GENERATED_USERSCRIPT)) {
throw new Error(`Generated userscript is missing: ${GENERATED_USERSCRIPT}`);
}
if (!disableVoteSubmission && rateBarEnabled === undefined && coloredThumbs === undefined) {
await page.addScriptTag({ path: GENERATED_USERSCRIPT });
return;
}
let source = fs.readFileSync(GENERATED_USERSCRIPT, "utf8");
if (disableVoteSubmission) {
source = overrideBooleanOption(source, "disableVoteSubmission", true);
}
if (rateBarEnabled !== undefined) {
source = overrideBooleanOption(source, "rateBarEnabled", rateBarEnabled);
}
if (coloredThumbs !== undefined) {
source = overrideBooleanOption(source, "coloredThumbs", coloredThumbs);
}
await page.addScriptTag({ content: source });
}
async function readGmValue(page, key) {
return page.evaluate((storageKey) => globalThis.GM.getValue(storageKey, null), key);
}
module.exports = {
API_ORIGIN,
CREDENTIAL_KEY,
GENERATED_USERSCRIPT,
VIDEO_A,
VIDEO_B,
ZERO_DIFFICULTY_PUZZLE,
createFakeBackend,
forbidUnsafeHtmlSinks,
injectGeneratedUserscript,
installGmEnvironment,
installHermeticRoutes,
openNavigationFixture,
openShortsFixture,
openWatchFixture,
readGmValue,
};
+168
View File
@@ -0,0 +1,168 @@
# Live YouTube smoke
This suite attaches over CDP to an already-running Brave profile. It is deliberately separate from `test:all` and CI.
The same page-level checks run in either userscript or extension mode; installation, storage, and protocol failure
coverage remain in the hermetic suites.
## Browser preparation
1. Open `brave://inspect/#remote-debugging` in the target Brave profile and enable **Allow remote debugging for this
browser instance**. Set `RYD_CDP_ENDPOINT` to the explicit HTTP or WebSocket endpoint exposed by that Brave instance;
`http://127.0.0.1:9222` is a common HTTP example, but use the actual port shown by Brave.
2. Use a dedicated two-item playlist containing two allowlisted public or unlisted test videos. Both must be accessible
to the signed-in profile and must have different rendered dislike counts.
3. Enable exactly one runtime:
- `userscript`: disable the browser extension and every other Return YouTube Dislike userscript, run
`npm run build:live:userscript`, import
`test-results/live-build/userscript/Return Youtube Dislike.user.js` into Tampermonkey, and enable it.
- `extension`: disable every Return YouTube Dislike userscript and any store-installed copy of the extension, run
`npm run build:live:extension`, then enable or reload only the unpacked extension at
`Extensions/combined/dist/chrome`.
Every live build receives a fresh random build ID even when its semantic version is unchanged. Import or reload the
runtime **after the final live build command**; rebuilding again invalidates the installed copy for this smoke.
4. Keep the Brave window open and do not interact with the tab created by the suite. The suite creates, uses, and closes
only its own tab.
Only the explicit live-test builds expose page markers containing their runtime, version, and exact build ID. Normal
production builds do not expose them. The suite reads the expected ID from the generated `live-build.json`, verifies
that the installed runtime exposes that exact ID and expected version, and verifies that the other runtime marker is
absent before every scenario. A stale installed script or extension therefore fails even when it has the same version.
There is no environment override for the build ID.
The read-only smoke still loads real YouTube and allows the installed runtime to read from the production RYD API,
including eager registration when the selected runtime has no confirmed identity. It does not mock API responses, but
it blocks production `POST /interact/*` requests before transmission as described below.
## Non-voting smoke
Set the following in PowerShell. Video IDs are the 11-character values from YouTube URLs, and the playlist URL must
start on `RYD_LIVE_WATCH_A`.
```powershell
$env:RYD_LIVE_YOUTUBE="1"
$env:RYD_LIVE_PRODUCTION_API="1"
$env:RYD_CDP_ENDPOINT="http://127.0.0.1:9222"
$env:RYD_LIVE_RUNTIME="userscript"
$env:RYD_LIVE_EXPECTED_CHANNEL="@your-test-channel"
$env:RYD_LIVE_WATCH_A="AAAAAAAAAAA"
$env:RYD_LIVE_WATCH_B="BBBBBBBBBBB"
$env:RYD_LIVE_SHORT="CCCCCCCCCCC"
$env:RYD_LIVE_PLAYLIST_URL="https://www.youtube.com/watch?v=AAAAAAAAAAA&list=PLAYLIST_ID"
# Optional overrides for the cold channel-navigation smoke. These are the defaults:
$env:RYD_LIVE_NAV_CHANNEL_URL="https://www.youtube.com/@SmashTrash"
$env:RYD_LIVE_NAV_SHORT="iKQhN7omLM4"
# Optional; the consecutive watch-sidebar stress defaults to three hops and accepts 1 through 10.
$env:RYD_LIVE_SIDEBAR_HOPS="3"
npm run test:live:youtube
```
The navigation smoke performs a real hard load and reload of `RYD_LIVE_NAV_CHANNEL_URL`, finds an exact visible link to
`RYD_LIVE_NAV_SHORT`, and clicks that link. It fails rather than falling back to direct navigation when the configured
channel no longer contains the exact card. It proves the channel-to-Short transition reused the same document, verifies
that the selected runtime initialized the current Short (including exactly one visible synthetic control in userscript
mode), clicks YouTube's visible **Next video** control, proves that transition also reused the document, and verifies a
new current video ID and initialized control. The channel-to-Short check runs first in the fresh suite tab so earlier
watch or Shorts visits cannot warm the page lifecycle it is meant to exercise.
YouTube can occasionally focus the visible **Next video** button without acting on its first trusted click. The live
driver waits five seconds for that first click, prints `LIVE_CHECKPOINT shorts-next-control.retrying`, and makes exactly
one more trusted click with the remaining 25-second navigation budget. If the URL still does not advance, the scenario
fails; it never loops or clicks a reaction control as part of this retry.
After the Shorts URL advances, the driver deliberately leaves playback running while YouTube hydrates the new reel,
the production `/votes` response arrives, and the current dislike control renders. It pauses only after those checks,
so a newly selected Short may play briefly before the `LIVE_CHECKPOINT playback.paused` message appears.
The watch-sidebar stress starts with the allowlisted `RYD_LIVE_WATCH_A`, then takes the first eligible visible
`#related` watch link on each page for the configured number of consecutive SPA hops. Previously visited IDs are
skipped. Every hop waits for the exact production `/votes?videoId=<target>` response and a rendered dislike count,
requires exactly one visible ratio bar for the selected runtime with valid reaction-control geometry, and samples that
same bar and count for four seconds to catch delayed YouTube pruning. It never clicks Like or Dislike. Deterministic
evidence paths are overwritten on each run at
`test-results/live-youtube/sidebar-stress/{runtime}-sidebar-hop-{01..N}.png`. The same scenario runs in userscript and
extension mode.
Every non-voting live scenario installs a BrowserContext deny route before it starts and aborts every production
`POST /interact/*` request. The accompanying request observer intentionally does not require a page frame, so attempts
from an extension or service worker also fail the scenario. The guard applies to the entire attached browser context;
do not use another tab in that Brave profile to react to a video while the smoke is running.
Automatic media-ended transitions for both watch pages and Shorts stay in the deterministic hermetic Playwright suite.
This production smoke intentionally exercises the visible Shorts **Next video** control only: live autoplay timing,
recommendation queues, ads, and account experiments are not stable enough to make an exact production transition a
reliable gate. The existing two-item playlist smoke continues to cover an explicit watch-page SPA transition.
The default channel dataset can drift as its public page changes. Override both variables with a channel URL and Short
that are deliberately kept together. The channel URL is restricted to a plain HTTPS `youtube.com/@handle` page or its
`featured`, `shorts`, or `videos` tab; the target must be an 11-character video ID.
An additional cold channel-to-watch scenario is available only when its exact link is deterministic on the configured
channel page. Opt in with an 11-character ID; otherwise that scenario is skipped:
```powershell
$env:RYD_LIVE_NAV_WATCH="DDDDDDDDDDD"
```
For the interactive Brave run, use `npm run test:live:youtube:interactive`. Before requesting any reaction approval,
it runs the read-only scenarios plus responsive visual checks. Enter `SKIP` at the prompt to finish without reactions.
The visual pass checks the watch-page ratio bar at widths 1280, 768, and 390. In userscript mode it also checks the
modern Shorts synthetic dislike control, its geometry, and its rendered count at those widths. In extension mode it
checks the active reel's native Like/Dislike pair at all three widths: exact action-host, button, and icon sizes;
typography; spacing; common reel ownership; ordering; and viewport containment. Cropped evidence images are written
under `test-results/live-youtube/responsive/`. If YouTube's native Like/Dislike pill is horizontally clipped by its own
mobile page overflow, the ratio bar may share only that same native left/right footprint; any extra overflow introduced
by the RYD bar still fails.
The interactive runner prints `LIVE_STAGE_START`, `LIVE_STAGE_COMPLETE`, and `LIVE_CHECKPOINT` records while it works.
In particular, `LIVE_CHECKPOINT playback.paused` means the runner deliberately paused the current YouTube video while
validating it; a stationary frame after that message is expected and is not evidence that Brave froze. Navigation,
account, runtime, control, and production `/votes` waits have their own checkpoints, so the last line identifies the
pending operation.
If a scenario fails, the runner records page errors, unhandled promise rejections, console errors, and the latest RYD
API request outcomes before closing its test tab. It writes a JSON snapshot under
`test-results/live-youtube/diagnostics/` and prints its absolute path as `LIVE_FAILURE_SNAPSHOT`. The snapshot includes
the URL, runtime markers, Shorts renderer IDs and links, action-bar and synthetic-control ownership, video paused state,
and recent API paths/statuses. Anonymous identity and proof-related query values are redacted. Preserve this file when
reporting a live-only failure; ordinary direct interactive runs otherwise have no Playwright trace.
Use `RYD_LIVE_RUNTIME="extension"` after manually switching the enabled runtime to execute the same smoke against the
extension. The expected version defaults to the local userscript candidate version or root package version; set
`RYD_LIVE_EXPECTED_VERSION` only when deliberately validating another installed build.
## Optional production reaction matrix
The reaction test is skipped by default. It covers all six Like/Dislike state transitions on `RYD_LIVE_WATCH_B` and the
allowlisted Short, asserts one logical production handshake for every transition, and returns each video to its initial
reaction state. A handshake contains one to three matching `/interact/vote` puzzle requests followed by exactly one
matching successful `/interact/confirmVote`. Any fourth vote request, changed identity/video/value, extra confirmation,
or other interaction traffic fails the run. It does not retry cleanup blindly if the state cannot be verified.
If click dispatch, the post-click state wait, or a handshake fails after a vote attempt while the UI already appears to
be back at its initial state, cleanup does not trust the UI alone: it confirms an away-and-back reaction round trip with
the same anonymous identity. A failed cleanup confirmation or identity mismatch reports the exact video URL for manual
restoration.
The matrix also captures the initial state and every post-transition state, for seven watch images and seven Shorts
images. Before each screenshot it reads both `aria-pressed` values, verifies the exact expected mutually-exclusive
state, and requires a numeric dislike count. Watch captures additionally require a visible, non-overlapping ratio bar
with sane geometry. Userscript Shorts captures reuse the strict native-vs-synthetic control geometry checks, including
the 48x78 action host, 24x24 icon, typography, spacing, active-reel ownership, and duplicate-control checks. Evidence is
written with deterministic names under
`test-results/live-youtube/reactions/{runtime}/{watch|short}-{0..6}-{neutral|liked|disliked}.png`. A failed visual check
still enters the same verified restoration path before the scenario reports the failure.
The exact live-build marker proves that the freshly generated test build is present and that the opposite runtime is
absent. It cannot detect an additional normal build of the same runtime, so the manual single-runtime preparation above
is mandatory.
Only after approving those real YouTube and production RYD writes, create a runtime-and-video-specific token immediately
before the run. It expires after two minutes and is consumed once locally, so the userscript token cannot silently
authorize a later extension run.
```powershell
$env:RYD_LIVE_VOTES="$env:RYD_LIVE_RUNTIME`:$env:RYD_LIVE_WATCH_B`:$([DateTimeOffset]::UtcNow.ToUnixTimeSeconds())"
npm run test:live:youtube
Remove-Item Env:RYD_LIVE_VOTES
```
After the live run, turn off **Allow remote debugging for this browser instance** or restart the test Brave profile.
@@ -0,0 +1,358 @@
const fs = require("node:fs");
const path = require("node:path");
const API_ORIGIN = "https://returnyoutubedislikeapi.com";
const UNHANDLED_REJECTION_PREFIX = "__RYD_LIVE_UNHANDLED_REJECTION__";
const MAX_BROWSER_SIGNALS = 50;
const MAX_API_REQUESTS = 50;
function serializeError(error) {
if (!error) return null;
if (typeof error === "string") return { message: error, name: "Error", stack: null };
return {
message: String(error.message ?? error),
name: String(error.name ?? "Error"),
stack: typeof error.stack === "string" ? error.stack : null,
};
}
function installUnhandledRejectionListener(prefix) {
if (globalThis.__rydLiveUnhandledRejectionListenerInstalled) return;
globalThis.__rydLiveUnhandledRejectionListenerInstalled = true;
globalThis.addEventListener("unhandledrejection", (event) => {
const reason = event.reason;
let message;
try {
if (reason instanceof Error)
message = `${reason.name}: ${reason.message}${reason.stack ? `\n${reason.stack}` : ""}`;
else if (typeof reason === "string") message = reason;
else message = JSON.stringify(reason);
} catch {
message = String(reason);
}
console.error(`${prefix}${message ?? "Unknown rejection"}`);
});
}
function readLivePageState() {
const readRect = (element) => {
const rect = element.getBoundingClientRect();
return {
bottom: rect.bottom,
height: rect.height,
left: rect.left,
right: rect.right,
top: rect.top,
width: rect.width,
};
};
const isVisible = (element) => {
const rect = element.getBoundingClientRect();
const style = getComputedStyle(element);
return (
rect.width > 0 &&
rect.height > 0 &&
rect.bottom > 0 &&
rect.right > 0 &&
rect.top < innerHeight &&
rect.left < innerWidth &&
style.display !== "none" &&
style.visibility !== "hidden"
);
};
const actionBarSelector = "reel-action-bar-view-model, .slim-video-action-bar-actions";
const syntheticSelector = "[data-ryd-synthetic-shorts-dislike]";
const renderers = [...document.querySelectorAll("ytd-reel-video-renderer, ytm-reel-video-renderer")];
return {
document: {
readyState: document.readyState,
title: document.title,
url: location.href,
viewport: { height: innerHeight, width: innerWidth },
},
runtimeMarkers: {
extensionBuild: document.documentElement.getAttribute("data-ryd-extension-build"),
extension: document.documentElement.getAttribute("data-ryd-extension-version"),
userscriptBuild: document.documentElement.getAttribute("data-ryd-userscript-build"),
userscript: document.documentElement.getAttribute("data-ryd-userscript-version"),
},
renderers: renderers.map((renderer, index) => ({
actionBars: renderer.querySelectorAll(actionBarSelector).length,
ariaHidden: renderer.getAttribute("aria-hidden"),
index,
isActive: renderer.getAttribute("is-active"),
links: [...renderer.querySelectorAll('a[href*="/shorts/"]')].map((link) => ({
href: link.getAttribute("href"),
visible: isVisible(link),
})),
rect: readRect(renderer),
syntheticControls: renderer.querySelectorAll(syntheticSelector).length,
tagName: renderer.tagName.toLowerCase(),
videoId: renderer.getAttribute("video-id"),
visible: isVisible(renderer),
})),
actionBars: [...document.querySelectorAll(actionBarSelector)].map((actionBar, index) => ({
index,
nativeDislikes: actionBar.querySelectorAll("dislike-button-view-model, #dislike-button").length,
nativeLikes: actionBar.querySelectorAll("like-button-view-model, #like-button").length,
rect: readRect(actionBar),
syntheticControls: actionBar.querySelectorAll(syntheticSelector).length,
tagName: actionBar.tagName.toLowerCase(),
videoId: actionBar.closest("ytd-reel-video-renderer, ytm-reel-video-renderer")?.getAttribute("video-id") ?? null,
visible: isVisible(actionBar),
})),
syntheticControls: [...document.querySelectorAll(syntheticSelector)].map((control, index) => ({
ariaPressed: control.querySelector("button")?.getAttribute("aria-pressed") ?? null,
index,
rect: readRect(control),
text: (control.textContent ?? "").replace(/\s+/g, " ").trim(),
videoId: control.getAttribute("data-ryd-video-id"),
visible: isVisible(control),
})),
videos: [...document.querySelectorAll("video")].map((video, index) => ({
currentTime: video.currentTime,
ended: video.ended,
index,
paused: video.paused,
readyState: video.readyState,
rect: readRect(video),
visible: isVisible(video),
})),
};
}
function diagnosticApiUrl(value) {
let url;
try {
url = new URL(value);
} catch {
return null;
}
if (url.origin !== API_ORIGIN) return null;
const query = {};
for (const [name, requestValue] of url.searchParams) {
query[name] = /auth|key|puzzle|secret|solution|token|user/i.test(name) ? "<redacted>" : requestValue;
}
return { pathname: url.pathname, query };
}
function timestampForFilename(date) {
return date.toISOString().replace(/[:.]/g, "-");
}
class LiveRunDiagnostics {
constructor(
page,
context,
{
clock = () => new Date(),
fileSystem = fs,
log = console.log,
outputDirectory = path.resolve(__dirname, "../../../../test-results/live-youtube/diagnostics"),
runtime = null,
} = {},
) {
this.browserSignals = [];
this.clock = clock;
this.context = context;
this.currentCheckpoint = null;
this.currentStage = "startup";
this.fileSystem = fileSystem;
this.log = log;
this.outputDirectory = outputDirectory;
this.page = page;
this.recentApiRequests = [];
this.requestRecords = new WeakMap();
this.runtime = runtime;
this.started = false;
this.onConsole = this.onConsole.bind(this);
this.onPageError = this.onPageError.bind(this);
this.onRequest = this.onRequest.bind(this);
this.onRequestFailed = this.onRequestFailed.bind(this);
this.onResponse = this.onResponse.bind(this);
}
now() {
return this.clock().toISOString();
}
appendCapped(collection, value, maximum) {
collection.push(value);
if (collection.length > maximum) collection.splice(0, collection.length - maximum);
}
recordBrowserSignal(type, details) {
const signal = {
at: this.now(),
checkpoint: this.currentCheckpoint,
stage: this.currentStage,
type,
...details,
};
this.appendCapped(this.browserSignals, signal, MAX_BROWSER_SIGNALS);
this.log(`LIVE_BROWSER_SIGNAL ${type} ${JSON.stringify(details)}`);
}
onPageError(error) {
this.recordBrowserSignal("pageerror", { error: serializeError(error) });
}
onConsole(message) {
if (message.type() !== "error") return;
const text = message.text();
const location = message.location?.() ?? {};
if (text.startsWith(UNHANDLED_REJECTION_PREFIX)) {
this.recordBrowserSignal("unhandledrejection", {
location,
message: text.slice(UNHANDLED_REJECTION_PREFIX.length),
});
return;
}
this.recordBrowserSignal("console.error", { location, message: text });
}
onRequest(request) {
const apiUrl = diagnosticApiUrl(request.url());
if (!apiUrl) return;
const record = {
...apiUrl,
at: this.now(),
failure: null,
method: request.method(),
resourceType: typeof request.resourceType === "function" ? request.resourceType() : null,
checkpoint: this.currentCheckpoint,
stage: this.currentStage,
status: null,
};
this.appendCapped(this.recentApiRequests, record, MAX_API_REQUESTS);
this.requestRecords.set(request, record);
}
onResponse(response) {
const record = this.requestRecords.get(response.request());
if (!record) return;
record.status = response.status();
}
onRequestFailed(request) {
const record = this.requestRecords.get(request);
if (!record) return;
record.failure = request.failure()?.errorText ?? "Unknown request failure";
}
async start() {
if (this.started) return;
this.started = true;
this.page.on("console", this.onConsole);
this.page.on("pageerror", this.onPageError);
this.context.on("request", this.onRequest);
this.context.on("requestfailed", this.onRequestFailed);
this.context.on("response", this.onResponse);
await this.page.addInitScript(installUnhandledRejectionListener, UNHANDLED_REJECTION_PREFIX);
if (!this.page.isClosed()) {
await this.page.evaluate(installUnhandledRejectionListener, UNHANDLED_REJECTION_PREFIX);
}
}
stop() {
if (!this.started) return;
this.started = false;
this.page.off("console", this.onConsole);
this.page.off("pageerror", this.onPageError);
this.context.off("request", this.onRequest);
this.context.off("requestfailed", this.onRequestFailed);
this.context.off("response", this.onResponse);
}
checkpoint(name, details = {}) {
this.currentCheckpoint = name;
this.log(`LIVE_CHECKPOINT ${name} ${JSON.stringify(details)}`);
}
stageStarted(name) {
this.currentCheckpoint = null;
this.currentStage = name;
this.log(`LIVE_STAGE_START ${name}`);
}
stageCompleted(name, startedAt) {
this.currentCheckpoint = null;
this.currentStage = name;
this.log(`LIVE_STAGE_COMPLETE ${name} durationMs=${Date.now() - startedAt}`);
}
stageFailed(name, startedAt, error) {
this.currentStage = name;
this.log(
`LIVE_STAGE_FAILED ${name} durationMs=${Date.now() - startedAt} error=${JSON.stringify(error?.message ?? String(error))}`,
);
}
async snapshot(error) {
let pageState = null;
let pageStateError = null;
let url = null;
try {
url = this.page.url();
} catch {
// A disconnected page still produces a useful harness/request snapshot.
}
if (!this.page.isClosed()) {
try {
pageState = await this.page.evaluate(readLivePageState);
} catch (snapshotError) {
pageStateError = serializeError(snapshotError);
}
}
return {
browserSignals: this.browserSignals,
capturedAt: this.now(),
currentCheckpoint: this.currentCheckpoint,
currentStage: this.currentStage,
error: serializeError(error),
pageClosed: this.page.isClosed(),
pageState,
pageStateError,
recentApiRequests: this.recentApiRequests,
runtime: this.runtime,
url,
};
}
async persistFailureSnapshot(error) {
const snapshot = await this.snapshot(error);
this.fileSystem.mkdirSync(this.outputDirectory, { recursive: true });
const fileName = `failure-${timestampForFilename(this.clock())}.json`;
const snapshotPath = path.join(this.outputDirectory, fileName);
this.fileSystem.writeFileSync(snapshotPath, `${JSON.stringify(snapshot, null, 2)}\n`, "utf8");
return snapshotPath;
}
}
async function runLoggedStage(diagnostics, name, action) {
const startedAt = Date.now();
diagnostics.stageStarted(name);
try {
const result = await action();
diagnostics.stageCompleted(name, startedAt);
return result;
} catch (error) {
diagnostics.stageFailed(name, startedAt, error);
throw error;
}
}
module.exports = {
API_ORIGIN,
LiveRunDiagnostics,
UNHANDLED_REJECTION_PREFIX,
diagnosticApiUrl,
installUnhandledRejectionListener,
readLivePageState,
runLoggedStage,
serializeError,
};
@@ -0,0 +1,138 @@
const readline = require("node:readline");
const { chromium } = require("@playwright/test");
const {
createExtensionLiveRuntimeAdapter,
createUserscriptLiveRuntimeAdapter,
} = require("../../../e2e/live-runtime-adapter");
const { createSharedLiveScenarioRunner } = require("../../../e2e/shared-live-scenarios");
const { consumeLiveVoteApproval, hasFreshVoteApproval, readLiveOptions } = require("../../live/live-options");
const { LiveRunDiagnostics, runLoggedStage } = require("./live-diagnostics");
const { LiveYoutubeDriver, VoteTrafficRecorder } = require("./live-youtube-driver");
const scenarioRunner = createSharedLiveScenarioRunner();
function readApprovalLine() {
const input = readline.createInterface({ input: process.stdin, terminal: false });
return new Promise((resolve) => {
input.once("line", (line) => {
input.close();
resolve(line.trim());
});
});
}
async function main() {
const options = readLiveOptions();
if (!options) throw new Error("Set RYD_LIVE_YOUTUBE=1 and the documented allowlist variables to opt in.");
let browser;
let diagnostics;
let page;
try {
console.log("WAITING_FOR_BRAVE_DEBUG_APPROVAL");
browser = await chromium.connectOverCDP(options.cdpEndpoint, {
isLocal: true,
noDefaults: true,
timeout: 120_000,
});
const [context] = browser.contexts();
if (!context) throw new Error("The attached Chromium browser has no default context.");
page = await context.newPage();
diagnostics = new LiveRunDiagnostics(page, context, { runtime: options.runtime });
await diagnostics.start();
const driver = new LiveYoutubeDriver(page, context, {
reportProgress: (name, details) => diagnostics.checkpoint(name, details),
});
const createAdapter =
options.runtime === "extension" ? createExtensionLiveRuntimeAdapter : createUserscriptLiveRuntimeAdapter;
const runtimeAdapter = createAdapter({
driver,
expectedBuildId: options.expectedBuildId,
expectedVersion: options.expectedVersion,
});
console.log("BRAVE_CONNECTED");
const readOnly = {};
readOnly.channelShorts = await runLoggedStage(diagnostics, "read-only.channel-to-shorts-and-next", () =>
scenarioRunner.run(runtimeAdapter, "channel-shorts-navigation", options),
);
if (options.navigation.watch) {
readOnly.channelWatch = await runLoggedStage(diagnostics, "read-only.channel-to-watch", () =>
scenarioRunner.run(runtimeAdapter, "channel-watch-navigation", options),
);
} else {
readOnly.channelWatch = null;
diagnostics.checkpoint("read-only.channel-to-watch.skipped", {
reason: "RYD_LIVE_NAV_WATCH is not configured",
});
}
readOnly.reload = await runLoggedStage(diagnostics, "read-only.reload", () =>
scenarioRunner.run(runtimeAdapter, "reload", options),
);
readOnly.responsive = await runLoggedStage(diagnostics, "read-only.responsive-visual", () =>
scenarioRunner.run(runtimeAdapter, "responsive-visual", options),
);
readOnly.short = await runLoggedStage(diagnostics, "read-only.short-render", () =>
scenarioRunner.run(runtimeAdapter, "shorts-render", options),
);
readOnly.spa = await runLoggedStage(diagnostics, "read-only.playlist-spa", () =>
scenarioRunner.run(runtimeAdapter, "spa-navigation", options),
);
readOnly.sidebarStress = await runLoggedStage(diagnostics, "read-only.sidebar-stress", () =>
scenarioRunner.run(runtimeAdapter, "sidebar-navigation-stress", options),
);
readOnly.watch = await runLoggedStage(diagnostics, "read-only.watch-render", () =>
scenarioRunner.run(runtimeAdapter, "watch-render", options),
);
console.log(`READ_ONLY_COMPLETE ${JSON.stringify(readOnly)}`);
console.log(
`READY_FOR_REACTION_APPROVAL runtime=${options.runtime} watch=${options.watchB} short=${options.short}`,
);
diagnostics.checkpoint("reaction-approval.waiting", {
instruction: "Enter SKIP to close the live-test tab without production reactions",
});
const approval = await readApprovalLine();
if (approval === "SKIP") {
console.log("REACTION_MATRIX_SKIPPED");
return;
}
if (!hasFreshVoteApproval(approval, options.runtime, options.watchB, Date.now())) {
throw new Error("The supplied live reaction approval is missing, expired, or does not match this run.");
}
const reaction = await runLoggedStage(diagnostics, "reaction-matrix", () =>
scenarioRunner.run(runtimeAdapter, "reaction-matrix", options, {
createRecorder: (videoId) => new VoteTrafficRecorder(context, videoId),
consumeVoteApproval: async () => {
driver.assertCurrentVideo(options.watchB);
await driver.assertRuntime(options.runtime, options.expectedVersion, options.expectedBuildId);
if (!consumeLiveVoteApproval(approval, options.runtime, options.watchB)) {
throw new Error("The live reaction approval expired or was already used. No reaction was clicked.");
}
},
}),
);
console.log(`REACTION_MATRIX_COMPLETE ${JSON.stringify(reaction)}`);
} catch (error) {
if (diagnostics) {
try {
const snapshotPath = await diagnostics.persistFailureSnapshot(error);
console.error(`LIVE_FAILURE_SNAPSHOT ${snapshotPath}`);
} catch (snapshotError) {
console.error(`LIVE_FAILURE_SNAPSHOT_FAILED ${snapshotError.message}`);
}
}
throw error;
} finally {
diagnostics?.stop();
if (page && !page.isClosed()) await page.close();
if (browser) await browser.close();
}
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
@@ -0,0 +1,568 @@
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const { WATCH_RATIO_SOAK_DURATION_MS } = require("./live-youtube-driver");
const RESPONSIVE_VIEWPORTS = [
{ height: 720, width: 1280 },
{ height: 720, width: 768 },
{ height: 844, width: 390 },
];
const REACTION_CYCLES = {
neutral: ["like", "like", "dislike", "like", "dislike", "dislike"],
liked: ["like", "dislike", "like", "dislike", "dislike", "like"],
disliked: ["like", "dislike", "dislike", "like", "like", "dislike"],
};
function nextReactionState(state, action) {
if (action === "like") return state === "liked" ? "neutral" : "liked";
if (action === "dislike") return state === "disliked" ? "neutral" : "disliked";
throw new Error(`Unsupported reaction action: ${action}`);
}
function reactionValue(state) {
if (state === "liked") return 1;
if (state === "disliked") return -1;
if (state === "neutral") return 0;
throw new Error(`Unsupported reaction state: ${state}`);
}
async function assertLivePreconditions(driver, options) {
await driver.assertSignedIn(options.expectedChannel);
await driver.assertRuntime(options.runtime, options.expectedVersion, options.expectedBuildId);
}
async function runWatchRenderScenario(driver, options) {
return driver.withNoProductionInteractions(async () => {
await driver.openPlaylist(options.playlistUrl, options.watchA);
await assertLivePreconditions(driver, options);
return driver.waitForDislikeText();
});
}
async function runReloadScenario(driver, options) {
return driver.withNoProductionInteractions(async () => {
await driver.openPlaylist(options.playlistUrl, options.watchA);
await assertLivePreconditions(driver, options);
await driver.waitForDislikeText();
await driver.reload(options.watchA);
await assertLivePreconditions(driver, options);
return driver.waitForDislikeText();
});
}
async function runSpaNavigationScenario(driver, options) {
return driver.withNoProductionInteractions(async () => {
await driver.openPlaylist(options.playlistUrl, options.watchA);
await assertLivePreconditions(driver, options);
const watchACount = await driver.waitForDislikeText();
await driver.navigateWithinPlaylist(options.watchB);
await assertLivePreconditions(driver, options);
const watchBCount = await driver.waitForDislikeText({ differentFrom: watchACount });
assert.match(watchACount, /\d/);
assert.match(watchBCount, /\d/);
assert.notEqual(
watchBCount,
watchACount,
"Choose allowlisted playlist videos whose rendered dislike counts differ so stale SPA UI can be detected.",
);
return { watchACount, watchBCount };
});
}
async function runSidebarStressScenario(
driver,
options,
{
makeDirectory = (directory) => fs.mkdirSync(directory, { recursive: true }),
outputDirectory = path.resolve(__dirname, "../../../../test-results/live-youtube/sidebar-stress"),
readyTimeoutMs = 1_000,
soakDurationMs = WATCH_RATIO_SOAK_DURATION_MS,
} = {},
) {
const hopCount = options.sidebar?.hopCount;
assert.ok(Number.isSafeInteger(hopCount) && hopCount > 0, "A positive sidebar stress hop count is required.");
assert.ok(
Number.isFinite(readyTimeoutMs) && readyTimeoutMs > 0,
"A positive ratio-bar readiness budget is required.",
);
makeDirectory(outputDirectory);
return driver.withNoProductionInteractions(async () => {
await driver.openWatch(options.watchA);
await assertLivePreconditions(driver, options);
const visitedVideoIds = [options.watchA];
const hops = [];
for (let index = 0; index < hopCount; index += 1) {
const { body, videoId } = await driver.navigateToRelatedWatch(visitedVideoIds);
assert.equal(typeof body.dislikes, "number", `Sidebar hop ${index + 1} has no production dislike count.`);
assert.ok(!visitedVideoIds.includes(videoId), `Sidebar hop ${index + 1} revisited ${videoId}.`);
driver.assertCurrentVideo(videoId);
await assertLivePreconditions(driver, options);
const screenshotPath = path.join(
outputDirectory,
`${options.runtime}-sidebar-hop-${String(index + 1).padStart(2, "0")}.png`,
);
const readinessStartedAt = Date.now();
const visual = await driver.captureWatchRatioVisual(options.runtime, screenshotPath, {
presenceTimeoutMs: readyTimeoutMs,
});
const readyLatencyMs = Date.now() - readinessStartedAt;
assert.ok(
readyLatencyMs <= readyTimeoutMs,
`Sidebar hop ${index + 1} ratio bar took ${readyLatencyMs}ms; the budget is ${readyTimeoutMs}ms.`,
);
assert.match(visual.count, /\d/, `Sidebar hop ${index + 1} did not render a dislike count.`);
const soak = await driver.soakWatchRatioVisual(options.runtime, {
durationMs: soakDurationMs,
expectedCount: visual.count,
videoId,
});
visitedVideoIds.push(videoId);
hops.push({
apiDislikes: body.dislikes,
count: visual.count,
readyLatencyMs,
readyTimeoutMs,
screenshotPath,
soak,
videoId,
});
}
return { hopCount, hops, outputDirectory, startVideoId: options.watchA };
});
}
async function runShortsRenderScenario(driver, options) {
return driver.withNoProductionInteractions(async () => {
await driver.openShort(options.short);
await assertLivePreconditions(driver, options);
return driver.waitForDislikeText();
});
}
async function runChannelShortsNavigationScenario(driver, options) {
return driver.withNoProductionInteractions(async () => {
await driver.navigateFromColdChannelToShort(options.navigation.channelUrl, options.navigation.short);
await assertLivePreconditions(driver, options);
const initial = await driver.assertCurrentShortsControl(options.navigation.short, options.runtime);
const nextVideoId = await driver.navigateToNextShort(options.navigation.short);
await driver.assertRuntime(options.runtime, options.expectedVersion, options.expectedBuildId);
const next = await driver.assertCurrentShortsControl(nextVideoId, options.runtime);
await driver.pausePlayback();
assert.notEqual(
next.videoId,
initial.videoId,
"The Shorts Next video scenario did not change the current video ID.",
);
return { initial, next };
});
}
async function runChannelWatchNavigationScenario(driver, options) {
if (!options.navigation.watch) {
throw new Error("RYD_LIVE_NAV_WATCH is required for the optional channel-to-watch scenario.");
}
return driver.withNoProductionInteractions(async () => {
await driver.navigateFromColdChannelToWatch(options.navigation.channelUrl, options.navigation.watch);
await assertLivePreconditions(driver, options);
const count = await driver.waitForDislikeText();
return { count, videoId: options.navigation.watch };
});
}
async function runResponsiveVisualScenario(
driver,
options,
{
makeDirectory = (directory) => fs.mkdirSync(directory, { recursive: true }),
outputDirectory = path.resolve(__dirname, "../../../../test-results/live-youtube/responsive"),
} = {},
) {
makeDirectory(outputDirectory);
return driver.withNoProductionInteractions(async () => {
const originalViewport = await driver.readViewportSize();
const watch = [];
const shorts = [];
try {
await driver.setViewportSize(RESPONSIVE_VIEWPORTS[0]);
await driver.openWatch(options.watchA);
await assertLivePreconditions(driver, options);
for (let index = 0; index < RESPONSIVE_VIEWPORTS.length; index += 1) {
const viewport = RESPONSIVE_VIEWPORTS[index];
if (index > 0) await driver.setViewportSize(viewport);
await driver.waitForDislikeText();
watch.push(
await driver.captureWatchRatioVisual(
options.runtime,
path.join(outputDirectory, `${options.runtime}-watch-ratio-${viewport.width}.png`),
),
);
}
await driver.setViewportSize(RESPONSIVE_VIEWPORTS[0]);
await driver.openShort(options.short);
await assertLivePreconditions(driver, options);
for (let index = 0; index < RESPONSIVE_VIEWPORTS.length; index += 1) {
const viewport = RESPONSIVE_VIEWPORTS[index];
if (index > 0) await driver.setViewportSize(viewport);
await driver.assertCurrentShortsControl(options.short, options.runtime);
await driver.waitForDislikeText();
const screenshotPath = path.join(outputDirectory, `${options.runtime}-shorts-control-${viewport.width}.png`);
shorts.push(
options.runtime === "userscript"
? await driver.captureSyntheticShortsVisual(options.short, screenshotPath)
: await driver.captureNativeShortsVisual(options.short, screenshotPath),
);
}
} finally {
await driver.setViewportSize(originalViewport);
}
return {
outputDirectory,
shorts,
shortsSkipped: null,
watch,
};
});
}
async function restoreReactionStateUnchecked(
driver,
recorder,
options,
videoId,
initialState,
expectedUserId,
isShort,
failedAttempt,
) {
try {
driver.assertCurrentVideo(videoId);
} catch {
if (isShort) await driver.openShort(videoId);
else await driver.openWatch(videoId);
await assertLivePreconditions(driver, options);
}
const currentState = await driver.readReactionState();
if (currentState === initialState && !failedAttempt) return;
const attemptedUserId = failedAttempt?.userId;
const assertCleanupUserId = (userId, roundTripUserId) => {
assert.equal(typeof userId, "string", "The cleanup reaction has no anonymous RYD identity.");
if (expectedUserId) {
assert.equal(userId, expectedUserId, "The cleanup reaction used a different anonymous RYD identity.");
}
if (attemptedUserId) {
assert.equal(userId, attemptedUserId, "The cleanup reaction did not use the failed attempt's RYD identity.");
}
if (roundTripUserId) {
assert.equal(userId, roundTripUserId, "The cleanup round trip changed anonymous RYD identity.");
}
};
const performCleanupTransition = async (action, targetState, roundTripUserId) => {
const mark = recorder.mark();
await driver.clickAction(videoId, action);
await driver.waitForReactionState(targetState);
const userId = await recorder.waitForHandshake(reactionValue(targetState), mark);
assertCleanupUserId(userId, roundTripUserId);
return userId;
};
if (currentState === initialState) {
const action = initialState === "liked" ? "like" : "dislike";
const awayState = initialState === "neutral" ? "disliked" : "neutral";
const awayUserId = await performCleanupTransition(action, awayState);
await performCleanupTransition(action, initialState, awayUserId);
return;
}
const action =
initialState === "neutral"
? currentState === "liked"
? "like"
: "dislike"
: initialState === "liked"
? "like"
: "dislike";
await performCleanupTransition(action, initialState);
}
async function restoreReactionState(
driver,
recorder,
options,
videoId,
initialState,
expectedUserId,
isShort,
failedAttempt,
) {
try {
await restoreReactionStateUnchecked(
driver,
recorder,
options,
videoId,
initialState,
expectedUserId,
isShort,
failedAttempt,
);
} catch (error) {
const url = isShort ? `https://www.youtube.com/shorts/${videoId}` : `https://www.youtube.com/watch?v=${videoId}`;
throw new Error(`Automatic cleanup could not be verified. Manually restore ${url}. ${error.message}`, {
cause: error,
});
}
}
async function runReactionCycle(
driver,
recorder,
options,
{ beforeFirstAction, captureReactionVisual = null, isShort = false, videoId },
) {
if (isShort) await driver.openShort(videoId);
else await driver.openWatch(videoId);
await assertLivePreconditions(driver, options);
await driver.waitForDislikeText();
const initialState = await driver.readReactionState();
const actions = REACTION_CYCLES[initialState];
assert.ok(actions, `Unsupported initial YouTube reaction state: ${initialState}`);
let authorized = false;
let completed = false;
let expectedUserId;
let failedAttempt;
let currentState = initialState;
const evidencePaths = [];
const captureEvidence = async (index) => {
if (!captureReactionVisual) return;
const screenshotPath = await captureReactionVisual({ index, state: currentState });
assert.equal(typeof screenshotPath, "string", "The reaction visual capture did not return an evidence path.");
evidencePaths.push(screenshotPath);
};
try {
await captureEvidence(0);
driver.assertCurrentVideo(videoId);
await assertLivePreconditions(driver, options);
if (beforeFirstAction) await beforeFirstAction();
authorized = true;
for (let index = 0; index < actions.length; index += 1) {
const action = actions[index];
const expectedState = nextReactionState(currentState, action);
const mark = recorder.mark();
const value = reactionValue(expectedState);
failedAttempt = { mark, userId: undefined, value };
await driver.clickAction(videoId, action);
await driver.waitForReactionState(expectedState);
const userId = await recorder.waitForHandshake(value, mark);
failedAttempt.userId = userId;
if (expectedUserId) assert.equal(userId, expectedUserId, "The transition cycle changed anonymous RYD identity.");
expectedUserId = userId;
failedAttempt = undefined;
currentState = expectedState;
await driver.waitForDislikeText();
await captureEvidence(index + 1);
}
assert.equal(currentState, initialState, "The six-transition cycle did not return to its initial state.");
completed = true;
return { evidencePaths, initialState, userId: expectedUserId };
} finally {
if (authorized && !completed) {
if (failedAttempt && !failedAttempt.userId && typeof recorder.voteUserId === "function") {
try {
failedAttempt.userId = recorder.voteUserId(failedAttempt.value, failedAttempt.mark);
} catch {
failedAttempt.userId = undefined;
}
}
await restoreReactionState(
driver,
recorder,
options,
videoId,
initialState,
expectedUserId,
isShort,
failedAttempt,
);
}
}
}
async function runProductionReactionMatrixScenario(
driver,
options,
createRecorder,
consumeVoteApproval,
visualOptions = {},
) {
const makeDirectory = visualOptions.makeDirectory ?? ((directory) => fs.mkdirSync(directory, { recursive: true }));
const outputDirectory =
visualOptions.outputDirectory ??
path.resolve(__dirname, "../../../../test-results/live-youtube/reactions", options.runtime);
makeDirectory(outputDirectory);
const captureFor =
(kind, videoId, isShort) =>
async ({ index, state }) => {
const screenshotPath = path.join(outputDirectory, `${kind}-${index}-${state}.png`);
const evidence = await driver.captureReactionStateVisual({
expectedState: state,
isShort,
runtime: options.runtime,
screenshotPath,
videoId,
});
assert.equal(
evidence?.screenshotPath,
screenshotPath,
`The ${kind} reaction capture did not write the requested evidence path.`,
);
return screenshotPath;
};
const watchRecorder = createRecorder(options.watchB);
let watchResult;
try {
watchResult = await runReactionCycle(driver, watchRecorder, options, {
beforeFirstAction: consumeVoteApproval,
captureReactionVisual: captureFor("watch", options.watchB, false),
videoId: options.watchB,
});
} finally {
watchRecorder.stop();
}
const shortRecorder = createRecorder(options.short);
try {
const shortResult = await runReactionCycle(driver, shortRecorder, options, {
captureReactionVisual: captureFor("short", options.short, true),
isShort: true,
videoId: options.short,
});
assert.equal(
shortResult.userId,
watchResult.userId,
"Watch and Shorts votes used different anonymous RYD identities.",
);
return {
evidencePaths: [...watchResult.evidencePaths, ...shortResult.evidencePaths],
outputDirectory,
short: shortResult,
watch: watchResult,
};
} finally {
shortRecorder.stop();
}
}
async function restoreNeutralStateUnchecked(driver, recorder, options, dislikeMark, expectedUserId) {
let state;
try {
driver.assertCurrentVideo(options.watchB);
state = await driver.readVoteState();
} catch {
await driver.openWatch(options.watchB);
await assertLivePreconditions(driver, options);
state = await driver.readVoteState();
}
if (state === "false") {
if (recorder.hasVote(-1, dislikeMark)) {
throw new Error(
`The YouTube button is neutral but the production -1 vote may remain. Manually verify and restore https://www.youtube.com/watch?v=${options.watchB}.`,
);
}
return;
}
if (state !== "true") {
throw new Error(
`Could not verify the YouTube dislike state. Manually restore https://www.youtube.com/watch?v=${options.watchB}.`,
);
}
const neutralMark = recorder.mark();
await driver.clickDislike(options.watchB);
await driver.waitForVoteState(false);
const neutralUserId = await recorder.waitForHandshake(0, neutralMark);
if (expectedUserId && neutralUserId !== expectedUserId) {
throw new Error(
`The neutral vote used a different identity from the dislike vote. Manually verify and restore https://www.youtube.com/watch?v=${options.watchB}.`,
);
}
}
async function restoreNeutralState(driver, recorder, options, dislikeMark, expectedUserId) {
try {
await restoreNeutralStateUnchecked(driver, recorder, options, dislikeMark, expectedUserId);
} catch (error) {
throw new Error(
`Automatic cleanup could not be verified. Manually restore https://www.youtube.com/watch?v=${options.watchB}. ${error.message}`,
{ cause: error },
);
}
}
async function runReversibleVoteScenario(driver, recorder, options, consumeVoteApproval) {
await driver.openWatch(options.watchB);
await assertLivePreconditions(driver, options);
await driver.waitForDislikeText();
const initialLikeState = await driver.readLikeState();
const initialDislikeState = await driver.readVoteState();
assert.equal(initialLikeState, "false", "The allowlisted vote video must not already be liked.");
assert.equal(initialDislikeState, "false", "The allowlisted vote video must not already be disliked.");
const dislikeMark = recorder.mark();
let cleanupRequired = false;
let dislikeUserId;
try {
driver.assertCurrentVideo(options.watchB);
await assertLivePreconditions(driver, options);
await consumeVoteApproval();
cleanupRequired = true;
await driver.clickDislike(options.watchB);
await driver.waitForVoteState(true);
dislikeUserId = await recorder.waitForHandshake(-1, dislikeMark);
} finally {
if (cleanupRequired) {
await restoreNeutralState(
driver,
recorder,
options,
dislikeMark,
dislikeUserId || recorder.voteUserId(-1, dislikeMark),
);
}
}
await driver.waitForDislikeText();
}
module.exports = {
RESPONSIVE_VIEWPORTS,
runChannelShortsNavigationScenario,
runChannelWatchNavigationScenario,
runProductionReactionMatrixScenario,
runReactionCycle,
runReloadScenario,
runReversibleVoteScenario,
runResponsiveVisualScenario,
runSidebarStressScenario,
runShortsRenderScenario,
runSpaNavigationScenario,
runWatchRenderScenario,
};
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,113 @@
const { chromium, test } = require("@playwright/test");
const {
createExtensionLiveRuntimeAdapter,
createUserscriptLiveRuntimeAdapter,
} = require("../../../e2e/live-runtime-adapter");
const { createSharedLiveScenarioRunner } = require("../../../e2e/shared-live-scenarios");
const { consumeLiveVoteApproval, hasFreshVoteApproval, readLiveOptions } = require("../../live/live-options");
const { LiveYoutubeDriver, VoteTrafficRecorder } = require("./live-youtube-driver");
const options = readLiveOptions();
const scenarioRunner = createSharedLiveScenarioRunner();
test.describe("live YouTube RYD smoke", () => {
test.skip(options === null, "Set RYD_LIVE_YOUTUBE=1 and the documented allowlist variables to opt in.");
test.describe.configure({ mode: "serial" });
let browser;
let context;
let page;
let driver;
let runtimeAdapter;
test.beforeAll(async () => {
try {
browser = await chromium.connectOverCDP(options.cdpEndpoint, {
isLocal: true,
noDefaults: true,
timeout: 15_000,
});
} catch (error) {
throw new Error(
`Could not attach to the running Brave/Chromium profile. Enable remote debugging at brave://inspect/#remote-debugging and set RYD_CDP_ENDPOINT to its explicit CDP endpoint, then retry. ${error.message}`,
);
}
[context] = browser.contexts();
if (!context) throw new Error("The attached Brave/Chromium browser has no default context.");
page = await context.newPage();
driver = new LiveYoutubeDriver(page, context);
const createAdapter =
options.runtime === "extension" ? createExtensionLiveRuntimeAdapter : createUserscriptLiveRuntimeAdapter;
runtimeAdapter = createAdapter({
driver,
expectedBuildId: options.expectedBuildId,
expectedVersion: options.expectedVersion,
});
});
test.afterAll(async () => {
if (page && !page.isClosed()) await page.close();
if (browser) await browser.close();
});
test(`${options?.runtime || "runtime"}: initializes after cold channel-to-Short and Next-video SPA navigation`, async () => {
test.setTimeout(150_000);
await scenarioRunner.run(runtimeAdapter, "channel-shorts-navigation", options);
});
test(`${options?.runtime || "runtime"}: initializes after optional cold channel-to-watch SPA navigation`, async () => {
test.setTimeout(120_000);
test.skip(
!options?.navigation.watch,
"Set RYD_LIVE_NAV_WATCH only when the configured channel page contains an exact visible link to that video.",
);
await scenarioRunner.run(runtimeAdapter, "channel-watch-navigation", options);
});
test(`${options?.runtime || "runtime"}: renders on a signed-in watch page`, async () => {
await scenarioRunner.run(runtimeAdapter, "watch-render", options);
});
test(`${options?.runtime || "runtime"}: reinitializes after reload`, async () => {
await scenarioRunner.run(runtimeAdapter, "reload", options);
});
test(`${options?.runtime || "runtime"}: reinitializes after real playlist SPA navigation`, async () => {
await scenarioRunner.run(runtimeAdapter, "spa-navigation", options);
});
test(`${options?.runtime || "runtime"}: survives consecutive real sidebar SPA navigations`, async () => {
test.setTimeout(60_000 + options.sidebar.hopCount * 75_000);
await scenarioRunner.run(runtimeAdapter, "sidebar-navigation-stress", options);
});
test(`${options?.runtime || "runtime"}: renders on an allowlisted Short`, async () => {
await scenarioRunner.run(runtimeAdapter, "shorts-render", options);
});
test(`${options?.runtime || "runtime"}: preserves reaction UI geometry across responsive widths`, async () => {
test.setTimeout(180_000);
await scenarioRunner.run(runtimeAdapter, "responsive-visual", options);
});
test(`${options?.runtime || "runtime"}: covers all reaction transitions on watch and Shorts`, async () => {
test.setTimeout(0);
const voteApproval = process.env.RYD_LIVE_VOTES;
test.skip(
!hasFreshVoteApproval(voteApproval, options.runtime, options.watchB, Date.now()),
"Create the documented short-lived RYD_LIVE_VOTES token only after approving the real vote pair.",
);
await scenarioRunner.run(runtimeAdapter, "reaction-matrix", options, {
createRecorder: (videoId) => new VoteTrafficRecorder(context, videoId),
consumeVoteApproval: async () => {
driver.assertCurrentVideo(options.watchB);
await driver.assertRuntime(options.runtime, options.expectedVersion, options.expectedBuildId);
if (!consumeLiveVoteApproval(voteApproval, options.runtime, options.watchB)) {
throw new Error("The live reaction approval expired or was already used. No reaction was clicked.");
}
},
});
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,418 @@
const { test, expect } = require("@playwright/test");
const {
CREDENTIAL_KEY,
VIDEO_A,
VIDEO_B,
createFakeBackend,
injectGeneratedUserscript,
installGmEnvironment,
installHermeticRoutes,
openShortsFixture,
readGmValue,
} = require("./harness");
const EXISTING_CREDENTIALS = {
userId: "ExistingUserscriptCredential000000000001",
registrationConfirmed: true,
};
const SYNTHETIC_STATE_KEY = `rydSyntheticDislikedShort:${VIDEO_A}`;
function visibleVoteButton(page, role) {
return page.locator(`[data-ryd-role="${role}"]:visible button`);
}
async function installDelayedSyntheticStateRead(page) {
await page.evaluate((delayedKey) => {
const originalGetValue = globalThis.GM.getValue;
let releaseRead;
let reportReadStarted;
let delayed = false;
const readGate = new Promise((resolve) => {
releaseRead = resolve;
});
globalThis.__rydDelayedGmReadStarted = new Promise((resolve) => {
reportReadStarted = resolve;
});
globalThis.__releaseRydDelayedGmRead = releaseRead;
globalThis.GM.getValue = async (key, fallbackValue) => {
if (key === delayedKey) {
if (!delayed) {
delayed = true;
reportReadStarted();
await readGate;
}
}
return originalGetValue(key, fallbackValue);
};
}, SYNTHETIC_STATE_KEY);
}
async function launchDelayedShorts(
{ context, page },
{ backendOptions, configureBackend, nativeDislike = false } = {},
) {
const consoleErrors = [];
const pageErrors = [];
page.on("console", (message) => {
if (message.type() === "error") consoleErrors.push(message.text());
});
page.on("pageerror", (error) => pageErrors.push(error.message));
const backend = createFakeBackend(backendOptions);
configureBackend?.(backend);
await installGmEnvironment(context, { [CREDENTIAL_KEY]: EXISTING_CREDENTIALS });
await installHermeticRoutes(context, backend);
await openShortsFixture(page, VIDEO_A);
await page.evaluate(() => {
globalThis.__unhandledRejections = [];
addEventListener("unhandledrejection", (event) => {
globalThis.__unhandledRejections.push(
event.reason instanceof Error ? event.reason.message : String(event.reason),
);
});
});
if (nativeDislike) {
await page.evaluate(() => window.__shortsFixture.installNativeDislike());
}
await installDelayedSyntheticStateRead(page);
await injectGeneratedUserscript(page);
await page.evaluate(() => globalThis.__rydDelayedGmReadStarted);
return { backend, consoleErrors, pageErrors };
}
async function releaseDelayedRead(page) {
await page.evaluate(() => globalThis.__releaseRydDelayedGmRead());
}
async function expectCompletedHandshakes(backend, expectedValues) {
await expect.poll(() => backend.requestsFor("POST", "/interact/confirmVote").length).toBe(expectedValues.length);
await expect
.poll(() => backend.requestsFor("POST", "/interact/confirmVote").every((request) => request.respondedAt))
.toBe(true);
await new Promise((resolve) => setTimeout(resolve, 150));
const votes = backend.requestsFor("POST", "/interact/vote");
const confirmations = backend.requestsFor("POST", "/interact/confirmVote");
expect(votes).toHaveLength(expectedValues.length);
expect(confirmations).toHaveLength(expectedValues.length);
expect(votes.map((request) => request.body.value)).toEqual(expectedValues);
for (let index = 0; index < expectedValues.length; index += 1) {
expect(votes[index].body).toEqual({
userId: EXISTING_CREDENTIALS.userId,
videoId: VIDEO_A,
value: expectedValues[index],
});
expect(confirmations[index].body).toMatchObject({
userId: EXISTING_CREDENTIALS.userId,
videoId: VIDEO_A,
});
}
}
async function expectSingleCompletedHandshake(backend, { value, videoId }) {
await expect.poll(() => backend.requestsFor("POST", "/interact/confirmVote").length).toBe(1);
await expect.poll(() => backend.requestsFor("POST", "/interact/confirmVote")[0]?.respondedAt).toBeTruthy();
await new Promise((resolve) => setTimeout(resolve, 150));
const votes = backend.requestsFor("POST", "/interact/vote");
const confirmations = backend.requestsFor("POST", "/interact/confirmVote");
expect(votes).toHaveLength(1);
expect(confirmations).toHaveLength(1);
expect(votes[0].body).toEqual({ userId: EXISTING_CREDENTIALS.userId, videoId, value });
expect(confirmations[0].body).toMatchObject({ userId: EXISTING_CREDENTIALS.userId, videoId });
}
async function expectNoRuntimeFailures(page, harness) {
expect(harness.backend.blockedRequests).toEqual([]);
expect(harness.consoleErrors).toEqual([]);
expect(harness.pageErrors).toEqual([]);
expect(await page.evaluate(() => globalThis.__unhandledRejections)).toEqual([]);
}
test("immediate native Like during synthetic Shorts initialization submits once and remains coherent", async ({
context,
page,
}) => {
const harness = await launchDelayedShorts({ context, page });
const likeButton = visibleVoteButton(page, "like");
await likeButton.click();
try {
await expect.poll(() => harness.backend.requestsFor("POST", "/interact/confirmVote").length).toBe(1);
} finally {
await releaseDelayedRead(page);
}
await expect(page.locator("[data-ryd-synthetic-shorts-dislike]:visible #text")).toHaveText("25");
await expect(likeButton).toHaveAttribute("aria-pressed", "true");
await expect(visibleVoteButton(page, "dislike")).toHaveAttribute("aria-pressed", "false");
await likeButton.click();
await expectCompletedHandshakes(harness.backend, [1, 0]);
await expect(likeButton).toHaveAttribute("aria-pressed", "false");
await expect(visibleVoteButton(page, "dislike")).toHaveAttribute("aria-pressed", "false");
await expectNoRuntimeFailures(page, harness);
});
test("immediate native Dislike during native Shorts initialization submits once and remains coherent", async ({
context,
page,
}) => {
const harness = await launchDelayedShorts({ context, page }, { nativeDislike: true });
const dislikeButton = visibleVoteButton(page, "dislike");
await dislikeButton.click();
try {
await expect.poll(() => harness.backend.requestsFor("POST", "/interact/confirmVote").length).toBe(1);
} finally {
await releaseDelayedRead(page);
}
await expect(dislikeButton.locator("#text")).toHaveText(/\d/);
await expect(dislikeButton).toHaveAttribute("aria-pressed", "true");
await expect(visibleVoteButton(page, "like")).toHaveAttribute("aria-pressed", "false");
await dislikeButton.click();
await expectCompletedHandshakes(harness.backend, [-1, 0]);
await expect(dislikeButton).toHaveAttribute("aria-pressed", "false");
await expect(visibleVoteButton(page, "like")).toHaveAttribute("aria-pressed", "false");
await expectNoRuntimeFailures(page, harness);
});
test("stale native Shorts hydration preserves video A's captured dislike across a recycled video B", async ({
context,
page,
}) => {
const harness = await launchDelayedShorts(
{ context, page },
{
backendOptions: {
countsByVideo: {
[VIDEO_A]: { dislikes: 11, likes: 100 },
[VIDEO_B]: { dislikes: 22, likes: 200 },
},
},
nativeDislike: true,
},
);
await visibleVoteButton(page, "dislike").click();
try {
await expect.poll(() => harness.backend.requestsFor("POST", "/interact/confirmVote").length).toBe(1);
expect(harness.backend.requestsFor("POST", "/interact/vote")[0].body).toEqual({
userId: EXISTING_CREDENTIALS.userId,
videoId: VIDEO_A,
value: -1,
});
await page.evaluate((videoId) => window.__shortsFixture.recycleActiveRenderer(videoId), VIDEO_B);
} finally {
await releaseDelayedRead(page);
}
await expect(page.locator('[data-ryd-role="dislike"]:visible #text')).toHaveText("22");
await expect.poll(() => readGmValue(page, SYNTHETIC_STATE_KEY)).toBe(true);
await page.evaluate((videoId) => {
window.__shortsFixture.recycleActiveRenderer(videoId);
window.__shortsFixture.removeNativeDislike();
}, VIDEO_A);
await expect(page.locator('[data-ryd-role="dislike"]:visible #text')).toHaveText("11");
const restoredSyntheticDislike = page.locator("[data-ryd-synthetic-shorts-dislike]:visible button");
await expect(restoredSyntheticDislike).toHaveAttribute("aria-pressed", "true");
await restoredSyntheticDislike.click();
await expectCompletedHandshakes(harness.backend, [-1, 0]);
await expect(restoredSyntheticDislike).toHaveAttribute("aria-pressed", "false");
await expect.poll(() => readGmValue(page, SYNTHETIC_STATE_KEY)).toBe(null);
expect(
harness.backend.requestsFor("POST", "/interact/vote").filter((request) => request.body.videoId === VIDEO_B),
).toHaveLength(0);
await expectNoRuntimeFailures(page, harness);
});
test("video B Like remains bound while video A hydration is delayed", async ({ context, page }) => {
const harness = await launchDelayedShorts(
{ context, page },
{
backendOptions: {
countsByVideo: {
[VIDEO_A]: { dislikes: 11, likes: 100 },
[VIDEO_B]: { dislikes: 22, likes: 200 },
},
},
},
);
await page.evaluate((videoId) => window.__shortsFixture.recycleActiveRenderer(videoId), VIDEO_B);
const videoBLike = visibleVoteButton(page, "like");
await videoBLike.click();
try {
await expectSingleCompletedHandshake(harness.backend, { videoId: VIDEO_B, value: 1 });
} finally {
await releaseDelayedRead(page);
}
await expect(page.locator('[data-ryd-role="dislike"]:visible #text')).toHaveText("22");
await expect(videoBLike).toHaveAttribute("aria-pressed", "true");
await expect(visibleVoteButton(page, "dislike")).toHaveAttribute("aria-pressed", "false");
expect(
harness.backend.requestsFor("POST", "/interact/vote").filter((request) => request.body.videoId === VIDEO_A),
).toHaveLength(0);
await expectNoRuntimeFailures(page, harness);
});
for (const replacement of ["inner Like button", "action bar"]) {
test(`a replacement ${replacement} receives the pending same-video hydration listener`, async ({ context, page }) => {
const harness = await launchDelayedShorts({ context, page });
await page.evaluate((replacementKind) => {
if (replacementKind === "inner Like button") {
window.__shortsFixture.replaceInnerButton("like");
} else {
window.__shortsFixture.replaceActionBar();
}
}, replacement);
const replacementLike = visibleVoteButton(page, "like");
await replacementLike.click();
try {
await expectSingleCompletedHandshake(harness.backend, { videoId: VIDEO_A, value: 1 });
} finally {
await releaseDelayedRead(page);
}
await expect(page.locator('[data-ryd-role="dislike"]:visible #text')).toHaveText("25");
await expect(replacementLike).toHaveAttribute("aria-pressed", "true");
await expect(page.locator("[data-ryd-synthetic-shorts-dislike]:visible")).toHaveCount(1);
await expectNoRuntimeFailures(page, harness);
});
}
test("enabled synthetic Dislike clears native Like during a pending same-wrapper rehydration", async ({
context,
page,
}) => {
const harness = await launchDelayedShorts({ context, page });
await releaseDelayedRead(page);
const syntheticDislike = visibleVoteButton(page, "dislike");
await expect(page.locator('[data-ryd-role="dislike"]:visible #text')).toHaveText("25");
await expect(syntheticDislike).toHaveAttribute("aria-disabled", "false");
await installDelayedSyntheticStateRead(page);
await page.evaluate((videoId) => window.__shortsFixture.activate(videoId, { state: "liked" }), VIDEO_A);
await page.evaluate(() => globalThis.__rydDelayedGmReadStarted);
await expect(visibleVoteButton(page, "like")).toHaveAttribute("aria-pressed", "true");
await syntheticDislike.click();
try {
await expectSingleCompletedHandshake(harness.backend, { videoId: VIDEO_A, value: -1 });
} finally {
await releaseDelayedRead(page);
}
await expect(visibleVoteButton(page, "like")).toHaveAttribute("aria-pressed", "false");
await expect(syntheticDislike).toHaveAttribute("aria-pressed", "true");
await expect.poll(() => readGmValue(page, SYNTHETIC_STATE_KEY)).toBe(true);
expect(harness.backend.requestsFor("GET", "/votes")).toHaveLength(1);
await expectNoRuntimeFailures(page, harness);
});
test("new same-video hydration wins after two native Dislike transitions complete before the old read", async ({
context,
page,
}) => {
const harness = await launchDelayedShorts(
{ context, page },
{
backendOptions: {
countsByVideo: {
[VIDEO_A]: { dislikes: 25, likes: 100 },
},
},
nativeDislike: true,
},
);
await expect(page.locator('[data-ryd-role="dislike"]:visible #text')).toHaveText("25");
let readReleased = false;
try {
await visibleVoteButton(page, "dislike").click();
await expectSingleCompletedHandshake(harness.backend, { videoId: VIDEO_A, value: -1 });
await expect(page.locator('[data-ryd-role="dislike"]:visible #text')).toHaveText("26");
await page.evaluate(() => window.__shortsFixture.replaceActionBar());
const replacementDislike = visibleVoteButton(page, "dislike");
await replacementDislike.click();
await expectCompletedHandshakes(harness.backend, [-1, 0]);
await expect(replacementDislike).toHaveAttribute("aria-pressed", "false");
await expect(page.locator('[data-ryd-role="dislike"]:visible #text')).toHaveText("25");
await expect.poll(() => readGmValue(page, SYNTHETIC_STATE_KEY)).toBe(null);
expect(harness.backend.requestsFor("GET", "/votes")).toHaveLength(1);
} finally {
await releaseDelayedRead(page);
readReleased = true;
}
expect(readReleased).toBe(true);
const finalDislike = visibleVoteButton(page, "dislike");
await expect(finalDislike).toHaveAttribute("aria-pressed", "false");
await expect(page.locator('[data-ryd-role="dislike"]:visible #text')).toHaveText("25");
await expect.poll(() => readGmValue(page, SYNTHETIC_STATE_KEY)).toBe(null);
await page.waitForTimeout(200);
expect(harness.backend.requestsFor("GET", "/votes")).toHaveLength(1);
expect(harness.backend.requestsFor("POST", "/interact/vote").map((request) => request.body.value)).toEqual([-1, 0]);
await finalDislike.click();
await expectCompletedHandshakes(harness.backend, [-1, 0, -1]);
await expect(finalDislike).toHaveAttribute("aria-pressed", "true");
await expect(page.locator('[data-ryd-role="dislike"]:visible #text')).toHaveText("26");
await expect.poll(() => readGmValue(page, SYNTHETIC_STATE_KEY)).toBe(true);
expect(harness.backend.requestsFor("GET", "/votes")).toHaveLength(1);
await expectNoRuntimeFailures(page, harness);
});
test("fresh same-video count state supersedes stale hydration correction", async ({ context, page }) => {
const harness = await launchDelayedShorts(
{ context, page },
{
configureBackend: (backend) => {
backend.enqueue("GET", "/votes", { body: { dislikes: 25, likes: 100, rating: 4.5 } });
backend.enqueue("GET", "/votes", { body: { dislikes: 22, likes: 200, rating: 4.5 } });
backend.enqueue("GET", "/votes", { body: { dislikes: 26, likes: 100, rating: 4.5 } });
},
nativeDislike: true,
},
);
const dislikeCount = page.locator('[data-ryd-role="dislike"]:visible #text');
await expect(dislikeCount).toHaveText("25");
try {
await visibleVoteButton(page, "dislike").click();
await expectSingleCompletedHandshake(harness.backend, { videoId: VIDEO_A, value: -1 });
await expect(dislikeCount).toHaveText("26");
await page.evaluate((videoId) => window.__shortsFixture.recycleActiveRenderer(videoId), VIDEO_B);
await expect(dislikeCount).toHaveText("22");
await page.evaluate(
(videoId) => window.__shortsFixture.recycleActiveRenderer(videoId, { state: "disliked" }),
VIDEO_A,
);
await expect(dislikeCount).toHaveText("26");
expect(
harness.backend.requestsFor("GET", "/votes").filter((request) => request.query.videoId === VIDEO_A),
).toHaveLength(2);
} finally {
await releaseDelayedRead(page);
}
await page.waitForTimeout(250);
await expect(dislikeCount).toHaveText("26");
await expect(visibleVoteButton(page, "dislike")).toHaveAttribute("aria-pressed", "true");
await expect.poll(() => readGmValue(page, SYNTHETIC_STATE_KEY)).toBe(true);
expect(harness.backend.requestsFor("POST", "/interact/vote").map((request) => request.body.value)).toEqual([-1]);
expect(harness.backend.requestsFor("POST", "/interact/confirmVote")).toHaveLength(1);
expect(harness.backend.requestsFor("GET", "/votes")).toHaveLength(3);
expect(
harness.backend.requestsFor("GET", "/votes").filter((request) => request.query.videoId === VIDEO_B),
).toHaveLength(1);
await expectNoRuntimeFailures(page, harness);
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,67 @@
const { test, expect } = require("@playwright/test");
const {
CREDENTIAL_KEY,
createFakeBackend,
injectGeneratedUserscript,
installGmEnvironment,
installHermeticRoutes,
openNavigationFixture,
} = require("./harness");
const {
NAVIGATION_MATRIX,
USERSCRIPT_MATRIX_RUNTIME,
installNavigationMatrixFixture,
runNavigationMatrixScenario,
} = require("./navigation-matrix");
const EXISTING_CREDENTIALS = {
registrationConfirmed: true,
userId: "ExistingUserscriptCredential000000000001",
};
for (const scenario of NAVIGATION_MATRIX) {
test(`userscript navigation matrix: ${scenario.id}`, async ({ context, page }) => {
await page.setViewportSize(scenario.viewport);
const consoleErrors = [];
const pageErrors = [];
page.on("console", (message) => {
if (message.type() === "error") consoleErrors.push(message.text());
});
page.on("pageerror", (error) => pageErrors.push(error.message));
await context.addInitScript(() => {
globalThis.__unhandledRejections = [];
addEventListener("unhandledrejection", (event) => {
const reason = event.reason;
globalThis.__unhandledRejections.push(reason instanceof Error ? reason.message : String(reason));
});
});
const backend = createFakeBackend({
countsByVideo: {
[scenario.destination.videoId]: scenario.destination.counts,
[scenario.origin.videoId]: scenario.origin.counts,
},
});
await installGmEnvironment(context, { [CREDENTIAL_KEY]: EXISTING_CREDENTIALS });
await installNavigationMatrixFixture(context, scenario);
await installHermeticRoutes(context, backend);
await openNavigationFixture(page, {
pageKind: scenario.origin.kind,
videoId: scenario.origin.videoId,
});
await injectGeneratedUserscript(page);
await runNavigationMatrixScenario({
backend,
page,
runtime: USERSCRIPT_MATRIX_RUNTIME,
scenario,
});
expect(backend.blockedRequests).toEqual([]);
expect(consoleErrors).toEqual([]);
expect(pageErrors).toEqual([]);
expect(await page.evaluate(() => globalThis.__unhandledRejections)).toEqual([]);
});
}
@@ -0,0 +1,219 @@
const { test, expect } = require("@playwright/test");
const {
CREDENTIAL_KEY,
VIDEO_A,
createFakeBackend,
injectGeneratedUserscript,
installGmEnvironment,
installHermeticRoutes,
openWatchFixture,
} = require("./harness");
const EXISTING_CREDENTIALS = {
userId: "ExistingUserscriptCredential000000000001",
registrationConfirmed: true,
};
const COUNTS = { likes: 300, dislikes: 100 };
const VIEWPORTS = [
{ name: "wide desktop", width: 1280, height: 720 },
{ name: "narrow desktop", width: 768, height: 720 },
{ name: "mobile-sized", width: 390, height: 844 },
];
function colorChannels(color) {
const srgb = color.match(/^color\(srgb\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)/);
if (srgb) return srgb.slice(1).map((channel) => Number(channel) * 255);
const channels = color
.match(/[\d.]+/g)
?.slice(0, 3)
.map(Number);
if (!channels || channels.length !== 3) throw new Error(`Unsupported computed color: ${color}`);
return channels;
}
function relativeLuminance(color) {
const channels = colorChannels(color);
const linear = channels.map((channel) => {
const normalized = channel / 255;
return normalized <= 0.04045 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4;
});
return linear[0] * 0.2126 + linear[1] * 0.7152 + linear[2] * 0.0722;
}
function contrastRatio(first, second) {
const lighter = Math.max(relativeLuminance(first), relativeLuminance(second));
const darker = Math.min(relativeLuminance(first), relativeLuminance(second));
return (lighter + 0.05) / (darker + 0.05);
}
async function readBarColors(page) {
return page.evaluate(() => ({
background: getComputedStyle(document.body).backgroundColor,
negative: getComputedStyle(document.querySelector("#return-youtube-dislike-bar-container")).backgroundColor,
positive: getComputedStyle(document.querySelector("#return-youtube-dislike-bar")).backgroundColor,
}));
}
function expectVisibleBarColors(colors, expected) {
expect(colors.background).toBe(expected.background);
expect(colors.positive).toBe(expected.positive);
colorChannels(colors.negative).forEach((channel) => expect(channel).toBeCloseTo(expected.negative, 0));
expect(contrastRatio(colors.negative, colors.background)).toBeGreaterThanOrEqual(3);
expect(contrastRatio(colors.positive, colors.negative)).toBeGreaterThanOrEqual(3);
}
async function launchRateBarFixture({ context, page }, { rateBarEnabled, theme = "dark" } = {}) {
const pageErrors = [];
page.on("pageerror", (error) => pageErrors.push(error.message));
const backend = createFakeBackend({ countsByVideo: { [VIDEO_A]: COUNTS } });
await installGmEnvironment(context, { [CREDENTIAL_KEY]: EXISTING_CREDENTIALS });
await installHermeticRoutes(context, backend);
await openWatchFixture(page, VIDEO_A);
if (theme === "light") {
await page.evaluate(() => {
document.documentElement.style.setProperty("--yt-spec-base-background", "rgb(255, 255, 255)");
document.documentElement.style.setProperty("--yt-spec-text-primary", "rgb(15, 15, 15)");
document.documentElement.style.setProperty("--yt-spec-text-secondary", "rgb(96, 96, 96)");
document.body.style.background = "rgb(255, 255, 255)";
});
}
await injectGeneratedUserscript(page, { rateBarEnabled });
await expect(page.locator('[data-ryd-role="dislike"] #text')).toHaveText(String(COUNTS.dislikes));
return { backend, pageErrors };
}
for (const viewport of VIEWPORTS) {
test(`default ratio bar renders cleanly at ${viewport.name} width`, async ({ context, page }) => {
await page.setViewportSize({ width: viewport.width, height: viewport.height });
const { backend, pageErrors } = await launchRateBarFixture({ context, page });
const wrapper = page.locator(".ryd-tooltip");
const container = page.locator("#return-youtube-dislike-bar-container");
const bar = page.locator("#return-youtube-dislike-bar");
const tooltip = page.locator("#ryd-dislike-tooltip");
const dislikeCount = page.locator('[data-ryd-role="dislike"] #text');
await expect(dislikeCount).toBeVisible();
await expect(dislikeCount).toHaveText(String(COUNTS.dislikes));
await expect(wrapper).toBeVisible();
await expect(container).toBeVisible();
await expect(bar).toBeVisible();
await expect(tooltip).toContainText("300 / 100");
await expect(tooltip).toHaveAttribute("role", "tooltip");
await expect(tooltip).toBeHidden();
await expect(page.locator("tp-yt-paper-tooltip#ryd-dislike-tooltip")).toHaveCount(0);
const idleTooltipStyle = await tooltip.evaluate((element) => {
const style = getComputedStyle(element);
return { opacity: style.opacity, visibility: style.visibility };
});
expect(idleTooltipStyle).toEqual({ opacity: "0", visibility: "hidden" });
await wrapper.hover({ position: { x: 1, y: 1 } });
await expect(tooltip).toBeVisible();
await expect.poll(() => tooltip.evaluate((element) => getComputedStyle(element).opacity)).toBe("1");
const hoveredTooltip = await tooltip.evaluate((element) => {
const bounds = element.getBoundingClientRect();
const style = getComputedStyle(element);
return {
backgroundColor: style.backgroundColor,
bottom: bounds.bottom,
color: style.color,
left: bounds.left,
opacity: style.opacity,
pointerEvents: style.pointerEvents,
position: style.position,
right: bounds.right,
visibility: style.visibility,
viewportWidth: innerWidth,
};
});
expect(hoveredTooltip).toMatchObject({
backgroundColor: "rgba(28, 28, 28, 0.96)",
color: "rgb(255, 255, 255)",
opacity: "1",
pointerEvents: "none",
position: "absolute",
visibility: "visible",
});
expect(hoveredTooltip.left).toBeGreaterThanOrEqual(0);
expect(hoveredTooltip.right).toBeLessThanOrEqual(hoveredTooltip.viewportWidth);
expect(hoveredTooltip.bottom).toBeGreaterThan(0);
await page.mouse.move(viewport.width - 1, viewport.height - 1);
await expect(tooltip).toBeHidden();
await wrapper.focus();
await expect(tooltip).toBeVisible();
await wrapper.evaluate((element) => element.blur());
await expect(tooltip).toBeHidden();
const geometry = await page.evaluate(() => {
const rect = (selector) => {
const bounds = document.querySelector(selector).getBoundingClientRect();
return {
bottom: bounds.bottom,
height: bounds.height,
left: bounds.left,
right: bounds.right,
top: bounds.top,
width: bounds.width,
};
};
return {
bar: rect("#return-youtube-dislike-bar"),
container: rect("#return-youtube-dislike-bar-container"),
dislike: rect('[data-ryd-role="dislike"] button'),
like: rect('[data-ryd-role="like"] button'),
viewport: { height: innerHeight, width: innerWidth },
wrapper: rect(".ryd-tooltip"),
};
});
expect(geometry.container.width).toBeGreaterThan(0);
expect(geometry.container.height).toBeGreaterThan(0);
expect(geometry.bar.width).toBeGreaterThan(0);
expect(geometry.bar.height).toBeGreaterThan(0);
expect(geometry.bar.width / geometry.container.width).toBeCloseTo(0.75, 2);
expect(geometry.wrapper.width).toBeCloseTo(geometry.like.width + geometry.dislike.width, 0);
expect(geometry.container.left).toBeGreaterThanOrEqual(0);
expect(geometry.container.top).toBeGreaterThanOrEqual(0);
expect(geometry.container.right).toBeLessThanOrEqual(geometry.viewport.width);
expect(geometry.container.bottom).toBeLessThanOrEqual(geometry.viewport.height);
expect(geometry.container.top).toBeGreaterThanOrEqual(Math.max(geometry.like.bottom, geometry.dislike.bottom));
expectVisibleBarColors(await readBarColors(page), {
background: "rgb(15, 15, 15)",
negative: 139,
positive: "rgb(241, 241, 241)",
});
expect(backend.blockedRequests).toEqual([]);
expect(pageErrors).toEqual([]);
});
}
test("default ratio bar keeps both sides distinct in the light theme", async ({ context, page }) => {
await page.setViewportSize({ width: 1280, height: 720 });
const { backend, pageErrors } = await launchRateBarFixture({ context, page }, { theme: "light" });
expectVisibleBarColors(await readBarColors(page), {
background: "rgb(255, 255, 255)",
negative: 123,
positive: "rgb(15, 15, 15)",
});
expect(backend.blockedRequests).toEqual([]);
expect(pageErrors).toEqual([]);
});
test("in-memory disabled option omits the ratio bar", async ({ context, page }) => {
await page.setViewportSize({ width: 1280, height: 720 });
const { backend, pageErrors } = await launchRateBarFixture({ context, page }, { rateBarEnabled: false });
await expect(page.locator("#return-youtube-dislike-bar-container")).toHaveCount(0);
await expect(page.locator("#return-youtube-dislike-bar")).toHaveCount(0);
await expect(page.locator("#ryd-dislike-tooltip")).toHaveCount(0);
expect(backend.blockedRequests).toEqual([]);
expect(pageErrors).toEqual([]);
});
@@ -0,0 +1,233 @@
const { test, expect } = require("@playwright/test");
const {
CREDENTIAL_KEY,
VIDEO_A,
createFakeBackend,
injectGeneratedUserscript,
installGmEnvironment,
installHermeticRoutes,
openShortsFixture,
} = require("./harness");
const EXISTING_CREDENTIALS = {
userId: "ExistingUserscriptCredential000000000001",
registrationConfirmed: true,
};
const VIEWPORTS = [
{ height: 720, name: "wide", width: 1280 },
{ height: 720, name: "narrow", width: 768 },
{ height: 844, name: "mobile-sized", width: 390 },
];
async function launchShortsLayoutFixture({ context, page }, fixture = {}) {
const pageErrors = [];
page.on("pageerror", (error) => pageErrors.push(error.message));
const backend = createFakeBackend({ fixture });
await installGmEnvironment(context, { [CREDENTIAL_KEY]: EXISTING_CREDENTIALS });
await installHermeticRoutes(context, backend);
await openShortsFixture(page, VIDEO_A);
await injectGeneratedUserscript(page);
const syntheticDislike = page.locator("[data-ryd-synthetic-shorts-dislike]:visible");
await expect(syntheticDislike.locator("#text")).toHaveText("25");
return { backend, pageErrors, syntheticDislike };
}
for (const viewport of VIEWPORTS) {
test(`synthetic desktop Shorts action matches the full native stack at ${viewport.name} width`, async ({
context,
page,
}) => {
await page.setViewportSize(viewport);
const { backend, pageErrors } = await launchShortsLayoutFixture({ context, page });
const geometry = await page.evaluate(() => {
const nativeOuter = document.querySelector('[data-ryd-role="like"]');
const syntheticOuter = document.querySelector("[data-ryd-synthetic-shorts-dislike]");
const actionBar = syntheticOuter.parentElement;
const commentsOuter = document.querySelector('[data-fixture-control="comments"]');
const nativeLabel = nativeOuter.querySelector("label");
const syntheticLabel = syntheticOuter.querySelector("label");
const syntheticButton = syntheticOuter.querySelector("button");
const syntheticIconWrapper = syntheticButton.querySelector(".ytSpecButtonShapeNextIcon");
const syntheticIcon = syntheticIconWrapper.querySelector("svg");
const nativeCount = nativeOuter.querySelector("#text");
const syntheticCount = syntheticOuter.querySelector("#text");
const rect = (element) => {
const bounds = element.getBoundingClientRect();
return {
bottom: bounds.bottom,
height: bounds.height,
left: bounds.left,
right: bounds.right,
top: bounds.top,
width: bounds.width,
};
};
const boxStyle = (element) => {
const style = getComputedStyle(element);
return {
margin: [style.marginTop, style.marginRight, style.marginBottom, style.marginLeft],
padding: [style.paddingTop, style.paddingRight, style.paddingBottom, style.paddingLeft],
};
};
const textStyle = (element) => {
const style = getComputedStyle(element);
return { fontSize: style.fontSize, lineHeight: style.lineHeight };
};
return {
actionStack: [...actionBar.children].map((element) => ({
role:
element.getAttribute("data-fixture-control") ??
(element.matches("[data-ryd-synthetic-shorts-dislike]") ? "synthetic-dislike" : "like"),
...rect(element),
})),
commentsOuter: rect(commentsOuter),
nativeClass: nativeOuter.getAttribute("class"),
nativeCount: textStyle(nativeCount),
nativeLabel: rect(nativeLabel),
nativeOuter: rect(nativeOuter),
nativeOuterStyle: boxStyle(nativeOuter),
syntheticButton: rect(syntheticButton),
syntheticClass: syntheticOuter.getAttribute("class"),
syntheticCount: textStyle(syntheticCount),
syntheticIcon: rect(syntheticIcon),
syntheticIconWrapper: rect(syntheticIconWrapper),
syntheticLabel: rect(syntheticLabel),
syntheticOuter: rect(syntheticOuter),
syntheticOuterStyle: boxStyle(syntheticOuter),
};
});
expect(geometry.nativeClass).toBe("ytLikeButtonViewModelHost ytwReelActionBarViewModelHostDesktopActionButton");
expect(geometry.nativeOuter.width).toBe(48);
expect(geometry.nativeOuter.height).toBe(78);
expect(geometry.nativeOuterStyle.padding).toEqual(["0px", "0px", "8px", "0px"]);
expect(geometry.nativeLabel.width).toBe(48);
expect(geometry.nativeLabel.height).toBe(70);
expect(geometry.nativeCount).toEqual({ fontSize: "12px", lineHeight: "18px" });
expect(geometry.syntheticClass.split(/\s+/)).toEqual(
expect.arrayContaining([
"ytLikeButtonViewModelHost",
"ytwReelActionBarViewModelHostDesktopActionButton",
"ryd-synthetic-shorts-dislike",
]),
);
expect(geometry.syntheticClass).not.toContain("undefined");
expect(geometry.syntheticOuter.width).toBeCloseTo(geometry.nativeOuter.width, 5);
expect(geometry.syntheticOuter.height).toBeCloseTo(geometry.nativeOuter.height, 5);
expect(geometry.syntheticOuterStyle.padding).toEqual(geometry.nativeOuterStyle.padding);
expect(geometry.syntheticOuterStyle.margin).toEqual(geometry.nativeOuterStyle.margin);
expect(geometry.syntheticOuter.top).toBeCloseTo(geometry.nativeOuter.bottom, 5);
expect(geometry.syntheticButton.width).toBe(48);
expect(geometry.syntheticButton.height).toBe(48);
expect(geometry.syntheticLabel.width).toBe(48);
expect(geometry.syntheticLabel.height).toBe(70);
expect(geometry.syntheticIconWrapper.width).toBe(24);
expect(geometry.syntheticIconWrapper.height).toBe(24);
expect(geometry.syntheticIcon.width).toBe(24);
expect(geometry.syntheticIcon.height).toBe(24);
expect(geometry.syntheticIconWrapper.left + geometry.syntheticIconWrapper.width / 2).toBeCloseTo(
geometry.syntheticButton.left + geometry.syntheticButton.width / 2,
5,
);
expect(geometry.syntheticIconWrapper.top + geometry.syntheticIconWrapper.height / 2).toBeCloseTo(
geometry.syntheticButton.top + geometry.syntheticButton.height / 2,
5,
);
expect(geometry.syntheticIcon.left + geometry.syntheticIcon.width / 2).toBeCloseTo(
geometry.syntheticButton.left + geometry.syntheticButton.width / 2,
5,
);
expect(geometry.syntheticIcon.top + geometry.syntheticIcon.height / 2).toBeCloseTo(
geometry.syntheticButton.top + geometry.syntheticButton.height / 2,
5,
);
expect(geometry.syntheticCount).toEqual({ fontSize: "12px", lineHeight: "18px" });
expect(geometry.syntheticCount).toEqual(geometry.nativeCount);
expect(geometry.commentsOuter.top).toBeCloseTo(geometry.syntheticOuter.bottom, 5);
expect(geometry.actionStack.map(({ role }) => role)).toEqual([
"like",
"synthetic-dislike",
"comments",
"share",
"remix",
]);
geometry.actionStack.forEach((control, index) => {
expect(control.width).toBeCloseTo(48, 5);
expect(control.height).toBeCloseTo(78, 5);
expect(control.left).toBeGreaterThanOrEqual(0);
expect(control.right).toBeLessThanOrEqual(viewport.width);
expect(control.top).toBeGreaterThanOrEqual(0);
expect(control.bottom).toBeLessThanOrEqual(viewport.height);
expect(control.left + control.width / 2).toBeCloseTo(
geometry.actionStack[0].left + geometry.actionStack[0].width / 2,
5,
);
if (index > 0) expect(control.top).toBeCloseTo(geometry.actionStack[index - 1].bottom, 5);
});
expect(backend.blockedRequests).toEqual([]);
expect(pageErrors).toEqual([]);
});
}
for (const viewport of VIEWPORTS) {
test(`Shorts selected and unselected states preserve the stack at ${viewport.name} width`, async ({
context,
page,
}) => {
await page.setViewportSize(viewport);
const { backend, pageErrors, syntheticDislike } = await launchShortsLayoutFixture(
{ context, page },
{ initialState: "liked" },
);
const nativeLike = page.locator('[data-ryd-role="like"]:visible');
const syntheticButton = syntheticDislike.locator("button");
const readStackGeometry = () =>
page.locator("reel-action-bar-view-model:visible").evaluate((actionBar) =>
[...actionBar.children].map((element) => {
const bounds = element.getBoundingClientRect();
return { height: bounds.height, left: bounds.left, top: bounds.top, width: bounds.width };
}),
);
const initialGeometry = await readStackGeometry();
const neutralColor = await syntheticButton.evaluate((button) => getComputedStyle(button).color);
await expect(nativeLike).toHaveClass(/\bstyle-default-active\b/);
await expect(nativeLike.locator("button")).toHaveAttribute("aria-pressed", "true");
await expect(syntheticDislike).toHaveClass(/\bytLikeButtonViewModelHost\b/);
await expect(syntheticDislike).toHaveClass(/\bytwReelActionBarViewModelHostDesktopActionButton\b/);
await expect(syntheticDislike).not.toHaveClass(/\bstyle-default-active\b/);
await expect(syntheticDislike).not.toHaveClass(/\bundefined\b/);
await expect(syntheticButton).toHaveAttribute("aria-pressed", "false");
await syntheticButton.click();
await expect.poll(() => backend.requestsFor("POST", "/interact/confirmVote").length).toBe(1);
await expect(nativeLike.locator("button")).toHaveAttribute("aria-pressed", "false");
await expect(syntheticButton).toHaveAttribute("aria-pressed", "true");
await expect(syntheticDislike).toHaveClass(/\bstyle-default-active\b/);
await expect(syntheticDislike).not.toHaveClass(/\bstyle-text\b/);
await expect
.poll(() => syntheticButton.evaluate((button) => getComputedStyle(button).color))
.toBe("rgb(62, 166, 255)");
expect(await readStackGeometry()).toEqual(initialGeometry);
await syntheticButton.click();
await expect.poll(() => backend.requestsFor("POST", "/interact/confirmVote").length).toBe(2);
await expect(syntheticButton).toHaveAttribute("aria-pressed", "false");
await expect(syntheticDislike).toHaveClass(/\bstyle-text\b/);
await expect(syntheticDislike).not.toHaveClass(/\bstyle-default-active\b/);
await expect.poll(() => syntheticButton.evaluate((button) => getComputedStyle(button).color)).toBe(neutralColor);
expect(await readStackGeometry()).toEqual(initialGeometry);
expect(backend.blockedRequests).toEqual([]);
expect(pageErrors).toEqual([]);
});
}
@@ -0,0 +1,426 @@
const { test, expect } = require("@playwright/test");
const {
CREDENTIAL_KEY,
VIDEO_A,
createFakeBackend,
injectGeneratedUserscript,
installGmEnvironment,
installHermeticRoutes,
openWatchFixture,
} = require("./harness");
const EXISTING_CREDENTIALS = {
userId: "ExistingUserscriptCredential000000000001",
registrationConfirmed: true,
};
const BASE_COUNTS = { likes: 300, dislikes: 100 };
const VIEWPORTS = [
{ name: "wide", width: 1280, height: 720 },
{ name: "narrow", width: 768, height: 720 },
{ name: "mobile-sized", width: 390, height: 844 },
];
const TRANSITIONS = [
{
action: "like",
initialState: "neutral",
nextState: "liked",
value: 1,
likesDelta: 1,
dislikesDelta: 0,
},
{
action: "dislike",
initialState: "neutral",
nextState: "disliked",
value: -1,
likesDelta: 0,
dislikesDelta: 1,
},
{
action: "like",
initialState: "liked",
nextState: "neutral",
value: 0,
likesDelta: -1,
dislikesDelta: 0,
},
{
action: "dislike",
initialState: "liked",
nextState: "disliked",
value: -1,
likesDelta: -1,
dislikesDelta: 1,
},
{
action: "like",
initialState: "disliked",
nextState: "liked",
value: 1,
likesDelta: 1,
dislikesDelta: -1,
},
{
action: "dislike",
initialState: "disliked",
nextState: "neutral",
value: 0,
likesDelta: 0,
dislikesDelta: -1,
},
];
function monitorRuntime(page) {
const consoleErrors = [];
const pageErrors = [];
page.on("console", (message) => {
if (message.type() === "error") consoleErrors.push(message.text());
});
page.on("pageerror", (error) => pageErrors.push(error.message));
return { consoleErrors, pageErrors };
}
async function launchVisualFixture({ context, page }, initialState) {
const runtime = monitorRuntime(page);
const backend = createFakeBackend({
countsByVideo: { [VIDEO_A]: BASE_COUNTS },
fixture: { initialState },
});
await installGmEnvironment(context, { [CREDENTIAL_KEY]: EXISTING_CREDENTIALS });
await installHermeticRoutes(context, backend);
await openWatchFixture(page, VIDEO_A);
await injectGeneratedUserscript(page);
await expect(page.locator('[data-ryd-role="dislike"] #text')).toHaveText(String(BASE_COUNTS.dislikes));
await expect(page.locator("#return-youtube-dislike-bar-container")).toBeVisible();
return { backend, ...runtime };
}
async function readVisualContract(page) {
return page.evaluate(() => {
const required = (selector) => {
const element = document.querySelector(selector);
if (!element) throw new Error(`Missing visual-contract element: ${selector}`);
return element;
};
const rect = (element) => {
const bounds = element.getBoundingClientRect();
return {
bottom: bounds.bottom,
height: bounds.height,
left: bounds.left,
right: bounds.right,
top: bounds.top,
width: bounds.width,
};
};
const box = (element) => {
const style = getComputedStyle(element);
return {
margin: [style.marginTop, style.marginRight, style.marginBottom, style.marginLeft],
padding: [style.paddingTop, style.paddingRight, style.paddingBottom, style.paddingLeft],
};
};
const control = (role) => {
const outer = required(`[data-ryd-role="${role}"]`);
const button = required(`[data-ryd-role="${role}"] button`);
const icon = required(`[data-ryd-role="${role}"] [data-fixture-icon]`);
const text = required(`[data-ryd-role="${role}"] #text`);
const buttonStyle = getComputedStyle(button);
return {
ariaPressed: button.getAttribute("aria-pressed"),
backgroundColor: buttonStyle.backgroundColor,
button: rect(button),
buttonBox: box(button),
classes: [...outer.classList].sort(),
color: buttonStyle.color,
icon: rect(icon),
iconBox: box(icon),
outer: rect(outer),
outerBox: box(outer),
text: rect(text),
textBox: box(text),
};
};
const surface = required("#top-level-buttons-computed");
const reactionGroup = required('[data-ryd-role="buttons"]');
const topRow = required("#top-row");
const wrapper = required(".ryd-tooltip");
const barContainer = required("#return-youtube-dislike-bar-container");
const bar = required("#return-youtube-dislike-bar");
const tooltip = required("#ryd-dislike-tooltip");
const reactionGroupStyle = getComputedStyle(reactionGroup);
const surfaceStyle = getComputedStyle(surface);
const topRowStyle = getComputedStyle(topRow);
const barStyle = getComputedStyle(bar);
const barContainerStyle = getComputedStyle(barContainer);
const tooltipStyle = getComputedStyle(tooltip);
const wrapperStyle = getComputedStyle(wrapper);
return {
bar: rect(bar),
barAppearance: {
backgroundColor: barStyle.backgroundColor,
borderRadius: barStyle.borderRadius,
},
barContainer: rect(barContainer),
barContainerAppearance: {
backgroundColor: barContainerStyle.backgroundColor,
borderRadius: barContainerStyle.borderRadius,
},
buttons: rect(surface),
buttonsBox: box(surface),
buttonsGap: reactionGroupStyle.gap,
buttonsPosition: surfaceStyle.position,
dislike: control("dislike"),
like: control("like"),
ownership: {
barOwnedByButtons: surface.contains(barContainer),
tooltipDescribedBy: wrapper.getAttribute("aria-describedby"),
tooltipOwnedByButtons: surface.contains(tooltip),
tooltipRole: tooltip.getAttribute("role"),
wrapperOwnedByButtons: wrapper.parentElement === surface,
},
reactionGroup: rect(reactionGroup),
reactionGroupBox: box(reactionGroup),
topRow: rect(topRow),
topRowBox: box(topRow),
topRowStyle: {
borderBottomWidth: topRowStyle.borderBottomWidth,
paddingBottom: topRowStyle.paddingBottom,
},
tooltipText: tooltip.textContent.replace(/\s+/g, " ").trim(),
tooltipAppearance: {
fontSize: tooltipStyle.fontSize,
lineHeight: tooltipStyle.lineHeight,
opacity: tooltipStyle.opacity,
padding: box(tooltip).padding,
position: tooltipStyle.position,
visibility: tooltipStyle.visibility,
},
unique: {
bars: document.querySelectorAll("#return-youtube-dislike-bar").length,
containers: document.querySelectorAll("#return-youtube-dislike-bar-container").length,
tooltips: document.querySelectorAll("#ryd-dislike-tooltip").length,
wrappers: document.querySelectorAll(".ryd-tooltip").length,
},
viewport: { height: innerHeight, width: innerWidth },
wrapper: rect(wrapper),
wrapperAppearance: {
box: box(wrapper),
position: wrapperStyle.position,
},
};
});
}
function expectClose(actual, expected, precision = 5) {
expect(actual).toBeCloseTo(expected, precision);
}
function expectComputedColor(actual, expectedChannel) {
const srgb = actual.match(/^color\(srgb\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)/);
const channels = srgb
? srgb.slice(1).map((channel) => Number(channel) * 255)
: actual
.match(/[\d.]+/g)
?.slice(0, 3)
.map(Number);
expect(channels).toHaveLength(3);
channels.forEach((channel) => expect(channel).toBeCloseTo(expectedChannel, 0));
}
function expectControlGeometry(control) {
expectClose(control.outer.width, 96);
expectClose(control.outer.height, 36);
expectClose(control.button.width, 96);
expectClose(control.button.height, 36);
expect(control.outerBox).toEqual({
margin: ["0px", "0px", "0px", "0px"],
padding: ["0px", "0px", "0px", "0px"],
});
expect(control.buttonBox).toEqual({
margin: ["0px", "0px", "0px", "0px"],
padding: ["0px", "12px", "0px", "12px"],
});
expect(control.iconBox).toEqual({
margin: ["0px", "0px", "0px", "0px"],
padding: ["0px", "0px", "0px", "0px"],
});
expect(control.textBox).toEqual({
margin: ["0px", "0px", "0px", "0px"],
padding: ["0px", "0px", "0px", "0px"],
});
expectClose(control.icon.width, 20);
expectClose(control.icon.height, 20);
expectClose(control.icon.top + control.icon.height / 2, control.button.top + control.button.height / 2);
expectClose(control.text.top + control.text.height / 2, control.button.top + control.button.height / 2);
expectClose(control.text.left - control.icon.right, 6);
}
function expectPressedState(control, pressed) {
expect(control.ariaPressed).toBe(String(pressed));
expect(control.classes).toEqual([pressed ? "style-default-active" : "style-text"]);
expect(control.backgroundColor).toBe(pressed ? "rgb(241, 241, 241)" : "rgb(39, 39, 39)");
expect(control.color).toBe(pressed ? "rgb(15, 15, 15)" : "rgb(241, 241, 241)");
}
function expectVisualState(snapshot, state, counts, viewport) {
expect(snapshot.viewport).toEqual({ height: viewport.height, width: viewport.width });
expect(snapshot.unique).toEqual({ bars: 1, containers: 1, tooltips: 1, wrappers: 1 });
expect(snapshot.ownership).toEqual({
barOwnedByButtons: true,
tooltipDescribedBy: "ryd-dislike-tooltip",
tooltipOwnedByButtons: true,
tooltipRole: "tooltip",
wrapperOwnedByButtons: true,
});
expect(snapshot.tooltipText).toBe(`${counts.likes} / ${counts.dislikes}`);
expect(snapshot.buttonsGap).toBe("0px");
expect(snapshot.buttonsPosition).toBe("relative");
expect(snapshot.buttonsBox).toEqual({
margin: ["0px", "0px", "0px", "0px"],
padding: ["0px", "0px", "0px", "0px"],
});
expect(snapshot.reactionGroupBox).toEqual(snapshot.buttonsBox);
expect(snapshot.topRowBox).toEqual({
margin: ["0px", "0px", "0px", "0px"],
padding: ["0px", "0px", "10px", "0px"],
});
expect(snapshot.topRowStyle).toEqual({ borderBottomWidth: "1px", paddingBottom: "10px" });
expect(snapshot.wrapperAppearance).toEqual({
box: {
margin: ["0px", "0px", "0px", "0px"],
padding: ["0px", "0px", "0px", "0px"],
},
position: "absolute",
});
expect(snapshot.barContainerAppearance.borderRadius).toBe("2px");
expectComputedColor(snapshot.barContainerAppearance.backgroundColor, 139);
expect(snapshot.barAppearance).toEqual({
backgroundColor: "rgb(241, 241, 241)",
borderRadius: "2px",
});
expect(snapshot.tooltipAppearance).toEqual({
fontSize: "12px",
lineHeight: "16px",
opacity: "0",
padding: ["6px", "8px", "6px", "8px"],
position: "absolute",
visibility: "hidden",
});
expectControlGeometry(snapshot.like);
expectControlGeometry(snapshot.dislike);
expectPressedState(snapshot.like, state === "liked");
expectPressedState(snapshot.dislike, state === "disliked");
expectClose(snapshot.like.outer.right, snapshot.dislike.outer.left);
expectClose(snapshot.wrapper.left, snapshot.like.outer.left);
expectClose(snapshot.wrapper.right, snapshot.dislike.outer.right);
expectClose(snapshot.wrapper.width, snapshot.like.outer.width + snapshot.dislike.outer.width);
expectClose(snapshot.wrapper.height, 2);
expectClose(snapshot.wrapper.top - Math.max(snapshot.like.outer.bottom, snapshot.dislike.outer.bottom), 8);
expectClose(snapshot.barContainer.width, snapshot.wrapper.width);
expectClose(snapshot.barContainer.height, 2);
expectClose(snapshot.bar.height, 2);
expectClose(snapshot.bar.width / snapshot.barContainer.width, counts.likes / (counts.likes + counts.dislikes), 3);
expect(snapshot.topRow.left).toBeGreaterThanOrEqual(0);
expect(snapshot.topRow.right).toBeLessThanOrEqual(snapshot.viewport.width);
expect(snapshot.wrapper.left).toBeGreaterThanOrEqual(snapshot.topRow.left);
expect(snapshot.wrapper.right).toBeLessThanOrEqual(snapshot.topRow.right);
}
function expectNoStructuralLayoutShift(before, after) {
const paths = [
["topRow"],
["buttons"],
["reactionGroup"],
["wrapper"],
["barContainer"],
["like", "outer"],
["like", "button"],
["dislike", "outer"],
["dislike", "button"],
];
const dimensions = ["top", "right", "bottom", "left", "width", "height"];
for (const path of paths) {
const beforeRect = path.reduce((value, key) => value[key], before);
const afterRect = path.reduce((value, key) => value[key], after);
for (const dimension of dimensions) {
expect(
Math.abs(afterRect[dimension] - beforeRect[dimension]),
`${path.join(".")}.${dimension}`,
).toBeLessThanOrEqual(0.25);
}
}
}
async function waitForBarRatio(page, counts) {
const expectedRatio = counts.likes / (counts.likes + counts.dislikes);
await expect
.poll(() =>
page.locator("#return-youtube-dislike-bar").evaluate((bar) => {
const container = bar.parentElement;
return bar.getBoundingClientRect().width / container.getBoundingClientRect().width;
}),
)
.toBeCloseTo(expectedRatio, 3);
}
async function attachFailureScreenshot(testInfo, page, name) {
let body;
try {
const scope = page.locator("#top-row");
body = (await scope.count()) > 0 ? await scope.screenshot({ animations: "disabled" }) : await page.screenshot();
} catch {
body = await page.screenshot().catch(() => null);
}
if (body) {
await testInfo.attach(`${name}-visual-contract-failure`, { body, contentType: "image/png" });
}
}
for (const viewport of VIEWPORTS) {
test.describe(`${viewport.name} watch visual contract`, () => {
for (const transition of TRANSITIONS) {
const name = `${transition.initialState} + ${transition.action} -> ${transition.nextState}`;
test(name, async ({ context, page }, testInfo) => {
await page.setViewportSize({ width: viewport.width, height: viewport.height });
try {
const harness = await launchVisualFixture({ context, page }, transition.initialState);
const before = await readVisualContract(page);
expectVisualState(before, transition.initialState, BASE_COUNTS, viewport);
await page.locator(`[data-ryd-role="${transition.action}"] button`).click();
await expect.poll(() => harness.backend.requestsFor("POST", "/interact/confirmVote").length).toBe(1);
const nextCounts = {
likes: BASE_COUNTS.likes + transition.likesDelta,
dislikes: BASE_COUNTS.dislikes + transition.dislikesDelta,
};
await waitForBarRatio(page, nextCounts);
const after = await readVisualContract(page);
expectVisualState(after, transition.nextState, nextCounts, viewport);
expectNoStructuralLayoutShift(before, after);
expect(harness.backend.requestsFor("GET", "/votes")).toHaveLength(1);
expect(harness.backend.requestsFor("POST", "/interact/vote")).toHaveLength(1);
expect(harness.backend.requestsFor("POST", "/interact/vote")[0].body).toMatchObject({
userId: EXISTING_CREDENTIALS.userId,
value: transition.value,
videoId: VIDEO_A,
});
expect(harness.backend.requestsFor("POST", "/interact/confirmVote")).toHaveLength(1);
expect(harness.backend.blockedRequests).toEqual([]);
expect(harness.consoleErrors).toEqual([]);
expect(harness.pageErrors).toEqual([]);
} catch (error) {
await attachFailureScreenshot(testInfo, page, `${viewport.name}-${name}`);
throw error;
}
});
}
});
}
@@ -0,0 +1,67 @@
const { test, expect } = require("@playwright/test");
const {
CREDENTIAL_KEY,
VIDEO_A,
createFakeBackend,
forbidUnsafeHtmlSinks,
injectGeneratedUserscript,
installGmEnvironment,
installHermeticRoutes,
openShortsFixture,
openWatchFixture,
} = require("./harness");
const EXISTING_CREDENTIALS = {
userId: "ExistingUserscriptCredential000000000001",
registrationConfirmed: true,
};
async function prepareGuardedFixture({ context, page }, openFixture) {
const pageErrors = [];
page.on("pageerror", (error) => pageErrors.push(error.message));
const backend = createFakeBackend();
await installGmEnvironment(context, { [CREDENTIAL_KEY]: EXISTING_CREDENTIALS });
await installHermeticRoutes(context, backend);
await openFixture(page, VIDEO_A);
await forbidUnsafeHtmlSinks(page);
await injectGeneratedUserscript(page);
return { backend, pageErrors };
}
async function expectNoUnsafeHtmlUsage(page, backend, pageErrors) {
expect(await page.evaluate(() => globalThis.__rydUnsafeHtmlSinkCalls)).toEqual([]);
expect(backend.blockedRequests).toEqual([]);
expect(pageErrors).toEqual([]);
}
test("watch ratio bar initializes when unsafe HTML sinks are forbidden", async ({ context, page }) => {
const { backend, pageErrors } = await prepareGuardedFixture({ context, page }, openWatchFixture);
await expect(page.locator('[data-ryd-role="dislike"] #text')).toHaveText("25");
expect(await page.evaluate(() => globalThis.__rydUnsafeHtmlSinkCalls)).toEqual([]);
await expect(page.locator("#return-youtube-dislike-bar-container")).toBeVisible();
await expect(page.locator("#return-youtube-dislike-bar")).toBeVisible();
await expect(page.locator("#ryd-dislike-tooltip")).toContainText("100 / 25");
await expectNoUnsafeHtmlUsage(page, backend, pageErrors);
});
test("modern Shorts synthetic dislike initializes when unsafe HTML sinks are forbidden", async ({ context, page }) => {
const { backend, pageErrors } = await prepareGuardedFixture({ context, page }, openShortsFixture);
const syntheticDislike = page.locator("[data-ryd-synthetic-shorts-dislike]");
await expect
.poll(() =>
page.evaluate(() => ({
initialized: Boolean(document.querySelector("[data-ryd-synthetic-shorts-dislike]")),
unsafeHtmlSinkCalls: globalThis.__rydUnsafeHtmlSinkCalls,
})),
)
.toEqual({ initialized: true, unsafeHtmlSinkCalls: [] });
await expect(syntheticDislike).toBeVisible();
await expect(syntheticDislike.locator("button")).toHaveAttribute("aria-pressed", "false");
await expect(syntheticDislike.locator("#text")).toHaveText("25");
await expect(syntheticDislike.locator("svg path")).toHaveCount(1);
await expectNoUnsafeHtmlUsage(page, backend, pageErrors);
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,172 @@
/**
* @jest-environment jsdom
*/
const path = require("node:path");
const {
LiveRunDiagnostics,
UNHANDLED_REJECTION_PREFIX,
diagnosticApiUrl,
readLivePageState,
runLoggedStage,
} = require("../e2e/live/live-diagnostics");
class FakeEmitter {
constructor() {
this.listeners = new Map();
}
emit(event, value) {
for (const listener of this.listeners.get(event) ?? []) listener(value);
}
off(event, listener) {
this.listeners.get(event)?.delete(listener);
}
on(event, listener) {
if (!this.listeners.has(event)) this.listeners.set(event, new Set());
this.listeners.get(event).add(listener);
}
}
function visibleRect() {
return { bottom: 100, height: 90, left: 10, right: 60, top: 10, width: 50 };
}
describe("live interactive diagnostics", () => {
test("captures runtime and Shorts ownership state without dumping page HTML", () => {
document.documentElement.setAttribute("data-ryd-userscript-version", "3.2.0");
document.body.innerHTML = `
<ytd-reel-video-renderer video-id="abcdefghijk" is-active>
<a href="/shorts/abcdefghijk">Short</a>
<reel-action-bar-view-model>
<like-button-view-model></like-button-view-model>
<button-view-model data-ryd-synthetic-shorts-dislike data-ryd-video-id="abcdefghijk">
<button aria-pressed="false"></button><span>123</span>
</button-view-model>
</reel-action-bar-view-model>
</ytd-reel-video-renderer>
`;
for (const element of document.querySelectorAll("*")) element.getBoundingClientRect = visibleRect;
const state = readLivePageState();
expect(state.runtimeMarkers).toEqual({
extension: null,
extensionBuild: null,
userscript: "3.2.0",
userscriptBuild: null,
});
expect(state.renderers).toEqual([
expect.objectContaining({ actionBars: 1, syntheticControls: 1, videoId: "abcdefghijk", visible: true }),
]);
expect(state.renderers[0].links).toEqual([{ href: "/shorts/abcdefghijk", visible: true }]);
expect(state.actionBars).toEqual([
expect.objectContaining({ nativeLikes: 1, syntheticControls: 1, videoId: "abcdefghijk", visible: true }),
]);
expect(state.syntheticControls).toEqual([
expect.objectContaining({ ariaPressed: "false", text: "123", videoId: "abcdefghijk", visible: true }),
]);
expect(JSON.stringify(state)).not.toContain("outerHTML");
});
test("records browser failures and redacted recent API traffic in the persisted snapshot", async () => {
const context = new FakeEmitter();
const page = new FakeEmitter();
page.addInitScript = jest.fn().mockResolvedValue(undefined);
page.evaluate = jest.fn(async (callback) =>
callback.name === "readLivePageState" ? { runtimeMarkers: { userscript: "3.2.0" } } : undefined,
);
page.isClosed = jest.fn(() => false);
page.url = jest.fn(() => "https://www.youtube.com/shorts/abcdefghijk");
const fileSystem = { mkdirSync: jest.fn(), writeFileSync: jest.fn() };
const log = jest.fn();
const diagnostics = new LiveRunDiagnostics(page, context, {
clock: () => new Date("2026-08-18T12:34:56.000Z"),
fileSystem,
log,
outputDirectory: "diagnostics",
runtime: "userscript",
});
await diagnostics.start();
diagnostics.stageStarted("read-only.channel-to-shorts-and-next");
diagnostics.checkpoint("ryd-votes-response.waiting", { videoId: "abcdefghijk" });
const request = {
method: () => "GET",
resourceType: () => "fetch",
url: () => "https://returnyoutubedislikeapi.com/votes?videoId=abcdefghijk&userId=private-id",
};
context.emit("request", request);
context.emit("response", { request: () => request, status: () => 200 });
page.emit("pageerror", new Error("page exploded"));
page.emit("console", {
location: () => ({ lineNumber: 7, url: "https://www.youtube.com/shorts/abcdefghijk" }),
text: () => `${UNHANDLED_REJECTION_PREFIX}promise exploded`,
type: () => "error",
});
page.emit("console", {
location: () => ({}),
text: () => "console exploded",
type: () => "error",
});
const snapshotPath = await diagnostics.persistFailureSnapshot(new Error("scenario timed out"));
diagnostics.stop();
expect(snapshotPath).toBe(path.join("diagnostics", "failure-2026-08-18T12-34-56-000Z.json"));
expect(fileSystem.mkdirSync).toHaveBeenCalledWith("diagnostics", { recursive: true });
const snapshot = JSON.parse(fileSystem.writeFileSync.mock.calls[0][1]);
expect(snapshot.currentStage).toBe("read-only.channel-to-shorts-and-next");
expect(snapshot.currentCheckpoint).toBe("ryd-votes-response.waiting");
expect(snapshot.browserSignals.map(({ type }) => type)).toEqual([
"pageerror",
"unhandledrejection",
"console.error",
]);
expect(snapshot.recentApiRequests).toEqual([
expect.objectContaining({
method: "GET",
pathname: "/votes",
query: { userId: "<redacted>", videoId: "abcdefghijk" },
status: 200,
}),
]);
expect(snapshot.pageState).toEqual({ runtimeMarkers: { userscript: "3.2.0" } });
expect(snapshot.url).toBe("https://www.youtube.com/shorts/abcdefghijk");
expect(context.listeners.get("request").size).toBe(0);
expect(page.listeners.get("pageerror").size).toBe(0);
});
test("logs stage completion and failure boundaries", async () => {
const diagnostics = {
stageCompleted: jest.fn(),
stageFailed: jest.fn(),
stageStarted: jest.fn(),
};
await expect(runLoggedStage(diagnostics, "success", async () => 42)).resolves.toBe(42);
const failure = new Error("failed");
await expect(
runLoggedStage(diagnostics, "failure", async () => {
throw failure;
}),
).rejects.toBe(failure);
expect(diagnostics.stageStarted.mock.calls.map(([name]) => name)).toEqual(["success", "failure"]);
expect(diagnostics.stageCompleted).toHaveBeenCalledWith("success", expect.any(Number));
expect(diagnostics.stageFailed).toHaveBeenCalledWith("failure", expect.any(Number), failure);
});
test("redacts identity and proof material while retaining diagnostic video IDs", () => {
expect(
diagnosticApiUrl(
"https://returnyoutubedislikeapi.com/puzzle/registration?userId=private&videoId=abcdefghijk&solution=secret",
),
).toEqual({
pathname: "/puzzle/registration",
query: { solution: "<redacted>", userId: "<redacted>", videoId: "abcdefghijk" },
});
});
});
+237
View File
@@ -0,0 +1,237 @@
const crypto = require("node:crypto");
const fs = require("node:fs");
const path = require("node:path");
const extensionPackage = require("../../../package.json");
const userscriptVersion = require("../userscript-version.json");
const LIVE_VOTE_APPROVAL_WINDOW_SECONDS = 120;
const LIVE_VOTE_APPROVALS_DIRECTORY = path.resolve(__dirname, "../../../test-results/live-youtube-vote-approvals");
const DEFAULT_LIVE_NAV_CHANNEL_URL = "https://www.youtube.com/@SmashTrash";
const DEFAULT_LIVE_NAV_SHORT = "iKQhN7omLM4";
const DEFAULT_LIVE_SIDEBAR_HOPS = 3;
const MAX_LIVE_SIDEBAR_HOPS = 10;
const VIDEO_ID_PATTERN = /^[A-Za-z0-9_-]{11}$/;
const LIVE_BUILD_ID_PATTERN = /^[a-f0-9]{32}$/;
const SUPPORTED_RUNTIMES = new Set(["userscript", "extension"]);
const LIVE_BUILD_MARKER_PATHS = {
extension: path.resolve(__dirname, "../../combined/dist/chrome/live-build.json"),
userscript: path.resolve(__dirname, "../../../test-results/live-build/userscript/live-build.json"),
};
function requireValue(environment, name) {
const value = environment[name]?.trim();
if (!value) throw new Error(`${name} is required for the live YouTube smoke suite.`);
return value;
}
function requireVideoId(environment, name) {
const value = requireValue(environment, name);
if (!VIDEO_ID_PATTERN.test(value)) {
throw new Error(`${name} must be an 11-character YouTube video ID.`);
}
return value;
}
function optionalVideoId(environment, name) {
const value = environment[name]?.trim();
if (!value) return null;
if (!VIDEO_ID_PATTERN.test(value)) {
throw new Error(`${name} must be an 11-character YouTube video ID when provided.`);
}
return value;
}
function optionalBoundedInteger(environment, name, defaultValue, maximum) {
const rawValue = environment[name]?.trim();
if (!rawValue) return defaultValue;
if (!/^\d+$/.test(rawValue)) {
throw new Error(`${name} must be a whole number from 1 to ${maximum}.`);
}
const value = Number(rawValue);
if (!Number.isSafeInteger(value) || value < 1 || value > maximum) {
throw new Error(`${name} must be a whole number from 1 to ${maximum}.`);
}
return value;
}
function requireChannelHandle(environment) {
const value = requireValue(environment, "RYD_LIVE_EXPECTED_CHANNEL");
if (!/^@[A-Za-z0-9._-]{3,100}$/.test(value)) {
throw new Error("RYD_LIVE_EXPECTED_CHANNEL must be the public @handle of the signed-in YouTube test channel.");
}
return value;
}
function liveVoteApproval(runtime, videoId, unixSeconds) {
return `${runtime}:${videoId}:${unixSeconds}`;
}
function hasFreshVoteApproval(value, runtime, videoId, nowMilliseconds) {
if (!value) return false;
const [approvedRuntime, approvedVideoId, approvedAt, ...rest] = value.split(":");
if (rest.length || approvedRuntime !== runtime || approvedVideoId !== videoId) return false;
const approvedAtSeconds = Number(approvedAt);
if (!Number.isSafeInteger(approvedAtSeconds)) return false;
const ageSeconds = Math.floor(nowMilliseconds / 1000) - approvedAtSeconds;
return ageSeconds >= 0 && ageSeconds <= LIVE_VOTE_APPROVAL_WINDOW_SECONDS;
}
function consumeLiveVoteApproval(
value,
runtime,
videoId,
{ nowMilliseconds = Date.now(), usedApprovalsDirectory } = {},
) {
if (!hasFreshVoteApproval(value, runtime, videoId, nowMilliseconds)) return false;
const directory = usedApprovalsDirectory || LIVE_VOTE_APPROVALS_DIRECTORY;
fs.mkdirSync(directory, { recursive: true });
const approvalHash = crypto.createHash("sha256").update(value).digest("hex");
try {
fs.writeFileSync(path.join(directory, approvalHash), `${runtime}:${videoId}\n`, { flag: "wx" });
return true;
} catch (error) {
if (error.code === "EEXIST") return false;
throw error;
}
}
function parsePlaylistUrl(value, watchVideoId) {
let url;
try {
url = new URL(value);
} catch {
throw new Error("RYD_LIVE_PLAYLIST_URL must be a valid HTTPS YouTube watch URL.");
}
if (
url.protocol !== "https:" ||
!["www.youtube.com", "youtube.com"].includes(url.hostname) ||
url.pathname !== "/watch" ||
url.searchParams.get("v") !== watchVideoId ||
!url.searchParams.get("list")
) {
throw new Error(
"RYD_LIVE_PLAYLIST_URL must be an HTTPS YouTube watch URL for RYD_LIVE_WATCH_A with a playlist ID.",
);
}
return url.toString();
}
function parseChannelUrl(value) {
let url;
try {
url = new URL(value);
} catch {
throw new Error("RYD_LIVE_NAV_CHANNEL_URL must be a valid HTTPS YouTube channel URL.");
}
const isSafeChannelPath = /^\/@[A-Za-z0-9._-]{3,100}(?:\/(?:featured|shorts|videos))?\/?$/.test(url.pathname);
if (
url.protocol !== "https:" ||
!["www.youtube.com", "youtube.com"].includes(url.hostname) ||
url.port ||
url.username ||
url.password ||
url.search ||
url.hash ||
!isSafeChannelPath
) {
throw new Error(
"RYD_LIVE_NAV_CHANNEL_URL must be a plain HTTPS youtube.com /@handle channel, featured, Shorts, or videos URL.",
);
}
url.hostname = "www.youtube.com";
return url.toString();
}
function readExpectedBuildId(runtime, { markerPaths = LIVE_BUILD_MARKER_PATHS, readFileSync = fs.readFileSync } = {}) {
const markerPath = markerPaths[runtime];
let marker;
try {
marker = JSON.parse(readFileSync(markerPath, "utf8"));
} catch (error) {
throw new Error(
`Cannot read the ${runtime} live-build marker at ${markerPath}. Build that live artifact before running the smoke.`,
{ cause: error },
);
}
if (!LIVE_BUILD_ID_PATTERN.test(marker?.buildId)) {
throw new Error(`The ${runtime} live-build marker at ${markerPath} is malformed.`);
}
return marker.buildId;
}
function readLiveOptions(
environment = process.env,
nowMilliseconds = Date.now(),
{ readBuildId = readExpectedBuildId } = {},
) {
if (environment.RYD_LIVE_YOUTUBE !== "1") return null;
if (environment.RYD_LIVE_PRODUCTION_API !== "1") {
throw new Error(
"RYD_LIVE_PRODUCTION_API=1 is required because the installed runtime will contact the production RYD API.",
);
}
const runtime = requireValue(environment, "RYD_LIVE_RUNTIME");
if (!SUPPORTED_RUNTIMES.has(runtime)) {
throw new Error('RYD_LIVE_RUNTIME must be either "userscript" or "extension".');
}
const watchA = requireVideoId(environment, "RYD_LIVE_WATCH_A");
const watchB = requireVideoId(environment, "RYD_LIVE_WATCH_B");
const short = requireVideoId(environment, "RYD_LIVE_SHORT");
if (watchA === watchB) throw new Error("RYD_LIVE_WATCH_A and RYD_LIVE_WATCH_B must be different videos.");
const expectedVersion =
environment.RYD_LIVE_EXPECTED_VERSION?.trim() ||
(runtime === "userscript" ? userscriptVersion : extensionPackage.version);
return {
cdpEndpoint: environment.RYD_CDP_ENDPOINT?.trim() || "chrome",
expectedBuildId: readBuildId(runtime),
expectedChannel: requireChannelHandle(environment),
expectedVersion,
navigation: {
channelUrl: parseChannelUrl(environment.RYD_LIVE_NAV_CHANNEL_URL?.trim() || DEFAULT_LIVE_NAV_CHANNEL_URL),
short: requireVideoId(
{ RYD_LIVE_NAV_SHORT: environment.RYD_LIVE_NAV_SHORT?.trim() || DEFAULT_LIVE_NAV_SHORT },
"RYD_LIVE_NAV_SHORT",
),
watch: optionalVideoId(environment, "RYD_LIVE_NAV_WATCH"),
},
playlistUrl: parsePlaylistUrl(requireValue(environment, "RYD_LIVE_PLAYLIST_URL"), watchA),
productionApiApproved: true,
runtime,
sidebar: {
hopCount: optionalBoundedInteger(
environment,
"RYD_LIVE_SIDEBAR_HOPS",
DEFAULT_LIVE_SIDEBAR_HOPS,
MAX_LIVE_SIDEBAR_HOPS,
),
},
short,
watchA,
watchB,
};
}
module.exports = {
DEFAULT_LIVE_NAV_CHANNEL_URL,
DEFAULT_LIVE_NAV_SHORT,
DEFAULT_LIVE_SIDEBAR_HOPS,
LIVE_VOTE_APPROVALS_DIRECTORY,
LIVE_VOTE_APPROVAL_WINDOW_SECONDS,
consumeLiveVoteApproval,
hasFreshVoteApproval,
liveVoteApproval,
readExpectedBuildId,
readLiveOptions,
};
@@ -0,0 +1,223 @@
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const {
DEFAULT_LIVE_NAV_CHANNEL_URL,
DEFAULT_LIVE_NAV_SHORT,
DEFAULT_LIVE_SIDEBAR_HOPS,
LIVE_VOTE_APPROVALS_DIRECTORY,
LIVE_VOTE_APPROVAL_WINDOW_SECONDS,
consumeLiveVoteApproval,
hasFreshVoteApproval,
liveVoteApproval,
readExpectedBuildId,
readLiveOptions: readLiveOptionsFromEnvironment,
} = require("./live-options");
const NOW = Date.parse("2026-08-08T12:00:00.000Z");
const EXPECTED_BUILD_ID = "0123456789abcdef0123456789abcdef";
const VALID_ENVIRONMENT = {
RYD_LIVE_YOUTUBE: "1",
RYD_LIVE_PRODUCTION_API: "1",
RYD_LIVE_RUNTIME: "userscript",
RYD_LIVE_WATCH_A: "abcdefghijk",
RYD_LIVE_WATCH_B: "zyxwvutsrqp",
RYD_LIVE_SHORT: "shortsabcde",
RYD_LIVE_PLAYLIST_URL: "https://www.youtube.com/watch?v=abcdefghijk&list=PL-test",
RYD_LIVE_EXPECTED_CHANNEL: "@ryd-test",
};
function readLiveOptions(environment, nowMilliseconds = NOW) {
return readLiveOptionsFromEnvironment(environment, nowMilliseconds, {
readBuildId: () => EXPECTED_BUILD_ID,
});
}
describe("live YouTube options", () => {
test("reads the exact generated build ID from the selected runtime marker", () => {
const readFileSync = jest.fn(() => JSON.stringify({ buildId: EXPECTED_BUILD_ID }));
expect(
readExpectedBuildId("userscript", {
markerPaths: { userscript: "owned-live-build.json" },
readFileSync,
}),
).toBe(EXPECTED_BUILD_ID);
expect(readFileSync).toHaveBeenCalledWith("owned-live-build.json", "utf8");
});
test.each([
[
"missing",
() => {
throw Object.assign(new Error("missing"), { code: "ENOENT" });
},
/Cannot read/,
],
["invalid JSON", () => "not JSON", /Cannot read/],
["malformed ID", () => JSON.stringify({ buildId: "stale" }), /is malformed/],
])("rejects a %s generated live-build marker", (_label, readFileSync, expectedMessage) => {
expect(() =>
readExpectedBuildId("extension", {
markerPaths: { extension: "owned-live-build.json" },
readFileSync,
}),
).toThrow(expectedMessage);
});
test("stores consumed vote approvals outside Playwright's cleaned output directory", () => {
const playwrightOutputDirectory = path.resolve(__dirname, "../../../test-results/live-youtube");
expect(LIVE_VOTE_APPROVALS_DIRECTORY).not.toBe(playwrightOutputDirectory);
expect(LIVE_VOTE_APPROVALS_DIRECTORY.startsWith(`${playwrightOutputDirectory}${path.sep}`)).toBe(false);
});
test("stays disabled unless explicitly opted in", () => {
expect(readLiveOptions({})).toBeNull();
});
test("requires production API acknowledgement", () => {
expect(() => readLiveOptions({ ...VALID_ENVIRONMENT, RYD_LIVE_PRODUCTION_API: undefined })).toThrow(
"RYD_LIVE_PRODUCTION_API=1",
);
});
test.each(["", "tampermonkey", "chrome"])("rejects unsupported runtime %p", (runtime) => {
expect(() => readLiveOptions({ ...VALID_ENVIRONMENT, RYD_LIVE_RUNTIME: runtime })).toThrow(/RYD_LIVE_RUNTIME/);
});
test("requires distinct valid video IDs", () => {
expect(() => readLiveOptions({ ...VALID_ENVIRONMENT, RYD_LIVE_SHORT: "too-short" })).toThrow(
"11-character YouTube video ID",
);
expect(() =>
readLiveOptions({
...VALID_ENVIRONMENT,
RYD_LIVE_WATCH_B: VALID_ENVIRONMENT.RYD_LIVE_WATCH_A,
}),
).toThrow("must be different videos");
});
test("requires a playlist URL anchored at watch A", () => {
expect(() =>
readLiveOptions({
...VALID_ENVIRONMENT,
RYD_LIVE_PLAYLIST_URL: "https://www.youtube.com/watch?v=zyxwvutsrqp&list=PL-test",
}),
).toThrow("for RYD_LIVE_WATCH_A");
});
test("requires the expected signed-in channel handle", () => {
expect(() => readLiveOptions({ ...VALID_ENVIRONMENT, RYD_LIVE_EXPECTED_CHANNEL: "not-a-handle" })).toThrow(
"public @handle",
);
});
test.each(["userscript", "extension"])("accepts the %s runtime", (runtime) => {
expect(readLiveOptions({ ...VALID_ENVIRONMENT, RYD_LIVE_RUNTIME: runtime })).toMatchObject({
cdpEndpoint: "chrome",
expectedBuildId: EXPECTED_BUILD_ID,
navigation: {
channelUrl: DEFAULT_LIVE_NAV_CHANNEL_URL,
short: DEFAULT_LIVE_NAV_SHORT,
watch: null,
},
runtime,
sidebar: { hopCount: DEFAULT_LIVE_SIDEBAR_HOPS },
short: "shortsabcde",
watchA: "abcdefghijk",
watchB: "zyxwvutsrqp",
});
});
test("takes the exact build ID from the generated marker and ignores environment attempts to bless a stale build", () => {
const readBuildId = jest.fn(() => EXPECTED_BUILD_ID);
const result = readLiveOptionsFromEnvironment(
{ ...VALID_ENVIRONMENT, RYD_LIVE_EXPECTED_BUILD_ID: "f".repeat(32) },
NOW,
{ readBuildId },
);
expect(readBuildId).toHaveBeenCalledWith("userscript");
expect(result.expectedBuildId).toBe(EXPECTED_BUILD_ID);
});
test("accepts a bounded sidebar stress hop count", () => {
expect(readLiveOptions({ ...VALID_ENVIRONMENT, RYD_LIVE_SIDEBAR_HOPS: "5" })).toMatchObject({
sidebar: { hopCount: 5 },
});
});
test.each(["0", "11", "1.5", "three", "-1"])("rejects invalid sidebar stress hop count %p", (hopCount) => {
expect(() => readLiveOptions({ ...VALID_ENVIRONMENT, RYD_LIVE_SIDEBAR_HOPS: hopCount })).toThrow(
/RYD_LIVE_SIDEBAR_HOPS.*whole number from 1 to 10/,
);
});
test("accepts an exact, safely scoped channel-navigation dataset", () => {
expect(
readLiveOptions({
...VALID_ENVIRONMENT,
RYD_LIVE_NAV_CHANNEL_URL: "https://youtube.com/@ryd-test/shorts",
RYD_LIVE_NAV_SHORT: "navshort001",
RYD_LIVE_NAV_WATCH: "navwatch001",
}),
).toMatchObject({
navigation: {
channelUrl: "https://www.youtube.com/@ryd-test/shorts",
short: "navshort001",
watch: "navwatch001",
},
});
});
test.each([
"http://www.youtube.com/@SmashTrash",
"https://example.com/@SmashTrash",
"https://www.youtube.com.evil.example/@SmashTrash",
"https://www.youtube.com/channel/UC-not-a-handle",
"https://www.youtube.com/@SmashTrash/playlists",
"https://www.youtube.com/@SmashTrash?app=desktop",
])("rejects unsafe or non-deterministic navigation channel URL %p", (channelUrl) => {
expect(() => readLiveOptions({ ...VALID_ENVIRONMENT, RYD_LIVE_NAV_CHANNEL_URL: channelUrl })).toThrow(
/RYD_LIVE_NAV_CHANNEL_URL/,
);
});
test.each(["RYD_LIVE_NAV_SHORT", "RYD_LIVE_NAV_WATCH"])("validates optional navigation ID %s", (name) => {
expect(() => readLiveOptions({ ...VALID_ENVIRONMENT, [name]: "invalid" })).toThrow("11-character YouTube video ID");
});
test("accepts only a fresh runtime-and-video-specific vote approval", () => {
const nowSeconds = Math.floor(NOW / 1000);
const validApproval = liveVoteApproval("userscript", VALID_ENVIRONMENT.RYD_LIVE_WATCH_B, nowSeconds);
expect(hasFreshVoteApproval(validApproval, "userscript", VALID_ENVIRONMENT.RYD_LIVE_WATCH_B, NOW)).toBe(true);
for (const approval of [
liveVoteApproval("extension", VALID_ENVIRONMENT.RYD_LIVE_WATCH_B, nowSeconds),
liveVoteApproval("userscript", VALID_ENVIRONMENT.RYD_LIVE_WATCH_A, nowSeconds),
liveVoteApproval(
"userscript",
VALID_ENVIRONMENT.RYD_LIVE_WATCH_B,
nowSeconds - LIVE_VOTE_APPROVAL_WINDOW_SECONDS - 1,
),
]) {
expect(hasFreshVoteApproval(approval, "userscript", VALID_ENVIRONMENT.RYD_LIVE_WATCH_B, NOW)).toBe(false);
}
const futureApproval = liveVoteApproval("userscript", VALID_ENVIRONMENT.RYD_LIVE_WATCH_B, nowSeconds + 1);
expect(hasFreshVoteApproval(futureApproval, "userscript", VALID_ENVIRONMENT.RYD_LIVE_WATCH_B, NOW)).toBe(false);
});
test("consumes a fresh vote approval only once", () => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "ryd-live-approval-"));
const approval = liveVoteApproval("userscript", VALID_ENVIRONMENT.RYD_LIVE_WATCH_B, Math.floor(NOW / 1000));
try {
const settings = { nowMilliseconds: NOW, usedApprovalsDirectory: directory };
expect(consumeLiveVoteApproval(approval, "userscript", VALID_ENVIRONMENT.RYD_LIVE_WATCH_B, settings)).toBe(true);
expect(consumeLiveVoteApproval(approval, "userscript", VALID_ENVIRONMENT.RYD_LIVE_WATCH_B, settings)).toBe(false);
} finally {
fs.rmSync(directory, { recursive: true, force: true });
}
});
});
@@ -0,0 +1,119 @@
const { LiveYoutubeDriver } = require("../e2e/live/live-youtube-driver");
class FakeBrowserContext {
constructor() {
this.listeners = new Map();
this.routes = [];
this.routeCalls = 0;
this.unrouteCalls = 0;
}
on(event, handler) {
if (!this.listeners.has(event)) this.listeners.set(event, new Set());
this.listeners.get(event).add(handler);
}
off(event, handler) {
this.listeners.get(event)?.delete(handler);
}
async route(matcher, handler) {
this.routeCalls += 1;
this.routes.push({ handler, matcher });
}
async unroute(matcher, handler) {
this.unrouteCalls += 1;
this.routes = this.routes.filter((route) => route.matcher !== matcher || route.handler !== handler);
}
async dispatch(request) {
for (const listener of this.listeners.get("request") ?? []) listener(request);
const route = {
abort: jest.fn().mockResolvedValue(undefined),
fallback: jest.fn().mockResolvedValue(undefined),
request: () => request,
};
for (const registered of [...this.routes].reverse()) {
if (registered.matcher(new URL(request.url()))) {
await registered.handler(route);
break;
}
}
return route;
}
}
function createDriver(context) {
const page = {
setDefaultNavigationTimeout: jest.fn(),
setDefaultTimeout: jest.fn(),
};
return new LiveYoutubeDriver(page, context);
}
function request(method, url) {
return {
frame: jest.fn(() => {
throw new Error("Service-worker requests do not have a frame");
}),
method: () => method,
url: () => url,
};
}
describe("live read-only production-interaction guard", () => {
test("installs the route first, aborts a frame-less interaction POST, and reports the attempt", async () => {
const context = new FakeBrowserContext();
const driver = createDriver(context);
const frameLessRequest = request("POST", "https://returnyoutubedislikeapi.com/interact/vote?source=service-worker");
let interceptedRoute;
await expect(
driver.withNoProductionInteractions(async () => {
expect(context.routeCalls).toBe(1);
interceptedRoute = await context.dispatch(frameLessRequest);
}),
).rejects.toThrow("attempted a production interaction");
expect(interceptedRoute.abort).toHaveBeenCalledWith("blockedbyclient");
expect(interceptedRoute.fallback).not.toHaveBeenCalled();
expect(frameLessRequest.frame).not.toHaveBeenCalled();
expect(context.unrouteCalls).toBe(1);
expect(context.routes).toEqual([]);
});
test("shares one deny route across nested read-only guards", async () => {
const context = new FakeBrowserContext();
const driver = createDriver(context);
const frameLessRequest = request("POST", "https://returnyoutubedislikeapi.com/interact/confirmVote");
await expect(
driver.withNoProductionInteractions(() =>
driver.withNoProductionInteractions(async () => {
await context.dispatch(frameLessRequest);
}),
),
).rejects.toThrow("attempted a production interaction");
expect(context.routeCalls).toBe(1);
expect(context.unrouteCalls).toBe(1);
expect(frameLessRequest.frame).not.toHaveBeenCalled();
});
test("allows non-interaction traffic and removes the route after success", async () => {
const context = new FakeBrowserContext();
const driver = createDriver(context);
const result = await driver.withNoProductionInteractions(async () => {
const unrelatedRoute = await context.dispatch(request("POST", "https://www.youtube.com/youtubei/v1/player"));
expect(unrelatedRoute.abort).not.toHaveBeenCalled();
return "complete";
});
expect(result).toBe("complete");
expect(context.routeCalls).toBe(1);
expect(context.unrouteCalls).toBe(1);
expect(context.routes).toEqual([]);
});
});
@@ -0,0 +1,649 @@
const path = require("node:path");
const {
RESPONSIVE_VIEWPORTS,
runChannelShortsNavigationScenario,
runProductionReactionMatrixScenario,
runReactionCycle,
runResponsiveVisualScenario,
runSidebarStressScenario,
} = require("../e2e/live/live-scenarios");
const EXPECTED_ACTIONS = {
neutral: ["like", "like", "dislike", "like", "dislike", "dislike"],
liked: ["like", "dislike", "like", "dislike", "dislike", "like"],
disliked: ["like", "dislike", "dislike", "like", "like", "dislike"],
};
function nextState(state, action) {
if (action === "like") return state === "liked" ? "neutral" : "liked";
return state === "disliked" ? "neutral" : "disliked";
}
function valueForState(state) {
if (state === "liked") return 1;
if (state === "disliked") return -1;
return 0;
}
function createReactionHarness(
initialState,
{
clickThrowTransition = null,
failHandshake = null,
failHandshakes = failHandshake === null ? [] : [failHandshake],
failedVoteUserId = "shared-user-id",
rollbackOnClickThrow = false,
rollbackOnHandshakeFailure = false,
rollbackOnStateWaitFailure = false,
stateWaitFailTransition = null,
} = {},
) {
const events = [];
let currentState = initialState;
let currentVideo = null;
let handshakeNumber = 0;
let stateWaitNumber = 0;
const failedHandshakeNumbers = new Set(failHandshakes);
const driver = {
assertCurrentVideo: jest.fn((videoId) => expect(currentVideo).toBe(videoId)),
assertRuntime: jest.fn(),
assertSignedIn: jest.fn(),
clickAction: jest.fn(async (videoId, action) => {
expect(currentVideo).toBe(videoId);
currentState = nextState(currentState, action);
events.push({ action, state: currentState });
if (events.length === clickThrowTransition) {
if (rollbackOnClickThrow) currentState = initialState;
throw new Error("simulated post-dispatch click failure");
}
}),
openShort: jest.fn(async (videoId) => {
currentVideo = videoId;
}),
openWatch: jest.fn(async (videoId) => {
currentVideo = videoId;
}),
readReactionState: jest.fn(async () => currentState),
waitForDislikeText: jest.fn(async () => "123"),
waitForReactionState: jest.fn(async (expected) => {
stateWaitNumber += 1;
if (stateWaitNumber === stateWaitFailTransition) {
if (rollbackOnStateWaitFailure) currentState = initialState;
throw new Error("simulated post-click state-wait failure");
}
expect(currentState).toBe(expected);
}),
};
const recorder = {
mark: jest.fn(() => events.length),
waitForHandshake: jest.fn(async (value) => {
handshakeNumber += 1;
expect(value).toBe(valueForState(currentState));
if (failedHandshakeNumbers.has(handshakeNumber)) {
if (rollbackOnHandshakeFailure) currentState = initialState;
throw new Error("simulated handshake failure");
}
return "shared-user-id";
}),
voteUserId: jest.fn(() => failedVoteUserId),
};
return {
driver,
events,
getState: () => currentState,
recorder,
};
}
const OPTIONS = {
expectedBuildId: "0123456789abcdef0123456789abcdef",
expectedChannel: "@ryd-test",
expectedVersion: "3.2.0",
runtime: "userscript",
};
describe("live Shorts navigation stabilization", () => {
test("pauses the next Short only after its current control has rendered", async () => {
const events = [];
const driver = {
assertCurrentShortsControl: jest.fn(async (videoId) => {
events.push(`control:${videoId}`);
return { count: videoId === "abcdefghijk" ? "100" : "200", synthetic: true, videoId };
}),
assertRuntime: jest.fn(),
assertSignedIn: jest.fn(),
navigateFromColdChannelToShort: jest.fn(async () => events.push("channel-navigation")),
navigateToNextShort: jest.fn(async () => {
events.push("next-navigation");
return "lmnopqrstuv";
}),
pausePlayback: jest.fn(async () => events.push("pause")),
withNoProductionInteractions: jest.fn(async (action) => action()),
};
const options = {
...OPTIONS,
navigation: {
channelUrl: "https://www.youtube.com/@SmashTrash",
short: "abcdefghijk",
},
};
await expect(runChannelShortsNavigationScenario(driver, options)).resolves.toEqual({
initial: { count: "100", synthetic: true, videoId: "abcdefghijk" },
next: { count: "200", synthetic: true, videoId: "lmnopqrstuv" },
});
expect(events).toEqual([
"channel-navigation",
"control:abcdefghijk",
"next-navigation",
"control:lmnopqrstuv",
"pause",
]);
expect(driver.pausePlayback).toHaveBeenCalledTimes(1);
});
});
describe("live watch sidebar stress scenario", () => {
test.each(["userscript", "extension"])(
"takes consecutive unvisited related links and soaks one current %s bar after each exact response",
async (runtime) => {
const hopVideoIds = ["hopvideo001", "hopvideo002", "hopvideo003"];
const counts = ["101", "202", "303"];
let currentVideoId = null;
let hopIndex = 0;
const driver = {
assertCurrentVideo: jest.fn((videoId) => expect(currentVideoId).toBe(videoId)),
assertRuntime: jest.fn(),
assertSignedIn: jest.fn(),
captureWatchRatioVisual: jest.fn(async (_runtime, screenshotPath) => ({
count: counts[hopIndex - 1],
screenshotPath,
})),
navigateToRelatedWatch: jest.fn(async (excludedVideoIds) => {
expect(excludedVideoIds).toEqual(["abcdefghijk", ...hopVideoIds.slice(0, hopIndex)]);
const videoId = hopVideoIds[hopIndex];
const dislikes = (hopIndex + 1) * 1_000;
hopIndex += 1;
currentVideoId = videoId;
return { body: { dislikes }, videoId };
}),
openWatch: jest.fn(async (videoId) => {
currentVideoId = videoId;
}),
soakWatchRatioVisual: jest.fn(async (_runtime, settings) => ({
count: settings.expectedCount,
durationMs: settings.durationMs,
sampleCount: 9,
videoId: settings.videoId,
})),
withNoProductionInteractions: jest.fn(async (action) => action()),
};
const makeDirectory = jest.fn();
const options = { ...OPTIONS, runtime, sidebar: { hopCount: 3 }, watchA: "abcdefghijk" };
const result = await runSidebarStressScenario(driver, options, {
makeDirectory,
outputDirectory: "sidebar-evidence",
soakDurationMs: 25,
});
expect(makeDirectory).toHaveBeenCalledWith("sidebar-evidence");
expect(driver.openWatch).toHaveBeenCalledWith(options.watchA);
expect(driver.navigateToRelatedWatch).toHaveBeenCalledTimes(3);
expect(driver.captureWatchRatioVisual.mock.calls).toEqual(
hopVideoIds.map((_videoId, index) => [
runtime,
path.join("sidebar-evidence", `${runtime}-sidebar-hop-0${index + 1}.png`),
{ presenceTimeoutMs: 1_000 },
]),
);
expect(driver.soakWatchRatioVisual.mock.calls).toEqual(
hopVideoIds.map((videoId, index) => [runtime, { durationMs: 25, expectedCount: counts[index], videoId }]),
);
expect(driver.assertRuntime).toHaveBeenCalledTimes(4);
expect(driver.assertSignedIn).toHaveBeenCalledTimes(4);
expect(driver.withNoProductionInteractions).toHaveBeenCalledTimes(1);
expect(result).toEqual({
hopCount: 3,
hops: hopVideoIds.map((videoId, index) => ({
apiDislikes: (index + 1) * 1_000,
count: counts[index],
readyLatencyMs: expect.any(Number),
readyTimeoutMs: 1_000,
screenshotPath: path.join("sidebar-evidence", `${runtime}-sidebar-hop-0${index + 1}.png`),
soak: {
count: counts[index],
durationMs: 25,
sampleCount: 9,
videoId,
},
videoId,
})),
outputDirectory: "sidebar-evidence",
startVideoId: options.watchA,
});
},
);
});
describe("live reaction cycle", () => {
test.each(["neutral", "liked", "disliked"])(
"covers all six transitions and restores an initially %s watch video",
async (initialState) => {
const harness = createReactionHarness(initialState);
const beforeFirstAction = jest.fn();
const result = await runReactionCycle(harness.driver, harness.recorder, OPTIONS, {
beforeFirstAction,
videoId: "abcdefghijk",
});
expect(harness.driver.openWatch).toHaveBeenCalledWith("abcdefghijk");
expect(harness.driver.openShort).not.toHaveBeenCalled();
expect(harness.events.map(({ action }) => action)).toEqual(EXPECTED_ACTIONS[initialState]);
expect(
new Set(
harness.events.map(
({ action, state }, index) =>
`${index ? harness.events[index - 1].state : initialState}:${action}:${state}`,
),
).size,
).toBe(6);
expect(harness.getState()).toBe(initialState);
expect(harness.recorder.waitForHandshake).toHaveBeenCalledTimes(6);
expect(beforeFirstAction).toHaveBeenCalledTimes(1);
expect(result).toEqual({ evidencePaths: [], initialState, userId: "shared-user-id" });
},
);
test("targets Shorts through the same transition implementation", async () => {
const harness = createReactionHarness("neutral");
await runReactionCycle(harness.driver, harness.recorder, OPTIONS, {
isShort: true,
videoId: "shortsabcde",
});
expect(harness.driver.openShort).toHaveBeenCalledWith("shortsabcde");
expect(harness.driver.openWatch).not.toHaveBeenCalled();
});
test("restores the initial state when a production handshake fails mid-cycle", async () => {
const harness = createReactionHarness("neutral", { failHandshake: 3 });
await expect(
runReactionCycle(harness.driver, harness.recorder, OPTIONS, {
beforeFirstAction: jest.fn(),
videoId: "abcdefghijk",
}),
).rejects.toThrow("simulated handshake failure");
expect(harness.getState()).toBe("neutral");
expect(harness.events.at(-1)).toEqual({ action: "dislike", state: "neutral" });
expect(harness.recorder.waitForHandshake).toHaveBeenLastCalledWith(0, 3);
});
test.each([
[
"click dispatch throws",
{ clickThrowTransition: 1, rollbackOnClickThrow: true },
"simulated post-dispatch click failure",
],
[
"post-click state wait fails",
{ rollbackOnStateWaitFailure: true, stateWaitFailTransition: 1 },
"simulated post-click state-wait failure",
],
])(
"forces a verified away-and-back cleanup when %s after the UI returns to its initial state",
async (_failure, harnessOptions, expectedError) => {
const harness = createReactionHarness("neutral", harnessOptions);
await expect(
runReactionCycle(harness.driver, harness.recorder, OPTIONS, {
beforeFirstAction: jest.fn(),
videoId: "abcdefghijk",
}),
).rejects.toThrow(expectedError);
expect(harness.recorder.voteUserId).toHaveBeenCalledWith(1, 0);
expect(harness.events.slice(-2)).toEqual([
{ action: "dislike", state: "disliked" },
{ action: "dislike", state: "neutral" },
]);
expect(harness.recorder.waitForHandshake.mock.calls).toEqual([
[-1, 1],
[0, 2],
]);
expect(harness.getState()).toBe("neutral");
},
);
test("reports the manual-restore URL when cleanup after a post-dispatch click failure cannot be confirmed", async () => {
const harness = createReactionHarness("neutral", {
clickThrowTransition: 1,
failHandshakes: [1],
rollbackOnClickThrow: true,
});
await expect(
runReactionCycle(harness.driver, harness.recorder, OPTIONS, {
beforeFirstAction: jest.fn(),
videoId: "abcdefghijk",
}),
).rejects.toThrow(
/Automatic cleanup could not be verified\. Manually restore https:\/\/www\.youtube\.com\/watch\?v=abcdefghijk/,
);
});
test.each([
["first", "neutral", 1, true],
["middle", "liked", 3, false],
["final", "disliked", 6, false],
])(
"forces a verified away-and-back cleanup after a %s transition handshake fails with the UI at its initial state",
async (_position, initialState, failHandshake, rollbackOnHandshakeFailure) => {
const harness = createReactionHarness(initialState, { failHandshake, rollbackOnHandshakeFailure });
await expect(
runReactionCycle(harness.driver, harness.recorder, OPTIONS, {
beforeFirstAction: jest.fn(),
videoId: "abcdefghijk",
}),
).rejects.toThrow("simulated handshake failure");
let failedState = initialState;
for (const action of EXPECTED_ACTIONS[initialState].slice(0, failHandshake)) {
failedState = nextState(failedState, action);
}
const cleanupAction = initialState === "liked" ? "like" : "dislike";
const awayState = initialState === "neutral" ? "disliked" : "neutral";
expect(harness.recorder.voteUserId).toHaveBeenCalledWith(valueForState(failedState), failHandshake - 1);
expect(harness.events.slice(-2)).toEqual([
{ action: cleanupAction, state: awayState },
{ action: cleanupAction, state: initialState },
]);
expect(harness.recorder.waitForHandshake.mock.calls.slice(-2)).toEqual([
[valueForState(awayState), failHandshake],
[valueForState(initialState), failHandshake + 1],
]);
expect(harness.getState()).toBe(initialState);
},
);
test("reports the manual-restore URL when the verified cleanup round trip fails", async () => {
const harness = createReactionHarness("neutral", {
failHandshakes: [1, 2],
rollbackOnHandshakeFailure: true,
});
await expect(
runReactionCycle(harness.driver, harness.recorder, OPTIONS, {
beforeFirstAction: jest.fn(),
videoId: "abcdefghijk",
}),
).rejects.toThrow(
/Automatic cleanup could not be verified\. Manually restore https:\/\/www\.youtube\.com\/watch\?v=abcdefghijk/,
);
});
test("reports the manual-restore URL when cleanup cannot match the failed attempt identity", async () => {
const harness = createReactionHarness("neutral", {
failHandshake: 1,
failedVoteUserId: "failed-attempt-user-id",
});
await expect(
runReactionCycle(harness.driver, harness.recorder, OPTIONS, {
beforeFirstAction: jest.fn(),
videoId: "abcdefghijk",
}),
).rejects.toThrow(/cleanup reaction did not use the failed attempt's RYD identity/);
});
test("restores the initial state when a post-action visual assertion fails", async () => {
const harness = createReactionHarness("neutral");
const captureReactionVisual = jest.fn(async ({ index, state }) => {
if (index === 3) throw new Error("simulated visual failure");
return `watch-${index}-${state}.png`;
});
await expect(
runReactionCycle(harness.driver, harness.recorder, OPTIONS, {
beforeFirstAction: jest.fn(),
captureReactionVisual,
videoId: "abcdefghijk",
}),
).rejects.toThrow("simulated visual failure");
expect(captureReactionVisual.mock.calls.map(([capture]) => capture)).toEqual([
{ index: 0, state: "neutral" },
{ index: 1, state: "liked" },
{ index: 2, state: "neutral" },
{ index: 3, state: "disliked" },
]);
expect(harness.getState()).toBe("neutral");
expect(harness.events.at(-1)).toEqual({ action: "dislike", state: "neutral" });
expect(harness.recorder.waitForHandshake).toHaveBeenLastCalledWith(0, 3);
});
});
describe("live production reaction matrix visual evidence", () => {
test("captures and returns the initial state plus all six transitions for watch and Shorts", async () => {
const states = new Map([
["abcdefghijk", "neutral"],
["shortsabcde", "neutral"],
]);
let currentVideo;
const driver = {
assertCurrentVideo: jest.fn((videoId) => expect(currentVideo).toBe(videoId)),
assertRuntime: jest.fn(),
assertSignedIn: jest.fn(),
captureReactionStateVisual: jest.fn(async (capture) => {
expect(states.get(capture.videoId)).toBe(capture.expectedState);
return { screenshotPath: capture.screenshotPath };
}),
clickAction: jest.fn(async (videoId, action) => {
states.set(videoId, nextState(states.get(videoId), action));
}),
openShort: jest.fn(async (videoId) => {
currentVideo = videoId;
}),
openWatch: jest.fn(async (videoId) => {
currentVideo = videoId;
}),
readReactionState: jest.fn(async () => states.get(currentVideo)),
waitForDislikeText: jest.fn(async () => "123"),
waitForReactionState: jest.fn(async (expected) => expect(states.get(currentVideo)).toBe(expected)),
};
const recorders = [];
const createRecorder = jest.fn((videoId) => {
const recorder = {
mark: jest.fn(() => 0),
stop: jest.fn(),
waitForHandshake: jest.fn(async (value) => {
expect(value).toBe(valueForState(states.get(videoId)));
return "shared-user-id";
}),
};
recorders.push(recorder);
return recorder;
});
const consumeVoteApproval = jest.fn();
const makeDirectory = jest.fn();
const options = {
...OPTIONS,
short: "shortsabcde",
watchB: "abcdefghijk",
};
const outputDirectory = "reaction-evidence";
const result = await runProductionReactionMatrixScenario(driver, options, createRecorder, consumeVoteApproval, {
makeDirectory,
outputDirectory,
});
const stateSequence = ["neutral", "liked", "neutral", "disliked", "liked", "disliked", "neutral"];
const expectedWatch = stateSequence.map((state, index) =>
path.join(outputDirectory, `watch-${index}-${state}.png`),
);
const expectedShort = stateSequence.map((state, index) =>
path.join(outputDirectory, `short-${index}-${state}.png`),
);
expect(makeDirectory).toHaveBeenCalledWith(outputDirectory);
expect(consumeVoteApproval).toHaveBeenCalledTimes(1);
expect(driver.captureReactionStateVisual).toHaveBeenCalledTimes(14);
expect(driver.captureReactionStateVisual.mock.calls.slice(0, 7).map(([capture]) => capture)).toEqual(
expectedWatch.map((screenshotPath, index) => ({
expectedState: stateSequence[index],
isShort: false,
runtime: "userscript",
screenshotPath,
videoId: options.watchB,
})),
);
expect(driver.captureReactionStateVisual.mock.calls.slice(7).map(([capture]) => capture)).toEqual(
expectedShort.map((screenshotPath, index) => ({
expectedState: stateSequence[index],
isShort: true,
runtime: "userscript",
screenshotPath,
videoId: options.short,
})),
);
expect(result).toEqual({
evidencePaths: [...expectedWatch, ...expectedShort],
outputDirectory,
short: { evidencePaths: expectedShort, initialState: "neutral", userId: "shared-user-id" },
watch: { evidencePaths: expectedWatch, initialState: "neutral", userId: "shared-user-id" },
});
expect(recorders).toHaveLength(2);
expect(recorders.every((recorder) => recorder.stop.mock.calls.length === 1)).toBe(true);
});
});
describe("live responsive visual scenario", () => {
test("captures watch and userscript Shorts evidence at all responsive widths without actions", async () => {
const originalViewport = { height: 900, width: 1440 };
let currentViewport = originalViewport;
const driver = {
assertCurrentShortsControl: jest.fn(),
assertRuntime: jest.fn(),
assertSignedIn: jest.fn(),
captureSyntheticShortsVisual: jest.fn(async (_videoId, screenshotPath) => ({
screenshotPath,
viewport: currentViewport,
})),
captureWatchRatioVisual: jest.fn(async (_runtime, screenshotPath) => ({
screenshotPath,
viewport: currentViewport,
})),
openShort: jest.fn(),
openWatch: jest.fn(),
readViewportSize: jest.fn(async () => currentViewport),
setViewportSize: jest.fn(async (viewport) => {
currentViewport = viewport;
}),
waitForDislikeText: jest.fn(async () => "123"),
withNoProductionInteractions: jest.fn(async (action) => action()),
};
const makeDirectory = jest.fn();
const options = { ...OPTIONS, short: "shortsabcde", watchA: "abcdefghijk" };
const result = await runResponsiveVisualScenario(driver, options, {
makeDirectory,
outputDirectory: "responsive-evidence",
});
expect(makeDirectory).toHaveBeenCalledWith("responsive-evidence");
expect(driver.openWatch).toHaveBeenCalledWith(options.watchA);
expect(driver.openShort).toHaveBeenCalledWith(options.short);
expect(driver.assertCurrentShortsControl).toHaveBeenCalledTimes(RESPONSIVE_VIEWPORTS.length);
expect(driver.captureWatchRatioVisual).toHaveBeenCalledTimes(RESPONSIVE_VIEWPORTS.length);
expect(driver.captureSyntheticShortsVisual).toHaveBeenCalledTimes(RESPONSIVE_VIEWPORTS.length);
expect(driver.captureWatchRatioVisual.mock.calls.map(([, screenshotPath]) => screenshotPath)).toEqual(
RESPONSIVE_VIEWPORTS.map(({ width }) => expect.stringContaining(`userscript-watch-ratio-${width}.png`)),
);
expect(driver.captureSyntheticShortsVisual.mock.calls.map(([, screenshotPath]) => screenshotPath)).toEqual(
RESPONSIVE_VIEWPORTS.map(({ width }) => expect.stringContaining(`userscript-shorts-control-${width}.png`)),
);
expect(driver.setViewportSize).toHaveBeenLastCalledWith(originalViewport);
expect(driver.withNoProductionInteractions).toHaveBeenCalledTimes(1);
expect(driver.waitForDislikeText).toHaveBeenCalledTimes(RESPONSIVE_VIEWPORTS.length * 2);
expect(result.watch).toHaveLength(RESPONSIVE_VIEWPORTS.length);
expect(result.shorts).toHaveLength(RESPONSIVE_VIEWPORTS.length);
expect(result.shortsSkipped).toBeNull();
});
test("captures watch and native extension Shorts evidence at all responsive widths without actions", async () => {
const originalViewport = { height: 900, width: 1440 };
let currentViewport = originalViewport;
const driver = {
assertCurrentShortsControl: jest.fn(),
assertRuntime: jest.fn(),
assertSignedIn: jest.fn(),
captureNativeShortsVisual: jest.fn(async (_videoId, screenshotPath) => ({
screenshotPath,
viewport: currentViewport,
})),
captureWatchRatioVisual: jest.fn(async (_runtime, screenshotPath) => ({
screenshotPath,
viewport: currentViewport,
})),
openShort: jest.fn(),
openWatch: jest.fn(),
readViewportSize: jest.fn(async () => currentViewport),
setViewportSize: jest.fn(async (viewport) => {
currentViewport = viewport;
}),
waitForDislikeText: jest.fn(async () => "123"),
withNoProductionInteractions: jest.fn(async (action) => action()),
};
const result = await runResponsiveVisualScenario(
driver,
{ ...OPTIONS, runtime: "extension", short: "shortsabcde", watchA: "abcdefghijk" },
{ makeDirectory: jest.fn(), outputDirectory: "responsive-evidence" },
);
expect(driver.openShort).toHaveBeenCalledWith("shortsabcde");
expect(driver.assertCurrentShortsControl).toHaveBeenCalledTimes(RESPONSIVE_VIEWPORTS.length);
expect(driver.captureNativeShortsVisual).toHaveBeenCalledTimes(RESPONSIVE_VIEWPORTS.length);
expect(driver.captureNativeShortsVisual.mock.calls.map(([, screenshotPath]) => screenshotPath)).toEqual(
RESPONSIVE_VIEWPORTS.map(({ width }) => expect.stringContaining(`extension-shorts-control-${width}.png`)),
);
expect(result.shorts).toHaveLength(RESPONSIVE_VIEWPORTS.length);
expect(result.shortsSkipped).toBeNull();
expect(driver.setViewportSize).toHaveBeenLastCalledWith(originalViewport);
});
test("restores the original viewport when a visual assertion fails", async () => {
const originalViewport = { height: 800, width: 1200 };
const driver = {
assertRuntime: jest.fn(),
assertSignedIn: jest.fn(),
captureWatchRatioVisual: jest.fn(async () => Promise.reject(new Error("visual failed"))),
openWatch: jest.fn(),
readViewportSize: jest.fn(async () => originalViewport),
setViewportSize: jest.fn(),
waitForDislikeText: jest.fn(async () => "123"),
withNoProductionInteractions: jest.fn(async (action) => action()),
};
await expect(
runResponsiveVisualScenario(
driver,
{ ...OPTIONS, short: "shortsabcde", watchA: "abcdefghijk" },
{
makeDirectory: jest.fn(),
outputDirectory: "responsive-evidence",
},
),
).rejects.toThrow("visual failed");
expect(driver.setViewportSize).toHaveBeenLastCalledWith(originalViewport);
});
});
@@ -0,0 +1,76 @@
const { assertLogicalVoteHandshake } = require("../e2e/live/live-youtube-driver");
const VIDEO_ID = "abcdefghijk";
const USER_ID = "shared-user-id";
function vote({ status = 200, userId = USER_ID, value = 1, videoId = VIDEO_ID } = {}) {
return {
body: { userId, value, videoId },
pathname: "/interact/vote",
responseError: null,
status,
};
}
function confirmation({ confirmed = true, status = 200, userId = USER_ID, videoId = VIDEO_ID } = {}) {
return {
body: { solution: "proof", userId, videoId },
pathname: "/interact/confirmVote",
responseBody: confirmed,
responseError: null,
status,
};
}
describe("live logical vote handshake validation", () => {
test("accepts one vote puzzle followed by one true confirmation", () => {
expect(assertLogicalVoteHandshake([vote(), confirmation()], VIDEO_ID, 1)).toBe(USER_ID);
});
test("accepts two matching vote puzzle requests followed by one true confirmation", () => {
expect(assertLogicalVoteHandshake([vote(), vote(), confirmation()], VIDEO_ID, 1)).toBe(USER_ID);
});
test("accepts three matching vote puzzle requests followed by one true confirmation", () => {
expect(assertLogicalVoteHandshake([vote(), vote(), vote(), confirmation()], VIDEO_ID, 1)).toBe(USER_ID);
});
test("rejects a fourth vote puzzle request", () => {
expect(() => assertLogicalVoteHandshake([vote(), vote(), vote(), vote(), confirmation()], VIDEO_ID, 1)).toThrow(
/one to three vote puzzle requests/,
);
});
test.each([
["user", [vote(), vote({ userId: "another-user" }), confirmation()], /different user IDs/],
["video", [vote(), vote({ videoId: "lmnopqrstuv" }), confirmation()], /different video/],
["value", [vote(), vote({ value: 0 }), confirmation()], /changed the requested vote value/],
])("rejects a retry with a mismatched %s", (_field, records, message) => {
expect(() => assertLogicalVoteHandshake(records, VIDEO_ID, 1)).toThrow(message);
});
test.each([
["extra confirmation", [vote(), confirmation(), confirmation()]],
["other interaction", [vote(), { ...vote(), pathname: "/interact/other" }, confirmation()]],
["traffic after confirmation", [vote(), confirmation(), vote()]],
])("rejects %s traffic", (_case, records) => {
expect(() => assertLogicalVoteHandshake(records, VIDEO_ID, 1)).toThrow(
/exactly one confirmation|logical vote may/i,
);
});
test("rejects a false confirmation", () => {
expect(() => assertLogicalVoteHandshake([vote(), confirmation({ confirmed: false })], VIDEO_ID, 1)).toThrow(
/did not confirm the vote/,
);
});
test("rejects a failed vote or confirmation response", () => {
expect(() => assertLogicalVoteHandshake([vote({ status: 500 }), confirmation()], VIDEO_ID, 1)).toThrow(
/Vote request failed with HTTP 500/,
);
expect(() => assertLogicalVoteHandshake([vote(), confirmation({ status: 500 })], VIDEO_ID, 1)).toThrow(
/Vote confirmation failed with HTTP 500/,
);
});
});
@@ -0,0 +1,920 @@
/**
* @jest-environment jsdom
*/
const {
LiveYoutubeDriver,
assertNativeShortsPairGeometry,
assertReactionPressedStates,
assertShortsActionStackGeometry,
assertSyntheticShortsGeometry,
assertWatchRatioViewportAlignment,
clickWithSingleNavigationRetry,
croppedScreenshotClip,
firstVisibleRelatedWatchLink,
isShortCandidateEligible,
isShortsIconVisualReady,
readDislikeControlText,
relatedWatchVideoId,
} = require("../e2e/live/live-youtube-driver");
const VISIBLE_RECT = {
bottom: 200,
height: 100,
left: 10,
right: 110,
top: 100,
width: 100,
};
const ACTION_HOST_STYLE = {
marginBottom: 0,
marginLeft: 0,
marginRight: 0,
marginTop: 0,
paddingBottom: 8,
paddingLeft: 0,
paddingRight: 0,
paddingTop: 0,
};
const COUNT_STYLE = {
fontFamily: '"Roboto", "Arial", sans-serif',
fontSize: 12,
fontStyle: "normal",
fontWeight: "400",
lineHeight: 18,
};
function box(x, y, width, height) {
return { height, width, x, y };
}
function validSyntheticShortsGeometry() {
return {
like: {
button: box(100, 10, 48, 48),
count: box(110, 58, 28, 14),
countStyle: { ...COUNT_STYLE },
host: box(100, 10, 48, 78),
hostStyle: { ...ACTION_HOST_STYLE },
icon: box(112, 22, 24, 24),
label: box(100, 10, 48, 70),
svg: box(112, 22, 24, 24),
},
next: {
host: box(100, 166, 48, 78),
},
synthetic: {
button: box(100, 88, 48, 48),
count: box(109, 136, 30, 14),
countStyle: { ...COUNT_STYLE },
host: box(100, 88, 48, 78),
hostStyle: { ...ACTION_HOST_STYLE },
icon: box(112, 100, 24, 24),
label: box(100, 88, 48, 70),
svg: box(112, 100, 24, 24),
},
};
}
function validNativeShortsPairGeometry() {
const syntheticGeometry = validSyntheticShortsGeometry();
const enrich = (geometry, actionIndex) => ({
...geometry,
actionIndex,
reelIndex: 2,
videoMatches: true,
});
return {
dislike: enrich(syntheticGeometry.synthetic, 4),
like: enrich(syntheticGeometry.like, 3),
};
}
function renderShort({ href, rendererVideoId } = {}) {
document.body.innerHTML = `
<ytd-reel-video-renderer${rendererVideoId ? ` video-id="${rendererVideoId}"` : ""}>
${href ? `<a href="${href}"></a>` : ""}
<button type="button">Dislike</button>
</ytd-reel-video-renderer>
`;
const reel = document.querySelector("ytd-reel-video-renderer");
const button = reel.querySelector("button");
reel.getBoundingClientRect = jest.fn(() => VISIBLE_RECT);
button.getBoundingClientRect = jest.fn(() => VISIBLE_RECT);
return { button, reel };
}
const activeShortSettings = (videoId) => ({
activeShortRequired: true,
expectedShortVideoId: videoId,
});
beforeEach(() => {
Object.defineProperty(window, "innerHeight", { configurable: true, value: 768 });
Object.defineProperty(window, "innerWidth", { configurable: true, value: 1024 });
document.body.innerHTML = "";
});
describe("exact installed live-build identity", () => {
const buildId = "0123456789abcdef0123456789abcdef";
function createDriver(markers) {
const page = {
locator: jest.fn((selector) => {
expect(selector).toBe("html");
return { evaluate: jest.fn(async () => markers) };
}),
setDefaultNavigationTimeout: jest.fn(),
setDefaultTimeout: jest.fn(),
};
return new LiveYoutubeDriver(page, {});
}
test.each(["userscript", "extension"])("accepts only the exact installed %s live build", async (runtime) => {
const otherRuntime = runtime === "userscript" ? "extension" : "userscript";
const markers = {
extension: null,
extensionBuild: null,
userscript: null,
userscriptBuild: null,
[runtime]: runtime === "userscript" ? "3.2.0" : "4.0.4",
[`${runtime}Build`]: buildId,
};
const driver = createDriver(markers);
await expect(driver.assertRuntime(runtime, markers[runtime], buildId)).resolves.toBeUndefined();
expect(markers[otherRuntime]).toBeNull();
});
test("rejects a stale userscript build even when its version matches", async () => {
const driver = createDriver({
extension: null,
extensionBuild: null,
userscript: "3.2.0",
userscriptBuild: "f".repeat(32),
});
await expect(driver.assertRuntime("userscript", "3.2.0", buildId)).rejects.toThrow(
"match the freshly generated artifact",
);
});
test.each([undefined, "", "stale", "A".repeat(32)])(
"refuses to test without a valid exact build ID: %p",
async (expectedBuildId) => {
const driver = createDriver({
extension: null,
extensionBuild: null,
userscript: "3.2.0",
userscriptBuild: buildId,
});
await expect(driver.assertRuntime("userscript", "3.2.0", expectedBuildId)).rejects.toThrow(
"exact 32-character live build ID",
);
},
);
test("rejects an enabled opposite runtime even when the selected marker is exact", async () => {
const driver = createDriver({
extension: "4.0.4",
extensionBuild: "f".repeat(32),
userscript: "3.2.0",
userscriptBuild: buildId,
});
await expect(driver.assertRuntime("userscript", "3.2.0", buildId)).rejects.toThrow(
"Disable the extension before running the userscript smoke",
);
});
});
describe("isShortCandidateEligible", () => {
test("accepts an exact renderer video-id without requiring a Shorts link", () => {
const { button } = renderShort({ rendererVideoId: "current-video" });
expect(isShortCandidateEligible(button, activeShortSettings("current-video"))).toBe(true);
});
test("accepts an exact Shorts link when the renderer has no video-id", () => {
const { button } = renderShort({ href: "/shorts/current-video?feature=share" });
expect(isShortCandidateEligible(button, activeShortSettings("current-video"))).toBe(true);
});
test("rejects a matching descendant link when the renderer video-id identifies another Short", () => {
const { button } = renderShort({ href: "/shorts/current-video", rendererVideoId: "outgoing-video" });
expect(isShortCandidateEligible(button, activeShortSettings("current-video"))).toBe(false);
});
test.each(["/shorts/current-video-extra", "/shorts/current-video/related", "/shorts/other-video"])(
"rejects a non-exact Shorts link: %s",
(href) => {
const { button } = renderShort({ href });
expect(isShortCandidateEligible(button, activeShortSettings("current-video"))).toBe(false);
},
);
test("rejects an exact video outside the viewport", () => {
const { button, reel } = renderShort({ rendererVideoId: "current-video" });
reel.getBoundingClientRect = jest.fn(() => ({ ...VISIBLE_RECT, bottom: -1, top: -101 }));
expect(isShortCandidateEligible(button, activeShortSettings("current-video"))).toBe(false);
});
test("rejects an action button outside the viewport even when its renderer intersects", () => {
const { button } = renderShort({ rendererVideoId: "current-video" });
button.getBoundingClientRect = jest.fn(() => ({ ...VISIBLE_RECT, left: 1100, right: 1200 }));
expect(isShortCandidateEligible(button, activeShortSettings("current-video"))).toBe(false);
});
});
describe("readDislikeControlText", () => {
test("reads a synthetic control count from the sibling of its inner button", () => {
document.body.innerHTML = `
<div data-ryd-synthetic-shorts-dislike>
<div>
<button type="button" aria-pressed="false"><svg></svg></button>
<div><span id="text" role="text">1.2K</span></div>
</div>
</div>
`;
expect(readDislikeControlText(document.querySelector("button"))).toBe("1.2K");
});
test("continues to read native dislike text from the button", () => {
document.body.innerHTML = `<button type="button"><span role="text">456</span></button>`;
expect(readDislikeControlText(document.querySelector("button"))).toBe("456");
});
});
describe("relatedWatchVideoId", () => {
const settings = {
currentVideoId: "abcdefghijk",
excludedVideoIds: ["excluded001"],
origin: "https://www.youtube.com",
};
function read(href, overrides = {}) {
const link = document.createElement("a");
link.setAttribute("href", href);
return relatedWatchVideoId(link, { ...settings, ...overrides });
}
test("accepts an exact same-origin watch target while preserving harmless query parameters", () => {
expect(read("/watch?v=targetvid01&pp=sidebar")).toBe("targetvid01");
});
test.each([
["/watch?v=abcdefghijk", "current video"],
["/watch?v=excluded001", "previously visited video"],
["/watch?v=too-short", "invalid video ID"],
["/shorts/targetvid01", "Shorts URL"],
["https://example.com/watch?v=targetvid01", "cross-origin URL"],
["https://www.youtube.com.evil.example/watch?v=targetvid01", "lookalike host"],
])("rejects a %s (%s)", (href) => {
expect(read(href)).toBeNull();
});
});
describe("firstVisibleRelatedWatchLink", () => {
function relatedLink(href, { visible = true, label = href } = {}) {
const element = document.createElement("a");
element.setAttribute("href", href);
element.textContent = label;
return {
element,
evaluate: async (callback, settings) => callback(element, settings),
isVisible: async () => visible,
};
}
function relatedPage(candidates) {
const locator = {
count: async () => candidates.length,
nth: (index) => candidates[index],
};
return {
locator,
page: {
locator: jest.fn(() => locator),
url: jest.fn(() => "https://www.youtube.com/watch?v=abcdefghijk"),
},
};
}
test("selects the first visible exact generic related anchor without renderer or thumbnail markup", async () => {
const offscreenThumbnail = relatedLink("/watch?v=targetvid01&pp=thumbnail", {
label: "thumbnail duplicate",
visible: false,
});
const currentVideo = relatedLink("/watch?v=abcdefghijk", { label: "current video" });
const excludedVideo = relatedLink("/watch?v=excluded001", { label: "visited video" });
const genericTitle = relatedLink("/watch?v=targetvid01&pp=title", { label: "generic title link" });
const visibleThumbnailDuplicate = relatedLink("/watch?v=targetvid01&pp=thumbnail", {
label: "visible thumbnail duplicate",
});
const laterTarget = relatedLink("/watch?v=latervideo1", { label: "later target" });
const { page } = relatedPage([
offscreenThumbnail,
currentVideo,
excludedVideo,
genericTitle,
visibleThumbnailDuplicate,
laterTarget,
]);
await expect(firstVisibleRelatedWatchLink(page, "abcdefghijk", ["excluded001"], 100)).resolves.toEqual({
link: genericTitle,
videoId: "targetvid01",
});
expect(page.locator).toHaveBeenCalledTimes(1);
expect(page.locator).toHaveBeenCalledWith('#related a[href*="/watch"]');
});
});
describe("live playback diagnostics", () => {
test("reports that playback was intentionally paused", async () => {
const videos = [{ pause: jest.fn() }, { pause: jest.fn() }];
const page = {
locator: jest.fn(() => ({ evaluateAll: async (callback) => callback(videos) })),
setDefaultNavigationTimeout: jest.fn(),
setDefaultTimeout: jest.fn(),
url: jest.fn(() => "https://www.youtube.com/shorts/abcdefghijk"),
};
const reportProgress = jest.fn();
const driver = new LiveYoutubeDriver(page, {}, { reportProgress });
await expect(driver.pausePlayback()).resolves.toEqual({ pauseFailures: [], pausedVideos: 2 });
expect(videos.every(({ pause }) => pause.mock.calls.length === 1)).toBe(true);
expect(reportProgress).toHaveBeenCalledWith("playback.paused", {
explanation: "The live smoke pauses media intentionally while it validates the current page",
pauseFailures: [],
pausedVideos: 2,
url: "https://www.youtube.com/shorts/abcdefghijk",
});
});
test("catches individual synchronous pause failures without waiting on media playback", async () => {
const videos = [
{ pause: jest.fn(() => undefined) },
{
pause: jest.fn(() => {
throw new Error("media state unavailable");
}),
},
];
const page = {
locator: jest.fn(() => ({ evaluateAll: async (callback) => callback(videos) })),
setDefaultNavigationTimeout: jest.fn(),
setDefaultTimeout: jest.fn(),
url: jest.fn(() => "https://www.youtube.com/shorts/abcdefghijk"),
};
const driver = new LiveYoutubeDriver(page, {});
await expect(driver.pausePlayback()).resolves.toEqual({
pauseFailures: ["media state unavailable"],
pausedVideos: 1,
});
});
test("waits for a new video URL without pausing until the caller requests stable playback", async () => {
const videos = [{ pause: jest.fn() }];
const page = {
locator: jest.fn(() => ({ evaluateAll: async (callback) => callback(videos) })),
setDefaultNavigationTimeout: jest.fn(),
setDefaultTimeout: jest.fn(),
url: jest.fn(() => "https://www.youtube.com/shorts/abcdefghijk"),
};
const driver = new LiveYoutubeDriver(page, {});
await driver.waitForVideoUrl("abcdefghijk");
expect(page.locator).not.toHaveBeenCalled();
expect(videos[0].pause).not.toHaveBeenCalled();
await driver.waitForVideo("abcdefghijk");
expect(page.locator).toHaveBeenCalledWith("video");
expect(videos[0].pause).toHaveBeenCalledTimes(1);
});
});
describe("watch ratio-bar soak", () => {
test("rechecks the current video, bar geometry, and stable count for the requested interval", async () => {
const page = {
setDefaultNavigationTimeout: jest.fn(),
setDefaultTimeout: jest.fn(),
};
const reportProgress = jest.fn();
const driver = new LiveYoutubeDriver(page, {}, { reportProgress });
driver.assertCurrentVideo = jest.fn();
driver.assertWatchRatioVisual = jest.fn(async () => ({ count: "123" }));
const result = await driver.soakWatchRatioVisual("userscript", {
durationMs: 1,
expectedCount: "123",
intervalMs: 1,
videoId: "targetvid01",
});
expect(driver.assertCurrentVideo).toHaveBeenCalledWith("targetvid01");
expect(driver.assertWatchRatioVisual).toHaveBeenCalledWith("userscript", { expectedCount: "123" });
expect(result).toEqual({
count: "123",
durationMs: 1,
sampleCount: driver.assertWatchRatioVisual.mock.calls.length,
videoId: "targetvid01",
});
expect(reportProgress).toHaveBeenNthCalledWith(1, "watch-ratio-soak.start", {
durationMs: 1,
expectedCount: "123",
runtime: "userscript",
videoId: "targetvid01",
});
expect(reportProgress).toHaveBeenLastCalledWith("watch-ratio-soak.complete", {
durationMs: 1,
expectedCount: "123",
runtime: "userscript",
sampleCount: result.sampleCount,
videoId: "targetvid01",
});
});
});
describe("reaction visual capture hygiene", () => {
function createCapturePage(tooltip, videoBoxes = []) {
const tooltipList = {
count: jest.fn(async () => 1),
nth: jest.fn(() => tooltip),
};
const videos = videoBoxes.map((measurement) => ({
boundingBox: jest.fn(async () => measurement),
isVisible: jest.fn(async () => true),
}));
const videoList = {
count: jest.fn(async () => videos.length),
nth: jest.fn((index) => videos[index]),
};
return {
evaluate: jest.fn(async () => undefined),
locator: jest.fn((selector) => (selector === "video" ? videoList : tooltipList)),
mouse: { move: jest.fn(async () => undefined) },
screenshot: jest.fn(async () => undefined),
setDefaultNavigationTimeout: jest.fn(),
setDefaultTimeout: jest.fn(),
};
}
test("parks the pointer inside the largest visible video and outside controls before capturing", async () => {
const tooltip = {
innerText: jest.fn(async () => "I like this"),
isVisible: jest.fn().mockResolvedValueOnce(true).mockResolvedValue(false),
};
const preloadedVideo = box(0, 0, 320, 180);
const currentVideo = box(100, 40, 800, 640);
const page = createCapturePage(tooltip, [preloadedVideo, currentVideo]);
const driver = new LiveYoutubeDriver(page, {}, { visualTooltipTimeout: 1_000 });
const control = box(900, 500, 48, 78);
driver.readViewportSize = jest.fn(async () => ({ height: 720, width: 1280 }));
await expect(driver.captureCroppedScreenshot("short-liked.png", [control])).resolves.toEqual({
height: 102,
width: 72,
x: 888,
y: 488,
});
const [pointerX, pointerY] = page.mouse.move.mock.calls[0];
expect({ x: pointerX, y: pointerY }).toEqual({ x: 500, y: 360 });
expect(pointerX).toBeGreaterThan(currentVideo.x);
expect(pointerX).toBeLessThan(currentVideo.x + currentVideo.width);
expect(pointerY).toBeGreaterThan(currentVideo.y);
expect(pointerY).toBeLessThan(currentVideo.y + currentVideo.height);
expect(
pointerX < control.x ||
pointerX > control.x + control.width ||
pointerY < control.y ||
pointerY > control.y + control.height,
).toBe(true);
expect(tooltip.isVisible).toHaveBeenCalledTimes(2);
expect(page.mouse.move.mock.invocationCallOrder[0]).toBeLessThan(page.screenshot.mock.invocationCallOrder[0]);
expect(page.screenshot).toHaveBeenCalledWith(
expect.objectContaining({ path: "short-liked.png", clip: { height: 102, width: 72, x: 888, y: 488 } }),
);
});
test("fails within the configured bound instead of capturing a persistent native tooltip", async () => {
const tooltip = {
innerText: jest.fn(async () => "I like this"),
isVisible: jest.fn(async () => true),
};
const page = createCapturePage(tooltip);
const driver = new LiveYoutubeDriver(page, {}, { visualTooltipTimeout: 1 });
driver.readViewportSize = jest.fn(async () => ({ height: 720, width: 1280 }));
await expect(driver.captureCroppedScreenshot("obscured.png", [box(900, 500, 48, 78)])).rejects.toThrow(
/Timed out waiting for native YouTube tooltips.*I like this/,
);
expect(page.mouse.move).toHaveBeenCalledTimes(1);
expect(page.screenshot).not.toHaveBeenCalled();
});
});
describe("watch reaction screenshot bounds", () => {
function visibleBoxLocator(measurement) {
const locator = {
boundingBox: jest.fn(async () => measurement),
count: jest.fn(async () => 1),
isVisible: jest.fn(async () => true),
nth: jest.fn(() => locator),
scrollIntoViewIfNeeded: jest.fn(async () => undefined),
};
return locator;
}
test("passes the full ratio wrapper to the crop so its bottom label is retained", async () => {
const viewport = { height: 300, width: 500 };
const likeBox = box(100, 100, 80, 40);
const dislikeBox = box(180, 100, 80, 40);
const containerBox = box(100, 144, 160, 2);
const barBox = box(100, 144, 120, 2);
const wrapperBox = box(100, 140, 160, 62);
const like = visibleBoxLocator(likeBox);
const dislike = visibleBoxLocator(dislikeBox);
const bar = visibleBoxLocator(barBox);
const wrapper = visibleBoxLocator(wrapperBox);
const container = visibleBoxLocator(containerBox);
container.locator = jest.fn(() => wrapper);
const page = {
locator: jest.fn((selector) => (selector === "#return-youtube-dislike-bar-container" ? container : bar)),
setDefaultNavigationTimeout: jest.fn(),
setDefaultTimeout: jest.fn(),
};
const driver = new LiveYoutubeDriver(page, {});
driver.captureCroppedScreenshot = jest.fn(async (_path, boxes) => croppedScreenshotClip(boxes, viewport));
driver.readViewportSize = jest.fn(async () => viewport);
driver.visibleDislikeButton = jest.fn(async () => dislike);
driver.visibleLikeButton = jest.fn(async () => like);
driver.waitForDislikeText = jest.fn(async () => "123");
const result = await driver.captureWatchRatioVisual("userscript", "watch-liked.png");
expect(driver.captureCroppedScreenshot).toHaveBeenCalledWith("watch-liked.png", [
likeBox,
dislikeBox,
containerBox,
wrapperBox,
]);
expect(result.screenshotClip.y + result.screenshotClip.height).toBe(wrapperBox.y + wrapperBox.height + 12);
expect(result.screenshotClip.y + result.screenshotClip.height).toBeGreaterThan(
containerBox.y + containerBox.height + 12,
);
});
test("rejects duplicate visible runtime bars before taking evidence", async () => {
const candidates = [{ isVisible: jest.fn(async () => true) }, { isVisible: jest.fn(async () => true) }];
const duplicateContainers = {
count: jest.fn(async () => candidates.length),
nth: jest.fn((index) => candidates[index]),
};
const bar = visibleBoxLocator(box(100, 144, 120, 2));
const page = {
locator: jest.fn((selector) =>
selector === "#return-youtube-dislike-bar-container" ? duplicateContainers : bar,
),
setDefaultNavigationTimeout: jest.fn(),
setDefaultTimeout: jest.fn(),
};
const driver = new LiveYoutubeDriver(page, {});
await expect(driver.captureWatchRatioVisual("userscript", "duplicate.png")).rejects.toThrow(
"Expected exactly one visible userscript watch ratio bar; found 2.",
);
});
});
describe("live Shorts Next navigation retry", () => {
test("retries exactly once when YouTube ignores the first trusted click", async () => {
let navigated = false;
const click = jest.fn().mockResolvedValue(undefined);
const reportProgress = jest.fn();
const waitForNavigation = jest
.fn()
.mockRejectedValueOnce(new Error("first navigation timed out"))
.mockImplementationOnce(async () => {
navigated = true;
});
await expect(
clickWithSingleNavigationRetry({
click,
hasNavigated: () => navigated,
reportProgress,
retryDetails: { previousVideoId: "abcdefghijk" },
waitForNavigation,
}),
).resolves.toEqual({ retried: true });
expect(click.mock.calls).toEqual([
[1, 5_000],
[2, 25_000],
]);
expect(waitForNavigation.mock.calls.map(([timeout]) => timeout)).toEqual([5_000, 25_000]);
expect(reportProgress).toHaveBeenCalledTimes(1);
expect(reportProgress).toHaveBeenCalledWith("shorts-next-control.retrying", {
firstFailure: "first navigation timed out",
firstTimeoutMs: 5_000,
previousVideoId: "abcdefghijk",
retryTimeoutMs: 25_000,
});
});
test("fails after the single retry instead of clicking a third time", async () => {
const click = jest.fn().mockResolvedValue(undefined);
const reportProgress = jest.fn();
const waitForNavigation = jest
.fn()
.mockRejectedValueOnce(new Error("first navigation timed out"))
.mockRejectedValueOnce(new Error("retry navigation timed out"));
await expect(
clickWithSingleNavigationRetry({
click,
hasNavigated: () => false,
reportProgress,
retryDetails: { previousVideoId: "abcdefghijk" },
waitForNavigation,
}),
).rejects.toThrow(/first Shorts Next click or its single retry/);
expect(click).toHaveBeenCalledTimes(2);
expect(waitForNavigation).toHaveBeenCalledTimes(2);
expect(reportProgress).toHaveBeenCalledTimes(1);
});
test("does not retry when navigation completed at the first timeout boundary", async () => {
let navigated = false;
const click = jest.fn().mockResolvedValue(undefined);
const reportProgress = jest.fn();
const waitForNavigation = jest.fn(async () => {
navigated = true;
throw new Error("navigation event timed out after the URL changed");
});
await expect(
clickWithSingleNavigationRetry({
click,
hasNavigated: () => navigated,
reportProgress,
retryDetails: { previousVideoId: "abcdefghijk" },
waitForNavigation,
}),
).resolves.toEqual({ retried: false });
expect(click).toHaveBeenCalledTimes(1);
expect(reportProgress).not.toHaveBeenCalled();
});
});
describe("watch ratio viewport alignment", () => {
const viewport = { height: 844, width: 375 };
const like = box(-25.828, 700, 80.77, 40);
const dislike = box(54.942, 700, 84.658, 40);
test("allows the ratio bar to share YouTube's native horizontal clipping exactly", () => {
const container = box(-25.828, 744, 165.428, 2);
expect(assertWatchRatioViewportAlignment(container, like, dislike, viewport)).toEqual({
nativeControlsAreHorizontallyClipped: true,
nativeLeft: -25.828,
nativeRight: 139.6,
});
});
test("rejects ratio-bar clipping that extends beyond the native reaction controls", () => {
const container = box(-35.828, 744, 175.428, 2);
expect(() => assertWatchRatioViewportAlignment(container, like, dislike, viewport)).toThrow(
/left edge alignment with native reaction controls/,
);
});
test("retains the strict viewport assertion when native controls are in bounds", () => {
const inBoundsLike = box(10, 700, 80, 40);
const inBoundsDislike = box(90, 700, 85, 40);
const clippedContainer = box(-2, 744, 177, 2);
expect(() => assertWatchRatioViewportAlignment(clippedContainer, inBoundsLike, inBoundsDislike, viewport)).toThrow(
/Watch ratio bar is clipped past the viewport's left edge/,
);
});
});
describe("assertReactionPressedStates", () => {
test.each([
["neutral", { dislikeState: "false", likeState: "false" }],
["liked", { dislikeState: "false", likeState: "true" }],
["disliked", { dislikeState: "true", likeState: "false" }],
])("accepts the exact mutually-exclusive %s state", (expectedState, pressedStates) => {
expect(() => assertReactionPressedStates(pressedStates, expectedState)).not.toThrow();
});
test("rejects a valid but unexpected pressed state", () => {
expect(() => assertReactionPressedStates({ dislikeState: "false", likeState: "true" }, "neutral")).toThrow(
/Expected YouTube reaction state neutral/,
);
});
test("rejects Like and Dislike being selected together", () => {
expect(() => assertReactionPressedStates({ dislikeState: "true", likeState: "true" }, "liked")).toThrow(
/Like and Dislike as selected/,
);
});
test("rejects an invalid pressed-state value", () => {
expect(() => assertReactionPressedStates({ dislikeState: null, likeState: "false" }, "neutral")).toThrow(
/Unexpected YouTube dislike state/,
);
});
});
describe("assertSyntheticShortsGeometry", () => {
test("accepts the measured native Shorts action geometry", () => {
expect(() => assertSyntheticShortsGeometry(validSyntheticShortsGeometry())).not.toThrow();
});
test.each([
["icon container", "icon"],
["SVG", "svg"],
])("rejects an 8px-wide synthetic %s", (_label, property) => {
const measurement = validSyntheticShortsGeometry();
measurement.synthetic[property].width = 8;
expect(() => assertSyntheticShortsGeometry(measurement)).toThrow(
new RegExp(`Synthetic Shorts ${property === "svg" ? "SVG" : "icon container"} width`),
);
});
test("rejects a synthetic action host with a 16px margin", () => {
const measurement = validSyntheticShortsGeometry();
measurement.synthetic.hostStyle.marginTop = 16;
expect(() => assertSyntheticShortsGeometry(measurement)).toThrow(/Synthetic Shorts action host marginTop/);
});
test("rejects a gap between Like and the synthetic action", () => {
const measurement = validSyntheticShortsGeometry();
measurement.synthetic.host.y += 16;
measurement.next.host.y += 16;
expect(() => assertSyntheticShortsGeometry(measurement)).toThrow(
/Gap between native Like and synthetic Shorts action hosts/,
);
});
test("rejects a gap between the synthetic and following actions", () => {
const measurement = validSyntheticShortsGeometry();
measurement.next.host.y += 2;
expect(() => assertSyntheticShortsGeometry(measurement)).toThrow(
/Gap between synthetic Shorts and following action hosts/,
);
});
test.each([
["button", "width", 44, /Synthetic Shorts button width/],
["label", "height", 68, /Synthetic Shorts label height/],
["host", "height", 80, /Synthetic Shorts action host height/],
])("rejects a mismatched synthetic %s %s", (part, dimension, value, message) => {
const measurement = validSyntheticShortsGeometry();
measurement.synthetic[part][dimension] = value;
expect(() => assertSyntheticShortsGeometry(measurement)).toThrow(message);
});
test("rejects incorrect action-host padding", () => {
const measurement = validSyntheticShortsGeometry();
measurement.synthetic.hostStyle.paddingBottom = 0;
expect(() => assertSyntheticShortsGeometry(measurement)).toThrow(/Synthetic Shorts action host paddingBottom/);
});
test.each([
["fontSize", 14, /Synthetic Shorts count font-size/],
["lineHeight", 16, /Synthetic Shorts count line-height/],
["fontFamily", "Arial", /Synthetic Shorts count fontFamily/],
])("rejects mismatched count %s", (property, value, message) => {
const measurement = validSyntheticShortsGeometry();
measurement.synthetic.countStyle[property] = value;
expect(() => assertSyntheticShortsGeometry(measurement)).toThrow(message);
});
test("rejects a control that is off the native action-column center", () => {
const measurement = validSyntheticShortsGeometry();
measurement.synthetic.button.x += 2;
expect(() => assertSyntheticShortsGeometry(measurement)).toThrow(/Synthetic Shorts button horizontal center/);
});
test("allows a one-pixel fractional rendering difference", () => {
const measurement = validSyntheticShortsGeometry();
measurement.synthetic.button.width = 47;
measurement.synthetic.svg.height = 24.75;
measurement.next.host.x = 101;
expect(() => assertSyntheticShortsGeometry(measurement)).not.toThrow();
});
});
describe("assertNativeShortsPairGeometry", () => {
test("accepts an exact active native Like/Dislike action pair", () => {
expect(() => assertNativeShortsPairGeometry(validNativeShortsPairGeometry())).not.toThrow();
});
test("rejects controls selected from different retained Shorts reels", () => {
const measurement = validNativeShortsPairGeometry();
measurement.dislike.reelIndex = 1;
expect(() => assertNativeShortsPairGeometry(measurement)).toThrow(/belong to different reels/);
});
test("rejects a native Dislike that is not immediately after Like", () => {
const measurement = validNativeShortsPairGeometry();
measurement.dislike.actionIndex += 1;
expect(() => assertNativeShortsPairGeometry(measurement)).toThrow(/not immediately after Like/);
});
test("rejects a shrunken native Dislike icon", () => {
const measurement = validNativeShortsPairGeometry();
measurement.dislike.icon.width = 8;
expect(() => assertNativeShortsPairGeometry(measurement)).toThrow(/Native Shorts Dislike icon container width/);
});
test("rejects broken native action-stack spacing", () => {
const measurement = validNativeShortsPairGeometry();
measurement.dislike.host.y += 12;
expect(() => assertNativeShortsPairGeometry(measurement)).toThrow(
/Gap between native Shorts Like and Dislike action hosts/,
);
});
test("rejects native Dislike count typography that diverges from Like", () => {
const measurement = validNativeShortsPairGeometry();
measurement.dislike.countStyle.fontFamily = "Comic Sans MS";
expect(() => assertNativeShortsPairGeometry(measurement)).toThrow(/count fontFamily does not match/);
});
});
describe("live Shorts full-stack visual readiness", () => {
const viewport = { height: 844, width: 390 };
const validStack = () => [0, 1, 2, 3, 4].map((index) => box(330, 100 + index * 78, 48, 78));
test("accepts five aligned visible controls and painted icons", () => {
expect(() => assertShortsActionStackGeometry(validStack(), viewport)).not.toThrow();
expect(
isShortsIconVisualReady({
effectiveOpacity: 1,
paintedGraphicCount: 1,
rendered: true,
svgPresent: true,
}),
).toBe(true);
});
test("rejects an incomplete Shorts stack", () => {
expect(() => assertShortsActionStackGeometry(validStack().slice(0, 4), viewport)).toThrow(
/full Shorts action stack/,
);
});
test("rejects a misaligned or clipped control", () => {
const misaligned = validStack();
misaligned[3].x -= 8;
expect(() => assertShortsActionStackGeometry(misaligned, viewport)).toThrow(/horizontal center/);
const clipped = validStack();
clipped[4].y = 800;
expect(() => assertShortsActionStackGeometry(clipped, viewport)).toThrow(/bottom edge/);
});
test.each([
["unhydrated SVG", { effectiveOpacity: 1, paintedGraphicCount: 0, rendered: true, svgPresent: true }],
["missing SVG", { effectiveOpacity: 1, paintedGraphicCount: 0, rendered: true, svgPresent: false }],
["hidden ancestor", { effectiveOpacity: 0, paintedGraphicCount: 1, rendered: true, svgPresent: true }],
["non-rendered ancestor", { effectiveOpacity: 1, paintedGraphicCount: 1, rendered: false, svgPresent: true }],
])("does not screenshot a %s as a ready icon", (_label, state) => {
expect(isShortsIconVisualReady(state)).toBe(false);
});
});
@@ -0,0 +1,71 @@
const CREDENTIALS_KEY = "rydVoteCredentials";
function hasModernMethod(name) {
return typeof GM !== "undefined" && typeof GM?.[name] === "function";
}
async function getStoredValue() {
if (hasModernMethod("getValue")) {
return GM.getValue(CREDENTIALS_KEY, null);
}
if (typeof GM_getValue === "function") {
return GM_getValue(CREDENTIALS_KEY, null);
}
throw new Error("Userscript storage API is unavailable");
}
async function setStoredValue(value) {
if (hasModernMethod("setValue")) {
await GM.setValue(CREDENTIALS_KEY, value);
return;
}
if (typeof GM_setValue === "function") {
await GM_setValue(CREDENTIALS_KEY, value);
return;
}
throw new Error("Userscript storage API is unavailable");
}
async function deleteStoredValue() {
if (hasModernMethod("deleteValue")) {
await GM.deleteValue(CREDENTIALS_KEY);
return;
}
if (typeof GM_deleteValue === "function") {
await GM_deleteValue(CREDENTIALS_KEY);
return;
}
// Old managers may expose get/set without delete. Null is treated as an
// empty credential by load(), while still allowing the client to recover.
await setStoredValue(null);
}
function createGmCredentialStore() {
return {
async load() {
const value = await getStoredValue();
if (!value || typeof value !== "object") {
return null;
}
return {
userId: value.userId,
registrationConfirmed: value.registrationConfirmed === true,
};
},
async save(credentials) {
await setStoredValue({
userId: credentials.userId,
registrationConfirmed: credentials.registrationConfirmed === true,
});
},
async clear() {
await deleteStoredValue();
},
};
}
export { CREDENTIALS_KEY, createGmCredentialStore };
@@ -0,0 +1,86 @@
import { CREDENTIALS_KEY, createGmCredentialStore } from "./gm-credential-store";
const LEGACY_METHODS = ["GM_getValue", "GM_setValue", "GM_deleteValue"];
afterEach(() => {
delete global.GM;
LEGACY_METHODS.forEach((name) => delete global[name]);
});
describe("createGmCredentialStore", () => {
it("uses the modern GM storage contract", async () => {
let value = null;
global.GM = {
getValue: jest.fn(async (_key, fallbackValue) => value ?? fallbackValue),
setValue: jest.fn(async (_key, nextValue) => {
value = nextValue;
}),
deleteValue: jest.fn(async () => {
value = null;
}),
};
const store = createGmCredentialStore();
expect(await store.load()).toBeNull();
await store.save({ userId: "modern-user", registrationConfirmed: true });
expect(await store.load()).toEqual({ userId: "modern-user", registrationConfirmed: true });
expect(global.GM.setValue).toHaveBeenCalledWith(CREDENTIALS_KEY, {
userId: "modern-user",
registrationConfirmed: true,
});
await store.clear();
expect(global.GM.deleteValue).toHaveBeenCalledWith(CREDENTIALS_KEY);
expect(await store.load()).toBeNull();
});
it("uses legacy synchronous storage methods when modern GM methods are absent", async () => {
let value = null;
global.GM_getValue = jest.fn((_key, fallbackValue) => value ?? fallbackValue);
global.GM_setValue = jest.fn((_key, nextValue) => {
value = nextValue;
});
global.GM_deleteValue = jest.fn(() => {
value = null;
});
const store = createGmCredentialStore();
await store.save({ userId: "legacy-user", registrationConfirmed: true });
expect(await store.load()).toEqual({ userId: "legacy-user", registrationConfirmed: true });
await store.clear();
expect(global.GM_deleteValue).toHaveBeenCalledWith(CREDENTIALS_KEY);
expect(await store.load()).toBeNull();
});
it("falls back to a null value when a legacy manager has no delete method", async () => {
global.GM_getValue = jest.fn(async (_key, fallbackValue) => fallbackValue);
global.GM_setValue = jest.fn(async () => undefined);
await createGmCredentialStore().clear();
expect(global.GM_setValue).toHaveBeenCalledWith(CREDENTIALS_KEY, null);
});
it("normalizes invalid and unconfirmed stored values", async () => {
global.GM = {
getValue: jest
.fn()
.mockResolvedValueOnce("invalid")
.mockResolvedValueOnce({ userId: "pending", registrationConfirmed: false }),
};
const store = createGmCredentialStore();
expect(await store.load()).toBeNull();
expect(await store.load()).toEqual({ userId: "pending", registrationConfirmed: false });
});
it("propagates storage failures and reports a missing storage API", async () => {
global.GM = { getValue: jest.fn(async () => Promise.reject(new Error("GM storage failed"))) };
await expect(createGmCredentialStore().load()).rejects.toThrow("GM storage failed");
delete global.GM;
await expect(createGmCredentialStore().load()).rejects.toThrow("Userscript storage API is unavailable");
await expect(createGmCredentialStore().save({ userId: "no-storage", registrationConfirmed: true })).rejects.toThrow(
"Userscript storage API is unavailable",
);
});
});
@@ -0,0 +1,87 @@
const SYNTHETIC_DISLIKE_KEY_PREFIX = "rydSyntheticDislikedShort:";
// Each currently disliked Short owns one independent key. Deliberately do not
// evict selected videos: forgetting one would make the next click submit -1
// again instead of the required neutral (0) transition.
function hasModernMethod(name) {
return typeof GM !== "undefined" && typeof GM?.[name] === "function";
}
async function getStoredValue(key, fallbackValue) {
if (hasModernMethod("getValue")) {
return GM.getValue(key, fallbackValue);
}
if (typeof GM_getValue === "function") {
return GM_getValue(key, fallbackValue);
}
throw new Error("Userscript storage API is unavailable");
}
async function setStoredValue(key, value) {
if (hasModernMethod("setValue")) {
await GM.setValue(key, value);
return;
}
if (typeof GM_setValue === "function") {
await GM_setValue(key, value);
return;
}
throw new Error("Userscript storage API is unavailable");
}
async function deleteStoredValue(key) {
if (hasModernMethod("deleteValue")) {
await GM.deleteValue(key);
return;
}
if (typeof GM_deleteValue === "function") {
await GM_deleteValue(key);
return;
}
// Some legacy managers do not expose deleteValue. An explicit false value
// has the same read semantics and prevents a stale state from returning.
await setStoredValue(key, false);
}
function validateVideoId(videoId) {
if (typeof videoId !== "string" || videoId.length === 0) {
throw new TypeError("videoId must be a non-empty string");
}
}
function syntheticDislikeKey(videoId) {
return `${SYNTHETIC_DISLIKE_KEY_PREFIX}${videoId}`;
}
function createGmSyntheticDislikeStore() {
let mutationQueue = Promise.resolve();
function enqueueMutation(mutation) {
const result = mutationQueue.catch(() => undefined).then(mutation);
mutationQueue = result;
return result;
}
return {
async isDisliked(videoId) {
validateVideoId(videoId);
await mutationQueue.catch(() => undefined);
return (await getStoredValue(syntheticDislikeKey(videoId), false)) === true;
},
async setDisliked(videoId, disliked) {
validateVideoId(videoId);
if (typeof disliked !== "boolean") {
throw new TypeError("disliked must be a boolean");
}
return enqueueMutation(() =>
disliked ? setStoredValue(syntheticDislikeKey(videoId), true) : deleteStoredValue(syntheticDislikeKey(videoId)),
);
},
};
}
export { SYNTHETIC_DISLIKE_KEY_PREFIX, createGmSyntheticDislikeStore };
@@ -0,0 +1,156 @@
import { SYNTHETIC_DISLIKE_KEY_PREFIX, createGmSyntheticDislikeStore } from "./gm-synthetic-dislike-store";
const LEGACY_METHODS = ["GM_getValue", "GM_setValue", "GM_deleteValue"];
const stateKey = (videoId) => `${SYNTHETIC_DISLIKE_KEY_PREFIX}${videoId}`;
function installModernStorage(initialValues = {}) {
const values = new Map(Object.entries(initialValues));
global.GM = {
getValue: jest.fn(async (key, fallbackValue) => (values.has(key) ? values.get(key) : fallbackValue)),
setValue: jest.fn(async (key, value) => {
values.set(key, value);
}),
deleteValue: jest.fn(async (key) => {
values.delete(key);
}),
};
return values;
}
afterEach(() => {
delete global.GM;
LEGACY_METHODS.forEach((name) => delete global[name]);
});
describe("createGmSyntheticDislikeStore", () => {
it("uses independent per-video values as the authoritative modern GM state", async () => {
const values = installModernStorage({ [stateKey("existing-video")]: true });
const store = createGmSyntheticDislikeStore();
await expect(store.isDisliked("existing-video")).resolves.toBe(true);
await expect(store.isDisliked("missing-video")).resolves.toBe(false);
await store.setDisliked("new-video", true);
expect(values.get(stateKey("new-video"))).toBe(true);
await store.setDisliked("existing-video", false);
expect(values.has(stateKey("existing-video"))).toBe(false);
await expect(store.isDisliked("existing-video")).resolves.toBe(false);
});
it("supports legacy synchronous GM methods", async () => {
const values = new Map();
global.GM_getValue = jest.fn((key, fallbackValue) => (values.has(key) ? values.get(key) : fallbackValue));
global.GM_setValue = jest.fn((key, value) => values.set(key, value));
global.GM_deleteValue = jest.fn((key) => values.delete(key));
const store = createGmSyntheticDislikeStore();
await store.setDisliked("legacy-video", true);
expect(global.GM_setValue).toHaveBeenCalledWith(stateKey("legacy-video"), true);
await expect(store.isDisliked("legacy-video")).resolves.toBe(true);
await store.setDisliked("legacy-video", false);
expect(global.GM_deleteValue).toHaveBeenCalledWith(stateKey("legacy-video"));
await expect(store.isDisliked("legacy-video")).resolves.toBe(false);
});
it("stores false when a legacy manager has no delete method", async () => {
const values = new Map();
global.GM_getValue = jest.fn((key, fallbackValue) => (values.has(key) ? values.get(key) : fallbackValue));
global.GM_setValue = jest.fn((key, value) => values.set(key, value));
const store = createGmSyntheticDislikeStore();
await store.setDisliked("legacy-video", true);
await store.setDisliked("legacy-video", false);
expect(values.get(stateKey("legacy-video"))).toBe(false);
await expect(store.isDisliked("legacy-video")).resolves.toBe(false);
});
it("does not lose state when two store instances update different videos concurrently", async () => {
const values = installModernStorage();
const firstStore = createGmSyntheticDislikeStore();
const secondStore = createGmSyntheticDislikeStore();
await Promise.all([firstStore.setDisliked("first-video", true), secondStore.setDisliked("second-video", true)]);
expect(values.get(stateKey("first-video"))).toBe(true);
expect(values.get(stateKey("second-video"))).toBe(true);
await expect(firstStore.isDisliked("first-video")).resolves.toBe(true);
await expect(secondStore.isDisliked("second-video")).resolves.toBe(true);
});
it("uses the last completed per-video write for same-video races", async () => {
const values = new Map();
let releaseTrueWrite;
let trueWriteStarted;
const trueWriteGate = new Promise((resolve) => {
releaseTrueWrite = resolve;
});
const trueWriteEntered = new Promise((resolve) => {
trueWriteStarted = resolve;
});
global.GM = {
getValue: jest.fn(async (key, fallbackValue) => (values.has(key) ? values.get(key) : fallbackValue)),
setValue: jest.fn(async (key, value) => {
if (key === stateKey("shared-video") && value === true) {
trueWriteStarted();
await trueWriteGate;
}
values.set(key, value);
}),
deleteValue: jest.fn(async (key) => {
values.delete(key);
}),
};
const firstStore = createGmSyntheticDislikeStore();
const secondStore = createGmSyntheticDislikeStore();
const delayedTrue = firstStore.setDisliked("shared-video", true);
await trueWriteEntered;
await secondStore.setDisliked("shared-video", false);
releaseTrueWrite();
await delayedTrue;
await expect(firstStore.isDisliked("shared-video")).resolves.toBe(true);
});
it("treats non-true stored values as neutral", async () => {
installModernStorage({
[stateKey("false-video")]: false,
[stateKey("corrupt-video")]: { disliked: true },
});
const store = createGmSyntheticDislikeStore();
await expect(store.isDisliked("false-video")).resolves.toBe(false);
await expect(store.isDisliked("corrupt-video")).resolves.toBe(false);
});
it("propagates storage errors and recovers its local mutation queue", async () => {
global.GM = { getValue: jest.fn(async () => Promise.reject(new Error("read failed"))) };
await expect(createGmSyntheticDislikeStore().isDisliked("video")).rejects.toThrow("read failed");
const values = installModernStorage();
global.GM.setValue.mockRejectedValueOnce(new Error("write failed"));
const store = createGmSyntheticDislikeStore();
await expect(store.setDisliked("video", true)).rejects.toThrow("write failed");
await expect(store.setDisliked("video", true)).resolves.toBeUndefined();
expect(values.get(stateKey("video"))).toBe(true);
delete global.GM;
await expect(createGmSyntheticDislikeStore().isDisliked("video")).rejects.toThrow(
"Userscript storage API is unavailable",
);
});
it("rejects invalid arguments without accessing storage", async () => {
installModernStorage();
const store = createGmSyntheticDislikeStore();
await expect(store.isDisliked(123)).rejects.toThrow(TypeError);
await expect(store.setDisliked("", true)).rejects.toThrow(TypeError);
await expect(store.setDisliked("video", 1)).rejects.toThrow(TypeError);
expect(global.GM.getValue).not.toHaveBeenCalled();
expect(global.GM.setValue).not.toHaveBeenCalled();
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,87 @@
const fs = require("fs");
const os = require("os");
const path = require("path");
const vm = require("vm");
const webpack = require("webpack");
const createUserscriptConfig = require("../../webpack.userscript.config");
const userscriptMeta = require("./userscript.meta");
const ARTIFACT_PATH = path.join(__dirname, "Return Youtube Dislike.user.js");
function compile(config) {
return new Promise((resolve, reject) => {
const compiler = webpack(config);
compiler.run((error, stats) => {
const finish = (closeError) => {
if (error || closeError) {
reject(error || closeError);
return;
}
if (stats.hasErrors()) {
reject(new Error(stats.toString({ all: false, errors: true })));
return;
}
resolve();
};
compiler.close(finish);
});
});
}
describe("generated userscript artifact", () => {
let artifact;
beforeAll(() => {
artifact = fs.readFileSync(ARTIFACT_PATH, "utf8");
});
it("contains the candidate metadata and required modern and legacy grants", () => {
expect(artifact.startsWith("// ==UserScript==\n")).toBe(true);
expect(artifact).toContain(`// @version ${userscriptMeta.version}`);
expect(userscriptMeta.version).toBe("3.2.0");
expect(artifact).toContain(`// @downloadURL ${userscriptMeta.downloadURL}`);
expect(artifact).toContain(`// @updateURL ${userscriptMeta.updateURL}`);
for (const grant of userscriptMeta.grants) {
expect(artifact).toContain(`// @grant ${grant}`);
}
expect(artifact).not.toContain("@grant GM.xmlHttpRequest");
expect(artifact).not.toContain("@connect");
});
it("is a standalone syntactically valid script with editable user options", () => {
expect(() => new vm.Script(artifact, { filename: ARTIFACT_PATH })).not.toThrow();
expect(artifact).not.toMatch(/^\s*(?:import|export)\s/m);
expect(artifact).toContain("BEGIN USER OPTIONS");
expect(artifact).toContain("disableVoteSubmission: false");
expect(artifact).not.toContain("data-ryd-userscript-version");
});
it("builds an unpublished live-test artifact with a runtime marker and no update URLs", async () => {
const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "ryd-userscript-live-build-"));
try {
const config = createUserscriptConfig({ liveTest: "true" }, { mode: "production" });
config.output = { ...config.output, path: temporaryDirectory };
await compile(config);
const liveArtifact = fs.readFileSync(path.join(temporaryDirectory, config.output.filename), "utf8");
expect(liveArtifact).toContain("data-ryd-userscript-version");
expect(liveArtifact).toContain("// @name Return YouTube Dislike [Live Test]");
expect(liveArtifact).not.toContain("// @downloadURL");
expect(liveArtifact).not.toContain("// @updateURL");
} finally {
fs.rmSync(temporaryDirectory, { recursive: true, force: true });
}
});
it("rebuilds deterministically", async () => {
const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "ryd-userscript-build-"));
try {
const config = createUserscriptConfig({}, { mode: "production" });
config.output = { ...config.output, path: temporaryDirectory };
await compile(config);
const rebuilt = fs.readFileSync(path.join(temporaryDirectory, config.output.filename), "utf8");
expect(rebuilt).toBe(artifact);
} finally {
fs.rmSync(temporaryDirectory, { recursive: true, force: true });
}
});
});
@@ -0,0 +1 @@
"3.2.0"
+29
View File
@@ -0,0 +1,29 @@
const version = require("./userscript-version.json");
module.exports = {
name: "Return YouTube Dislike",
namespace: "https://www.returnyoutubedislike.com/",
homepage: "https://www.returnyoutubedislike.com/",
version,
encoding: "utf-8",
description: "Return of the YouTube Dislike, Based off https://www.returnyoutubedislike.com/",
icon: "https://github.com/Anarios/return-youtube-dislike/raw/main/Icons/Return%20Youtube%20Dislike%20-%20Transparent.png",
author: "Anarios & JRWR",
match: ["*://*.youtube.com/*"],
exclude: ["*://music.youtube.com/*", "*://*.music.youtube.com/*"],
compatible: ["chrome", "firefox", "opera", "safari", "edge"],
downloadURL:
"https://github.com/Anarios/return-youtube-dislike/raw/main/Extensions/UserScript/Return%20Youtube%20Dislike.user.js",
updateURL:
"https://github.com/Anarios/return-youtube-dislike/raw/main/Extensions/UserScript/Return%20Youtube%20Dislike.user.js",
grants: [
"GM.getValue",
"GM.setValue",
"GM.deleteValue",
"GM_getValue",
"GM_setValue",
"GM_deleteValue",
"GM_addStyle",
],
runAt: "document-end",
};
+6 -1
View File
@@ -19,7 +19,12 @@
} */
#ryd-bar-container {
background: var(--yt-spec-icon-disabled);
background: #737373;
background: color-mix(
in srgb,
var(--yt-spec-text-primary, #f1f1f1) 55%,
var(--yt-spec-base-background, #0f0f0f) 45%
);
border-radius: 2px;
}
+18 -149
View File
@@ -1,4 +1,6 @@
import { config, getApiUrl, getApiEndpoint, getChangelogUrl } from "./src/config";
import { createVoteClient } from "../common/vote-client";
import { createBrowserCredentialStore } from "./src/vote-client-adapter";
const apiUrl = getApiUrl();
const voteDisabledIconName = config.voteDisabledIconName;
@@ -13,7 +15,15 @@ let extConfig = { ...config.defaultExtConfig };
if (isChrome()) api = chrome;
else if (isFirefox()) api = browser;
const voteClient = createVoteClient({
apiBaseUrl: apiUrl,
fetchImpl: (...args) => fetch(...args),
credentialStore: createBrowserCredentialStore(api.storage.sync, () => api.runtime?.lastError),
cryptoImpl: globalThis.crypto,
});
initExtConfig();
voteClient.ensureRegistered().catch((error) => console.error("Vote registration failed", error));
function broadcastPatreonStatus(authenticated, user, sessionToken) {
chrome.tabs.query({}, (tabs) => {
@@ -175,10 +185,16 @@ api.runtime.onMessage.addListener((request, sender, sendResponse) => {
toSend = [];
}
} else if (request.message == "register") {
register();
voteClient
.ensureRegistered()
.then(({ userId }) => sendResponse?.({ success: true, userId }))
.catch((error) => sendResponse?.({ success: false, error: error.message }));
return true;
} else if (request.message == "send_vote") {
sendVote(request.videoId, request.vote);
voteClient
.submitVote(request.videoId, request.vote)
.then(() => sendResponse?.({ success: true }))
.catch((error) => sendResponse?.({ success: false, error: error.message }));
return true;
} else if (request.message === "patreon_oauth_login") {
(async () => {
@@ -486,156 +502,9 @@ if (api?.runtime?.onStartup && typeof api.runtime.onStartup.addListener === "fun
// }
// });
async function sendVote(videoId, vote, depth = 1) {
api.storage.sync.get(null, async (storageResult) => {
if (!storageResult.userId || !storageResult.registrationConfirmed) {
await register();
}
let voteResponse = await fetch(getApiEndpoint("/interact/vote"), {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
userId: storageResult.userId,
videoId,
value: vote,
}),
});
if (voteResponse.status == 401 && depth > 0) {
await register();
await sendVote(videoId, vote, depth - 1);
return;
} else if (voteResponse.status == 401) {
// We have already tried registering
return;
}
const voteResponseJson = await voteResponse.json();
const solvedPuzzle = await solvePuzzle(voteResponseJson);
if (!solvedPuzzle.solution) {
await sendVote(videoId, vote);
return;
}
await fetch(getApiEndpoint("/interact/confirmVote"), {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
...solvedPuzzle,
userId: storageResult.userId,
videoId,
}),
});
});
}
async function register() {
const userId = generateUserID();
api.storage.sync.set({ userId });
const registrationResponse = await fetch(getApiEndpoint(`/puzzle/registration?userId=${userId}`), {
method: "GET",
headers: {
Accept: "application/json",
},
}).then((response) => response.json());
const solvedPuzzle = await solvePuzzle(registrationResponse);
if (!solvedPuzzle.solution) {
await register();
return;
}
const result = await fetch(getApiEndpoint(`/puzzle/registration?userId=${userId}`), {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(solvedPuzzle),
}).then((response) => response.json());
if (result === true) {
return api.storage.sync.set({ registrationConfirmed: true });
}
}
api.storage.sync.get(null, async (res) => {
if (!res || !res.userId || !res.registrationConfirmed) {
await register();
}
});
const sentIds = new Set();
let toSend = [];
function countLeadingZeroes(uInt8View, limit) {
let zeroes = 0;
let value = 0;
for (let i = 0; i < uInt8View.length; i++) {
value = uInt8View[i];
if (value === 0) {
zeroes += 8;
} else {
let count = 1;
if (value >>> 4 === 0) {
count += 4;
value <<= 4;
}
if (value >>> 6 === 0) {
count += 2;
value <<= 2;
}
zeroes += count - (value >>> 7);
break;
}
if (zeroes >= limit) {
break;
}
}
return zeroes;
}
async function solvePuzzle(puzzle) {
let challenge = Uint8Array.from(atob(puzzle.challenge), (c) => c.charCodeAt(0));
let buffer = new ArrayBuffer(20);
let uInt8View = new Uint8Array(buffer);
let uInt32View = new Uint32Array(buffer);
let maxCount = Math.pow(2, puzzle.difficulty) * 3;
for (let i = 4; i < 20; i++) {
uInt8View[i] = challenge[i - 4];
}
for (let i = 0; i < maxCount; i++) {
uInt32View[0] = i;
let hash = await crypto.subtle.digest("SHA-512", buffer);
let hashUint8 = new Uint8Array(hash);
if (countLeadingZeroes(hashUint8) >= puzzle.difficulty) {
return {
solution: btoa(String.fromCharCode.apply(null, uInt8View.slice(0, 4))),
};
}
}
return {};
}
function generateUserID(length = 36) {
const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
let result = "";
if (crypto && crypto.getRandomValues) {
const values = new Uint32Array(length);
crypto.getRandomValues(values);
for (let i = 0; i < length; i++) {
result += charset[values[i] % charset.length];
}
return result;
} else {
for (let i = 0; i < length; i++) {
result += charset[Math.floor(Math.random() * charset.length)];
}
return result;
}
}
function storageChangeHandler(changes, area) {
if (changes.disableVoteSubmission !== undefined) {
handleDisableVoteSubmissionChangeEvent(changes.disableVoteSubmission.newValue);
+84 -10
View File
@@ -1,9 +1,22 @@
import { getButtons } from "./src/buttons";
import { isShorts, setInitialState, initExtConfig } from "./src/state";
import { getBrowser, isVideoLoaded } from "./src/utils";
import { getButtons, getDislikeButton, getLikeButton, hasRenderedBox, markButtonsForVideo } from "./src/buttons";
import {
hasLoadedStateForVideo,
initExtConfig,
isLikesDisabled,
isShorts,
restoreCurrentState,
setInitialState,
} from "./src/state";
import { getBrowser, getVideoId, isVideoLoaded } from "./src/utils";
import { addLikeDislikeEventListener, createSmartimationObserver, storageChangeHandler } from "./src/events";
import { createInitializationCycleRunner } from "./src/initialization-cycle";
import { initPatreonFeatures } from "./src/patreon";
if (__RYD_LIVE_TEST_BUILD__) {
document.documentElement.setAttribute("data-ryd-extension-version", getBrowser().runtime.getManifest().version);
document.documentElement.setAttribute("data-ryd-extension-build", __RYD_LIVE_BUILD_ID__);
}
await initExtConfig();
initPatreonFeatures();
@@ -12,6 +25,11 @@ let isSetInitialStateDone = false;
let isStorageListenerRegistered = false;
let shortsNavigationObserver = null;
let shortsNavigationObserverTarget = null;
let initializedVideoId = null;
let initializedButtons = null;
let initializedLikeButton = null;
let initializedDislikeButton = null;
let initializationCheckRunning = false;
function ensureShortsNavigationObserver() {
if (!isShorts()) {
@@ -51,19 +69,44 @@ function ensureShortsNavigationObserver() {
}
async function checkForInitialization() {
if (initializationCheckRunning) return;
initializationCheckRunning = true;
try {
if (isShorts()) {
ensureShortsNavigationObserver();
}
if ((isShorts() && isVideoLoaded()) || (getButtons()?.offsetParent && isVideoLoaded())) {
const buttons = getButtons();
const videoId = getVideoId(window.location.href);
if ((isShorts() && isVideoLoaded()) || (hasRenderedBox(buttons) && isVideoLoaded())) {
if (!buttons) return;
const likeButton = getLikeButton();
const dislikeButton = getDislikeButton();
if (!likeButton || !dislikeButton) return;
if (jsInitChecktimer !== null) {
clearInterval(jsInitChecktimer);
jsInitChecktimer = null;
}
markButtonsForVideo(buttons, videoId);
createSmartimationObserver();
addLikeDislikeEventListener();
await setInitialState();
if (hasLoadedStateForVideo(videoId)) {
restoreCurrentState();
} else {
await setInitialState();
}
if (
videoId !== getVideoId(window.location.href) ||
buttons !== getButtons() ||
likeButton !== getLikeButton() ||
dislikeButton !== getDislikeButton()
) {
return;
}
initializedVideoId = videoId;
initializedButtons = buttons;
initializedLikeButton = likeButton;
initializedDislikeButton = dislikeButton;
isSetInitialStateDone = true;
if (!isStorageListenerRegistered) {
getBrowser().storage.onChanged.addListener(storageChangeHandler);
@@ -71,14 +114,13 @@ async function checkForInitialization() {
}
}
} catch (exception) {
if (!isSetInitialStateDone) {
console.log("error");
await setInitialState();
}
console.warn("Initialization failed; retrying when the current controls are ready.", exception);
} finally {
initializationCheckRunning = false;
}
}
async function triggerInitializationCycle() {
const initializationCycle = createInitializationCycleRunner(async () => {
isSetInitialStateDone = false;
if (jsInitChecktimer !== null) {
@@ -99,6 +141,10 @@ async function triggerInitializationCycle() {
}
}, 2000);
}
});
function triggerInitializationCycle() {
return initializationCycle.request();
}
async function setEventListeners() {
@@ -111,6 +157,34 @@ document.addEventListener("yt-navigate-finish", async function (event) {
await setEventListeners();
});
function watchControlsNeedInitialization() {
const videoId = getVideoId(window.location.href);
if (!videoId || initializationCycle.isRunning() || jsInitChecktimer !== null) return false;
const buttons = getButtons();
if (!buttons) return true;
const likeButton = getLikeButton();
const dislikeButton = getDislikeButton();
return (
initializedVideoId !== videoId ||
initializedButtons !== buttons ||
initializedLikeButton !== likeButton ||
initializedDislikeButton !== dislikeButton ||
!buttons?.isConnected ||
!likeButton?.isConnected ||
!dislikeButton?.isConnected ||
!buttons?.contains(likeButton) ||
!buttons?.contains(dislikeButton) ||
(!isShorts() &&
hasLoadedStateForVideo(videoId) &&
!isLikesDisabled() &&
!buttons.querySelector("#ryd-bar-container"))
);
}
setInterval(() => {
if (watchControlsNeedInitialization()) void triggerInitializationCycle();
}, 500);
const s = document.createElement("script");
s.src = chrome.runtime.getURL("menu-fixer.js");
s.onload = function () {
+47 -17
View File
@@ -2,12 +2,32 @@ import { getButtons, getDislikeButton, getLikeButton } from "./buttons";
import { extConfig, isMobile, isLikesDisabled, isNewDesign, isRoundedDesign, isShorts } from "./state";
import { getColorFromTheme, isInViewport, querySelector } from "./utils";
function closestConfigured(element, selectors) {
for (const selector of Array.isArray(selectors) ? selectors : [selectors]) {
const match = selector ? element?.closest(selector) : null;
if (match) return match;
}
return null;
}
function findInCurrentWatchTree(buttons, selectors, fallbackScope = null) {
const closest = closestConfigured(buttons, selectors);
if (closest) return closest;
const watchRoot = buttons?.closest("ytd-watch-flexy, ytd-watch-grid");
const scope = fallbackScope ?? watchRoot;
return scope ? querySelector(selectors, scope) : undefined;
}
function createRateBar(likes, dislikes) {
let rateBar = document.getElementById("ryd-bar-container");
const buttons = getButtons();
for (const wrapper of document.querySelectorAll(".ryd-tooltip")) {
if (!buttons?.contains(wrapper)) wrapper.remove();
}
let rateBar = buttons?.querySelector("#ryd-bar-container");
if (!isLikesDisabled()) {
// sometimes rate bar is hidden
if (rateBar && !isInViewport(rateBar)) {
rateBar.remove();
(rateBar.closest(".ryd-tooltip") ?? rateBar).remove();
rateBar = null;
}
@@ -52,10 +72,7 @@ function createRateBar(likes, dislikes) {
colorLikeStyle = "; background-color: " + getColorFromTheme(true);
colorDislikeStyle = "; background-color: " + getColorFromTheme(false);
}
let actions =
isNewDesign() && getButtons() === querySelector(extConfig.selectors.rateBar.newDesignActions)
? getButtons()
: querySelector(extConfig.selectors.rateBar.oldDesignActions);
const actions = buttons;
(actions || querySelector(extConfig.selectors.rateBar.mobileActionBar)).insertAdjacentHTML(
"beforeend",
`
@@ -80,30 +97,43 @@ function createRateBar(likes, dislikes) {
if (isNewDesign()) {
// Add border between info and comments
let descriptionAndActionsElement = querySelector(extConfig.selectors.rateBar.topRow);
descriptionAndActionsElement.style.borderBottom = "1px solid var(--yt-spec-10-percent-layer)";
descriptionAndActionsElement.style.paddingBottom = "10px";
const descriptionAndActionsElement = findInCurrentWatchTree(buttons, extConfig.selectors.rateBar.topRow);
if (descriptionAndActionsElement) {
descriptionAndActionsElement.style.borderBottom = "1px solid var(--yt-spec-10-percent-layer)";
descriptionAndActionsElement.style.paddingBottom = "10px";
}
// Fix like/dislike ratio bar offset in new UI
querySelector(extConfig.selectors.rateBar.actionsInner).style.width = "revert";
const actionsInner = findInCurrentWatchTree(
buttons,
extConfig.selectors.rateBar.actionsInner,
descriptionAndActionsElement,
);
if (actionsInner) actionsInner.style.width = "revert";
if (isRoundedDesign()) {
querySelector(extConfig.selectors.rateBar.actions).style.flexDirection = "row-reverse";
const actions = findInCurrentWatchTree(
buttons,
extConfig.selectors.rateBar.actions,
descriptionAndActionsElement,
);
if (actions) actions.style.flexDirection = "row-reverse";
}
}
} else {
document.querySelector(`.ryd-tooltip`).style.width = widthPx + "px";
document.getElementById("ryd-bar").style.width = widthPercent + "%";
document.querySelector("#ryd-dislike-tooltip > #tooltip").innerHTML = tooltipInnerHTML;
buttons.querySelector(`.ryd-tooltip`).style.width = widthPx + "px";
buttons.querySelector("#ryd-bar").style.width = widthPercent + "%";
const tooltip = buttons.querySelector("#ryd-dislike-tooltip > #tooltip");
if (tooltip) tooltip.innerHTML = tooltipInnerHTML;
if (extConfig.coloredBar) {
document.getElementById("ryd-bar-container").style.backgroundColor = getColorFromTheme(false);
document.getElementById("ryd-bar").style.backgroundColor = getColorFromTheme(true);
buttons.querySelector("#ryd-bar-container").style.backgroundColor = getColorFromTheme(false);
buttons.querySelector("#ryd-bar").style.backgroundColor = getColorFromTheme(true);
}
}
}
} else {
console.log("removing bar");
if (rateBar) {
rateBar.parentNode.removeChild(rateBar);
(rateBar.closest(".ryd-tooltip") ?? rateBar).remove();
}
}
}
+116
View File
@@ -0,0 +1,116 @@
/** @jest-environment jsdom */
const fs = require("fs");
const path = require("path");
jest.mock("./buttons", () => ({
getButtons: jest.fn(),
getDislikeButton: jest.fn(),
getLikeButton: jest.fn(),
}));
jest.mock("./state", () => ({
extConfig: {
coloredBar: false,
rateBar: null,
selectors: {
rateBar: {
actions: ["#actions"],
actionsInner: ["#actions-inner"],
mobileActionBar: ["ytm-slim-video-action-bar-renderer"],
topRow: ["#top-row"],
},
},
showTooltipPercentage: false,
tooltipPercentageMode: "dash_like",
},
isLikesDisabled: jest.fn(() => false),
isMobile: jest.fn(() => false),
isNewDesign: jest.fn(() => true),
isRoundedDesign: jest.fn(() => true),
isShorts: jest.fn(() => false),
}));
jest.mock("./utils", () => ({
getColorFromTheme: jest.fn(() => "red"),
isInViewport: jest.fn(() => true),
querySelector: jest.fn((selectors, element) => {
const scope = element ?? globalThis.document;
for (const selector of Array.isArray(selectors) ? selectors : [selectors]) {
const match = scope?.querySelector(selector);
if (match) return match;
}
return undefined;
}),
}));
const { getButtons, getDislikeButton, getLikeButton } = require("./buttons");
const { createRateBar } = require("./bar");
const { isLikesDisabled, isMobile, isNewDesign, isRoundedDesign, isShorts } = require("./state");
describe("rate-bar stylesheet contrast", () => {
test("uses the same high-contrast negative track recipe as the userscript", () => {
const stylesheet = fs.readFileSync(path.join(__dirname, "../content-style.css"), "utf8");
const rule = stylesheet.match(/#ryd-bar-container\s*{([^}]*)}/)?.[1] ?? "";
expect(rule).toContain("background: #737373");
expect(rule).toContain("var(--yt-spec-text-primary, #f1f1f1) 55%");
expect(rule).toContain("var(--yt-spec-base-background, #0f0f0f) 45%");
expect(rule).not.toContain("--yt-spec-icon-disabled");
});
});
function watchTree(videoId) {
return `
<ytd-watch-flexy video-id="${videoId}">
<div id="top-row" data-watch-row="${videoId}">
<div id="actions-inner" data-watch-actions-inner="${videoId}" style="width: 999px">
<div id="actions" data-watch-actions="${videoId}">
<div id="top-level-buttons-computed" data-watch-buttons="${videoId}">
<like-button-view-model><button style="width: 96px"></button></like-button-view-model>
<dislike-button-view-model><button style="width: 96px"></button></dislike-button-view-model>
</div>
</div>
</div>
</div>
</ytd-watch-flexy>`;
}
describe("rate-bar visual ownership", () => {
beforeEach(() => {
document.body.innerHTML = `${watchTree("AAAAAAAAAAA")}${watchTree("BBBBBBBBBBB")}`;
isLikesDisabled.mockReturnValue(false);
isMobile.mockReturnValue(false);
isNewDesign.mockReturnValue(true);
isRoundedDesign.mockReturnValue(true);
isShorts.mockReturnValue(false);
const buttons = document.querySelector('[data-watch-buttons="BBBBBBBBBBB"]');
getButtons.mockReturnValue(buttons);
getLikeButton.mockReturnValue(buttons.querySelector("like-button-view-model"));
getDislikeButton.mockReturnValue(buttons.querySelector("dislike-button-view-model"));
});
test("mutates only the selected current watch tree when stale duplicate IDs come first", () => {
const currentButtons = document.querySelector('[data-watch-buttons="BBBBBBBBBBB"]');
expect(currentButtons.closest("#top-row")).toBe(document.querySelector('[data-watch-row="BBBBBBBBBBB"]'));
expect(isNewDesign()).toBe(true);
createRateBar(100, 25);
const staleRow = document.querySelector('[data-watch-row="AAAAAAAAAAA"]');
const staleActionsInner = document.querySelector('[data-watch-actions-inner="AAAAAAAAAAA"]');
const staleActions = document.querySelector('[data-watch-actions="AAAAAAAAAAA"]');
const currentRow = document.querySelector('[data-watch-row="BBBBBBBBBBB"]');
const currentActionsInner = document.querySelector('[data-watch-actions-inner="BBBBBBBBBBB"]');
const currentActions = document.querySelector('[data-watch-actions="BBBBBBBBBBB"]');
expect(staleRow.style.borderBottom).toBe("");
expect(staleRow.style.paddingBottom).toBe("");
expect(staleActionsInner.style.width).toBe("999px");
expect(staleActions.style.flexDirection).toBe("");
expect(currentRow.style.paddingBottom).toBe("10px");
expect(currentActionsInner.style.width).toBe("revert");
expect(currentActions.style.flexDirection).toBe("row-reverse");
expect(document.querySelector('[data-watch-buttons="BBBBBBBBBBB"] > .ryd-tooltip')).not.toBeNull();
expect(document.querySelector('[data-watch-buttons="AAAAAAAAAAA"] > .ryd-tooltip')).toBeNull();
});
});
+117 -15
View File
@@ -1,5 +1,109 @@
import { isMobile, isShorts, extConfig } from "./state";
import { isInViewport, querySelector, querySelectorAll } from "./utils";
import { getVideoId, isInViewport, querySelector, querySelectorAll } from "./utils";
const buttonsVideoOwnership = new WeakMap();
function hasRenderedBox(element) {
if (!element?.isConnected || element.closest("[hidden], [aria-hidden='true'], [inert]")) {
return false;
}
for (let current = element; current; current = current.parentElement) {
const style = window.getComputedStyle(current);
if (
style.display === "none" ||
style.visibility === "hidden" ||
style.visibility === "collapse" ||
Number.parseFloat(style.opacity) === 0
) {
return false;
}
}
const rect = element.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
}
function intersectsViewport(element) {
const rect = element.getBoundingClientRect();
const height = innerHeight || document.documentElement.clientHeight;
const width = innerWidth || document.documentElement.clientWidth;
return (
rect.width > 0 && rect.height > 0 && rect.bottom > 0 && rect.right > 0 && rect.top < height && rect.left < width
);
}
function configuredMatches(selectors) {
const matches = [];
for (const selector of Array.isArray(selectors) ? selectors : [selectors]) {
if (selector) matches.push(...document.querySelectorAll(selector));
}
return matches;
}
function getDesktopWatchButtonCandidates() {
return Array.from(
new Set([
...configuredMatches(extConfig.selectors.buttons.regular.desktopMenu),
...configuredMatches(extConfig.selectors.buttons.regular.desktopNoMenu),
]),
).filter((candidate) => {
const segmented = querySelector(extConfig.selectors.buttons.segmentedContainer, candidate);
const like = querySelector(extConfig.selectors.buttons.likeButton.notSegmented, candidate);
return segmented !== undefined || like !== undefined;
});
}
function markButtonsForVideo(buttons, videoId) {
if (buttons && videoId) {
buttonsVideoOwnership.set(buttons, videoId);
}
}
function getButtonsVideoOwnership(candidate) {
return (
buttonsVideoOwnership.get(candidate) ??
candidate.getAttribute("video-id") ??
candidate.getAttribute("data-video-id")
);
}
function selectCurrentWatchButtons(candidates) {
const videoId = getVideoId(window.location.href);
const ranked = candidates
.map((candidate, index) => {
const watchRoot = candidate.closest("ytd-watch-flexy, ytd-watch-grid");
const rootVideoId = watchRoot?.getAttribute("video-id");
const rootMatches = Boolean(videoId && rootVideoId === videoId);
const rootConflicts = Boolean(videoId && rootVideoId && rootVideoId !== videoId);
const controlsVideoId = getButtonsVideoOwnership(candidate);
const controlsMatch = Boolean(videoId && controlsVideoId === videoId);
const controlsConflict = Boolean(videoId && controlsVideoId && controlsVideoId !== videoId);
const rendered = hasRenderedBox(candidate);
const inViewport = rendered && intersectsViewport(candidate);
const tier =
controlsMatch && inViewport
? 8
: rootMatches && inViewport
? 7
: inViewport
? 6
: controlsMatch && rendered
? 5
: rootMatches && rendered
? 4
: rendered
? 3
: controlsMatch || rootMatches
? 2
: 1;
return { candidate, controlsConflict, index, rootConflicts, tier };
})
// Do not bind outgoing controls to the destination video while YouTube is
// switching the current watch root during an SPA navigation.
.filter(({ rootConflicts }) => !rootConflicts);
const ownershipCompatible = ranked.filter(({ controlsConflict }) => !controlsConflict);
const selectionPool = ownershipCompatible.length > 0 ? ownershipCompatible : ranked;
return selectionPool.sort((left, right) => right.tier - left.tier || left.index - right.index)[0]?.candidate;
}
function getNativeButton(buttonContainer) {
return querySelector(extConfig.selectors.buttons.nativeButton, buttonContainer);
@@ -32,20 +136,15 @@ function getButtons() {
if (isMobile()) {
return document.querySelector(extConfig.selectors.buttons.regular.mobile);
}
//--- If Menu Element Is Displayed: ---//
if (querySelector(extConfig.selectors.menuContainer)?.offsetParent === null) {
return querySelector(extConfig.selectors.buttons.regular.desktopMenu);
//--- If Menu Element Isn't Displayed: ---//
} else {
return querySelector(extConfig.selectors.buttons.regular.desktopNoMenu);
}
return selectCurrentWatchButtons(getDesktopWatchButtonCandidates());
}
function getLikeButton() {
const buttons = getButtons();
return isSegmentedButtonLayout()
? querySelector(extConfig.selectors.buttons.likeButton.segmented) ??
querySelector(extConfig.selectors.buttons.likeButton.segmentedGetButtons, getButtons())
: querySelector(extConfig.selectors.buttons.likeButton.notSegmented, getButtons());
? querySelector(extConfig.selectors.buttons.likeButton.segmented, buttons) ??
querySelector(extConfig.selectors.buttons.likeButton.segmentedGetButtons, buttons)
: querySelector(extConfig.selectors.buttons.likeButton.notSegmented, buttons);
}
function getLikeTextContainer() {
@@ -53,21 +152,22 @@ function getLikeTextContainer() {
}
function getDislikeButton() {
const buttons = getButtons();
if (isSegmentedButtonLayout()) {
return (
querySelector(extConfig.selectors.buttons.dislikeButton.segmented) ??
querySelector(extConfig.selectors.buttons.dislikeButton.segmentedGetButtons, getButtons())
querySelector(extConfig.selectors.buttons.dislikeButton.segmented, buttons) ??
querySelector(extConfig.selectors.buttons.dislikeButton.segmentedGetButtons, buttons)
);
}
const notSegmentedMatch = querySelector(extConfig.selectors.buttons.dislikeButton.notSegmented, getButtons());
const notSegmentedMatch = querySelector(extConfig.selectors.buttons.dislikeButton.notSegmented, buttons);
if (notSegmentedMatch != null) {
return notSegmentedMatch;
}
if (isShorts()) {
return querySelector(extConfig.selectors.buttons.dislikeButton.shortsFallback, getButtons());
return querySelector(extConfig.selectors.buttons.dislikeButton.shortsFallback, buttons);
}
return null;
@@ -141,4 +241,6 @@ export {
getLikeTextContainer,
getDislikeTextContainer,
checkForSignInButton,
hasRenderedBox,
markButtonsForVideo,
};
+151
View File
@@ -0,0 +1,151 @@
/** @jest-environment jsdom */
const { extConfig } = require("./state");
const { getButtons, getDislikeButton, getLikeButton, hasRenderedBox, markButtonsForVideo } = require("./buttons");
function controls(id, videoId, attributes = "") {
return `
<ytd-menu-renderer class="ytd-watch-metadata" ${attributes}>
<div id="${id}" data-video-id="${videoId}">
<segmented-like-dislike-button-view-model>
<like-button-view-model id="segmented-like-button"><button aria-pressed="false"></button></like-button-view-model>
<dislike-button-view-model id="segmented-dislike-button"><button aria-pressed="false"></button></dislike-button-view-model>
</segmented-like-dislike-button-view-model>
</div>
</ytd-menu-renderer>`;
}
function setBox(element, { height = 48, width = 320, x = 20, y = 20 } = {}) {
element.getBoundingClientRect = () => ({
bottom: y + height,
height,
left: x,
right: x + width,
top: y,
width,
x,
y,
});
}
describe("desktop watch button ownership", () => {
beforeEach(() => {
history.replaceState({}, "", "/watch?v=BBBBBBBBBBB");
extConfig.selectors.buttons.regular.desktopMenu = ["ytd-menu-renderer.ytd-watch-metadata > div"];
extConfig.selectors.buttons.regular.desktopNoMenu = ["#top-level-buttons-computed"];
document.body.innerHTML = "";
});
test("prefers viewport-intersecting destination controls over a positive-size offscreen sibling in the same root", () => {
document.body.innerHTML = `
<ytd-watch-flexy video-id="BBBBBBBBBBB">
${controls("stale", "AAAAAAAAAAA")}
${controls("current", "BBBBBBBBBBB")}
</ytd-watch-flexy>`;
setBox(document.querySelector("#stale"), { x: -10_000 });
setBox(document.querySelector("#current"), { x: 40 });
expect(getButtons()).toBe(document.querySelector("#current"));
});
test("prefers unowned destination controls over initialized outgoing controls when both overlap in the current root", () => {
document.body.innerHTML = `
<ytd-watch-flexy video-id="BBBBBBBBBBB">
${controls("stale", "AAAAAAAAAAA")}
${controls("current", "BBBBBBBBBBB")}
</ytd-watch-flexy>`;
const stale = document.querySelector("#stale");
const current = document.querySelector("#current");
setBox(stale, { x: 40 });
setBox(current, { x: 40 });
markButtonsForVideo(stale, "AAAAAAAAAAA");
expect(getButtons()).toBe(current);
});
test("falls back to a reused marked control when it is the only current-root candidate", () => {
document.body.innerHTML = `
<ytd-watch-flexy video-id="BBBBBBBBBBB">
${controls("reused", "AAAAAAAAAAA")}
</ytd-watch-flexy>`;
const reused = document.querySelector("#reused");
setBox(reused, { x: 40 });
markButtonsForVideo(reused, "AAAAAAAAAAA");
expect(getButtons()).toBe(reused);
});
test("ignores hidden, transparent, and inert stale candidates before current controls", () => {
document.body.innerHTML = `
<ytd-watch-flexy video-id="BBBBBBBBBBB">
<div hidden>${controls("hidden-stale", "AAAAAAAAAAA")}</div>
<div style="opacity: 0">${controls("transparent-stale", "AAAAAAAAAAA")}</div>
<div inert>${controls("inert-stale", "AAAAAAAAAAA")}</div>
${controls("current", "BBBBBBBBBBB")}
</ytd-watch-flexy>`;
["#hidden-stale", "#transparent-stale", "#inert-stale", "#current"].forEach((selector) =>
setBox(document.querySelector(selector)),
);
expect(getButtons()).toBe(document.querySelector("#current"));
expect(hasRenderedBox(document.querySelector("#hidden-stale"))).toBe(false);
expect(hasRenderedBox(document.querySelector("#transparent-stale"))).toBe(false);
expect(hasRenderedBox(document.querySelector("#inert-stale"))).toBe(false);
});
test("does not bind rendered controls owned by an explicit outgoing video root", () => {
document.body.innerHTML = `
<ytd-watch-flexy video-id="AAAAAAAAAAA">
${controls("outgoing", "AAAAAAAAAAA")}
</ytd-watch-flexy>`;
setBox(document.querySelector("#outgoing"));
expect(getButtons()).toBeUndefined();
});
test("accepts fixed-position current controls even though they have no offset parent", () => {
document.body.innerHTML = `
<ytd-watch-flexy video-id="BBBBBBBBBBB">
${controls("current", "BBBBBBBBBBB", 'style="position: fixed"')}
</ytd-watch-flexy>`;
const current = document.querySelector("#current");
setBox(current);
expect(current.offsetParent).toBeNull();
expect(hasRenderedBox(current)).toBe(true);
expect(getButtons()).toBe(current);
});
test("uses an offscreen current-root candidate as a fallback when no viewport candidate exists", () => {
document.body.innerHTML = `
<ytd-watch-flexy video-id="BBBBBBBBBBB">
${controls("current", "BBBBBBBBBBB")}
</ytd-watch-flexy>`;
const current = document.querySelector("#current");
setBox(current, { x: -10_000 });
expect(hasRenderedBox(current)).toBe(true);
expect(getButtons()).toBe(current);
});
test("scopes segmented reaction controls to the selected destination container", () => {
const originalSegmentedContainer = extConfig.selectors.buttons.segmentedContainer;
extConfig.selectors.buttons.segmentedContainer = ["segmented-like-dislike-button-view-model"];
try {
document.body.innerHTML = `
<ytd-watch-flexy video-id="BBBBBBBBBBB">
${controls("stale", "AAAAAAAAAAA")}
${controls("current", "BBBBBBBBBBB")}
</ytd-watch-flexy>`;
setBox(document.querySelector("#stale"), { x: -10_000 });
setBox(document.querySelector("#current"), { x: 40 });
const current = document.querySelector("#current");
expect(getButtons()).toBe(current);
expect(getLikeButton()).toBe(current.querySelector("like-button-view-model"));
expect(getDislikeButton()).toBe(current.querySelector("dislike-button-view-model"));
} finally {
extConfig.selectors.buttons.segmentedContainer = originalSegmentedContainer;
}
});
});
+42 -66
View File
@@ -1,24 +1,25 @@
import { getBrowser, getVideoId, numberFormat, createObserver, querySelector } from "./utils";
import { checkForSignInButton, getButtons, getDislikeButton, getLikeButton } from "./buttons";
import {
NEUTRAL_STATE,
LIKED_STATE,
DISLIKED_STATE,
setDislikes,
extConfig,
storedData,
setLikes,
getLikeCountFromButton,
} from "./state";
import { setDislikes, extConfig, storedData, setLikes, getLikeCountFromButton } from "./state";
import { createRateBar } from "./bar";
import {
LIKE_ACTION,
DISLIKE_ACTION,
resolveVoteTransition,
applyVoteTransitionCounts,
shouldSubmitVote,
} from "../../common/vote-transition";
function sendVote(vote) {
if (extConfig.disableVoteSubmission !== true) {
getBrowser().runtime.sendMessage({
if (shouldSubmitVote({ disableVoteSubmission: extConfig.disableVoteSubmission })) {
const result = getBrowser().runtime.sendMessage({
message: "send_vote",
vote: vote,
videoId: getVideoId(window.location.href),
});
if (result && typeof result.catch === "function") {
result.catch((error) => console.error("Vote submission failed", error));
}
}
}
@@ -27,25 +28,16 @@ function updateDOMDislikes() {
createRateBar(storedData.likes, storedData.dislikes);
}
function likeClicked() {
function handleVoteAction(action) {
if (checkForSignInButton() === false) {
if (storedData.previousState === DISLIKED_STATE) {
sendVote(1);
if (storedData.dislikes > 0) storedData.dislikes--;
storedData.likes++;
updateDOMDislikes();
storedData.previousState = LIKED_STATE;
} else if (storedData.previousState === NEUTRAL_STATE) {
sendVote(1);
storedData.likes++;
updateDOMDislikes();
storedData.previousState = LIKED_STATE;
} else if ((storedData.previousState = LIKED_STATE)) {
sendVote(0);
if (storedData.likes > 0) storedData.likes--;
updateDOMDislikes();
storedData.previousState = NEUTRAL_STATE;
}
const transition = resolveVoteTransition(storedData.previousState, action);
const counts = applyVoteTransitionCounts(storedData.likes, storedData.dislikes, transition);
sendVote(transition.value);
storedData.likes = counts.likes;
storedData.dislikes = counts.dislikes;
storedData.previousState = transition.nextState;
updateDOMDislikes();
if (extConfig.numberDisplayReformatLikes === true) {
const nativeLikes = getLikeCountFromButton();
if (nativeLikes !== false) {
@@ -55,45 +47,29 @@ function likeClicked() {
}
}
function dislikeClicked() {
if (checkForSignInButton() == false) {
if (storedData.previousState === NEUTRAL_STATE) {
sendVote(-1);
storedData.dislikes++;
updateDOMDislikes();
storedData.previousState = DISLIKED_STATE;
} else if (storedData.previousState === DISLIKED_STATE) {
sendVote(0);
if (storedData.dislikes > 0) storedData.dislikes--;
updateDOMDislikes();
storedData.previousState = NEUTRAL_STATE;
} else if (storedData.previousState === LIKED_STATE) {
sendVote(-1);
if (storedData.likes > 0) storedData.likes--;
storedData.dislikes++;
updateDOMDislikes();
storedData.previousState = DISLIKED_STATE;
if (extConfig.numberDisplayReformatLikes === true) {
const nativeLikes = getLikeCountFromButton();
if (nativeLikes !== false) {
setLikes(numberFormat(nativeLikes));
}
}
}
}
function likeClicked() {
handleVoteAction(LIKE_ACTION);
}
function dislikeClicked() {
handleVoteAction(DISLIKE_ACTION);
}
const boundLikeButtons = new WeakSet();
const boundDislikeButtons = new WeakSet();
function addLikeDislikeEventListener() {
if (window.rydPreNavigateLikeButton !== getLikeButton()) {
getLikeButton().addEventListener("click", likeClicked);
getLikeButton().addEventListener("touchstart", likeClicked);
if (getDislikeButton()) {
getDislikeButton().addEventListener("click", dislikeClicked);
getDislikeButton().addEventListener("touchstart", dislikeClicked);
getDislikeButton().addEventListener("focusin", updateDOMDislikes);
getDislikeButton().addEventListener("focusout", updateDOMDislikes);
}
window.rydPreNavigateLikeButton = getLikeButton();
const likeButton = getLikeButton();
const dislikeButton = getDislikeButton();
if (likeButton && !boundLikeButtons.has(likeButton)) {
likeButton.addEventListener("click", likeClicked);
boundLikeButtons.add(likeButton);
}
if (dislikeButton && !boundDislikeButtons.has(dislikeButton)) {
dislikeButton.addEventListener("click", dislikeClicked);
dislikeButton.addEventListener("focusin", updateDOMDislikes);
dislikeButton.addEventListener("focusout", updateDOMDislikes);
boundDislikeButtons.add(dislikeButton);
}
}
@@ -0,0 +1,35 @@
function createInitializationCycleRunner(runCycle) {
if (typeof runCycle !== "function") {
throw new TypeError("An initialization cycle function is required.");
}
let activePromise = null;
let rerunRequested = false;
async function request() {
if (activePromise) {
rerunRequested = true;
return activePromise;
}
activePromise = (async () => {
do {
rerunRequested = false;
await runCycle();
} while (rerunRequested);
})();
try {
return await activePromise;
} finally {
activePromise = null;
}
}
return {
isRunning: () => activePromise !== null,
request,
};
}
export { createInitializationCycleRunner };
@@ -0,0 +1,58 @@
import { createInitializationCycleRunner } from "./initialization-cycle";
function deferred() {
let resolve;
const promise = new Promise((resolvePromise) => {
resolve = resolvePromise;
});
return { promise, resolve };
}
test("queues a fresh initialization when navigation arrives during an in-flight cycle", async () => {
const outgoing = deferred();
const calls = [];
const runner = createInitializationCycleRunner(async () => {
calls.push(calls.length === 0 ? "A" : "B");
if (calls.length === 1) await outgoing.promise;
});
const first = runner.request();
const navigation = runner.request();
expect(runner.isRunning()).toBe(true);
expect(calls).toEqual(["A"]);
outgoing.resolve();
await Promise.all([first, navigation]);
expect(calls).toEqual(["A", "B"]);
expect(runner.isRunning()).toBe(false);
});
test("coalesces repeated navigation signals into one pending cycle", async () => {
const outgoing = deferred();
const runCycle = jest.fn(async () => {
if (runCycle.mock.calls.length === 1) await outgoing.promise;
});
const runner = createInitializationCycleRunner(runCycle);
const first = runner.request();
const queued = [runner.request(), runner.request(), runner.request()];
outgoing.resolve();
await Promise.all([first, ...queued]);
expect(runCycle).toHaveBeenCalledTimes(2);
});
test("continues accepting independent cycles after a queued rerun", async () => {
const runner = createInitializationCycleRunner(jest.fn(async () => {}));
await runner.request();
await runner.request();
expect(runner.isRunning()).toBe(false);
});
test("rejects invalid cycle callbacks", () => {
expect(() => createInitializationCycleRunner()).toThrow(TypeError);
});
+45 -22
View File
@@ -11,9 +11,7 @@ import {
createObserver,
} from "./utils";
import { config, getApiEndpoint, DEV_API_URL, PROD_API_URL, isDevelopment } from "./config";
const LIKED_STATE = "LIKED_STATE";
const DISLIKED_STATE = "DISLIKED_STATE";
const NEUTRAL_STATE = "NEUTRAL_STATE";
import { LIKED_STATE, DISLIKED_STATE, NEUTRAL_STATE } from "../../common/vote-transition";
const DEFAULT_SELECTORS = {
dislikeTextContainer: [
@@ -140,6 +138,7 @@ let storedData = {
likes: 0,
dislikes: 0,
previousState: NEUTRAL_STATE,
videoId: null,
};
function isMobile() {
@@ -317,7 +316,10 @@ function processResponse(response, storedData) {
}
// Tells the user if the API is down
function displayError(error) {
function displayError(error, videoId = getVideoId(window.location.href)) {
if (getVideoId(window.location.href) !== videoId) {
return;
}
getDislikeTextContainer().innerText = localize("textTempUnavailable");
}
@@ -326,35 +328,54 @@ async function setState(storedData) {
window.__rydSetStateCalls = (window.__rydSetStateCalls || 0) + 1;
}
storedData.previousState = isVideoDisliked() ? DISLIKED_STATE : isVideoLiked() ? LIKED_STATE : NEUTRAL_STATE;
let statsSet = false;
console.log("Video is loaded. Adding buttons...");
let videoId = getVideoId(window.location.href);
let likeCount = getLikeCountFromButton() || null;
let response = await fetch(getApiEndpoint(`/votes?videoId=${videoId}&likeCount=${likeCount || ""}`), {
method: "GET",
headers: {
Accept: "application/json",
},
})
.then((response) => {
if (!response.ok) displayError(response.error);
return response;
})
.then((response) => response.json())
.catch(displayError);
const videoId = getVideoId(window.location.href);
const likeCount = getLikeCountFromButton() || null;
let response;
try {
const request = await fetch(getApiEndpoint(`/votes?videoId=${videoId}&likeCount=${likeCount || ""}`), {
method: "GET",
headers: {
Accept: "application/json",
},
});
if (!request.ok) {
displayError(request.error, videoId);
return;
}
response = await request.json();
} catch (error) {
displayError(error, videoId);
return;
}
console.log("response from api:");
console.log(JSON.stringify(response));
if (response !== undefined && !("traceId" in response) && !statsSet) {
processResponse(response, storedData);
if (getVideoId(window.location.href) !== videoId) {
return;
}
if (!response || typeof response !== "object" || "traceId" in response) {
displayError(response, videoId);
return;
}
processResponse(response, storedData);
storedData.videoId = videoId;
}
async function setInitialState() {
await setState(storedData);
}
function hasLoadedStateForVideo(videoId) {
return storedData.videoId === videoId;
}
function restoreCurrentState() {
storedData.previousState = isVideoDisliked() ? DISLIKED_STATE : isVideoLiked() ? LIKED_STATE : NEUTRAL_STATE;
setDislikes(numberFormat(storedData.dislikes));
createRateBar(storedData.likes, storedData.dislikes);
}
async function initExtConfig() {
initializeDisableVoteSubmission();
initializeDisableLogging();
@@ -508,4 +529,6 @@ export {
initExtConfig,
storedData,
isLikesDisabled,
hasLoadedStateForVideo,
restoreCurrentState,
};
+130
View File
@@ -0,0 +1,130 @@
/**
* @jest-environment jsdom
*/
jest.mock("./buttons", () => {
const dislikeTextContainer = {
innerText: "native dislike",
removeAttribute: jest.fn(),
};
const likeButton = {
classList: { contains: () => false },
native: { getAttribute: (name) => (name === "aria-label" ? "35 likes" : "false") },
};
const dislikeButton = {
classList: { contains: () => false },
native: { getAttribute: () => "false" },
};
return {
getButtons: () => ({ children: [] }),
getDislikeButton: () => dislikeButton,
getDislikeTextContainer: () => dislikeTextContainer,
getLikeButton: () => likeButton,
getLikeTextContainer: () => ({ innerText: "35" }),
};
});
jest.mock("./bar", () => ({
createRateBar: jest.fn(),
}));
jest.mock("./config", () => ({
DEV_API_URL: "https://example.test",
PROD_API_URL: "https://example.test",
config: {},
getApiEndpoint: (pathname) => `https://example.test${pathname}`,
isDevelopment: () => false,
}));
jest.mock("./utils", () => ({
createObserver: () => ({ observe: jest.fn() }),
getBrowser: () => undefined,
getColorFromTheme: () => undefined,
getVideoId: (url) => new URL(url).searchParams.get("v"),
initializeLogging: () => undefined,
localize: (key) => key,
numberFormat: (value) => String(value),
querySelector: (selectors, root) => root?.native ?? null,
}));
import { createRateBar } from "./bar";
import { getDislikeTextContainer } from "./buttons";
import { setState } from "./state";
const dislikeTextContainer = getDislikeTextContainer();
function deferred() {
let resolve;
let reject;
const promise = new Promise((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, reject, resolve };
}
beforeEach(() => {
dislikeTextContainer.innerText = "native dislike";
dislikeTextContainer.removeAttribute.mockClear();
createRateBar.mockClear();
global.fetch = jest.fn();
window.history.replaceState(null, "", "/watch?v=abcdefghijk");
});
afterAll(() => {
delete global.fetch;
});
test("a delayed outgoing request failure cannot overwrite the destination before its successful initialization", async () => {
const outgoing = deferred();
const destinationResponse = {
ok: true,
json: jest.fn(async () => ({ dislikes: 65, likes: 35, rating: 3.5 })),
};
fetch.mockReturnValueOnce(outgoing.promise).mockResolvedValueOnce(destinationResponse);
const testState = { dislikes: 0, likes: 0, previousState: "neutral", videoId: null };
const outgoingInitialization = setState(testState);
await Promise.resolve();
expect(fetch).toHaveBeenCalledTimes(1);
window.history.replaceState(null, "", "/watch?v=zyxwvutsrqp");
outgoing.reject(new TypeError("outgoing request failed"));
await outgoingInitialization;
expect(dislikeTextContainer.innerText).toBe("native dislike");
expect(createRateBar).not.toHaveBeenCalled();
await setState(testState);
expect(dislikeTextContainer.innerText).toBe("65");
expect(createRateBar).toHaveBeenCalledWith(35, 65);
expect(testState.videoId).toBe("zyxwvutsrqp");
});
test.each([
["non-2xx", { ok: false, error: new Error("unavailable") }],
[
"malformed JSON",
{
ok: true,
json: async () => {
throw new SyntaxError("invalid JSON");
},
},
],
])("a delayed outgoing %s response cannot write an error into the destination controls", async (name, response) => {
const outgoing = deferred();
fetch.mockReturnValueOnce(outgoing.promise);
const testState = { dislikes: 0, likes: 0, previousState: "neutral", videoId: null };
const initialization = setState(testState);
await Promise.resolve();
window.history.replaceState(null, "", "/watch?v=zyxwvutsrqp");
// Resolve the request only after navigation so every failure path exercises the stale-video guard.
outgoing.resolve(response);
await initialization;
expect(dislikeTextContainer.innerText).toBe("native dislike");
expect(createRateBar).not.toHaveBeenCalled();
});
@@ -0,0 +1,68 @@
function createStorageCaller(storageArea, getLastError = () => undefined) {
return function call(methodName, ...args) {
return new Promise((resolve, reject) => {
let settled = false;
const finish = (callback) => (value) => {
if (settled) return;
settled = true;
callback(value);
};
const resolveOnce = finish(resolve);
const rejectOnce = finish(reject);
const callback = (result) => {
const lastError = getLastError();
if (lastError) {
rejectOnce(new Error(lastError.message || String(lastError)));
} else {
resolveOnce(result);
}
};
try {
const maybePromise = storageArea[methodName](...args, callback);
if (maybePromise && typeof maybePromise.then === "function") {
maybePromise.then(resolveOnce, rejectOnce);
}
} catch (callbackError) {
try {
const maybePromise = storageArea[methodName](...args);
Promise.resolve(maybePromise).then(resolveOnce, rejectOnce);
} catch (promiseError) {
rejectOnce(promiseError ?? callbackError);
}
}
});
};
}
function createBrowserCredentialStore(storageArea, getLastError) {
if (!storageArea?.get || !storageArea?.set) {
throw new TypeError("A browser storage area is required");
}
const call = createStorageCaller(storageArea, getLastError);
return {
async load() {
const result = await call("get", ["userId", "registrationConfirmed"]);
if (!result?.userId || result.registrationConfirmed !== true) return null;
return { userId: result.userId, registrationConfirmed: true };
},
async save(credentials) {
await call("set", {
userId: credentials.userId,
registrationConfirmed: credentials.registrationConfirmed === true,
});
},
async clear() {
if (typeof storageArea.remove === "function") {
await call("remove", ["userId", "registrationConfirmed"]);
} else {
await call("set", { userId: null, registrationConfirmed: false });
}
},
};
}
export { createBrowserCredentialStore };
@@ -0,0 +1,80 @@
import { createBrowserCredentialStore } from "./vote-client-adapter";
describe("createBrowserCredentialStore", () => {
it("implements the credential contract with callback-based Chrome storage", async () => {
const values = {};
const storageArea = {
get: jest.fn((keys, callback) => {
callback(Object.fromEntries(keys.filter((key) => key in values).map((key) => [key, values[key]])));
}),
set: jest.fn((nextValues, callback) => {
Object.assign(values, nextValues);
callback();
}),
remove: jest.fn((keys, callback) => {
keys.forEach((key) => delete values[key]);
callback();
}),
};
const store = createBrowserCredentialStore(storageArea, () => undefined);
expect(await store.load()).toBeNull();
await store.save({ userId: "callback-user", registrationConfirmed: true });
expect(await store.load()).toEqual({ userId: "callback-user", registrationConfirmed: true });
expect(values).toEqual({ userId: "callback-user", registrationConfirmed: true });
await store.clear();
expect(values).toEqual({});
expect(storageArea.remove).toHaveBeenCalledWith(["userId", "registrationConfirmed"], expect.any(Function));
});
it("implements the same contract with Promise-based browser storage", async () => {
const values = { userId: "promise-user", registrationConfirmed: true };
const storageArea = {
get: jest.fn(async (keys) => Object.fromEntries(keys.map((key) => [key, values[key]]))),
set: jest.fn(async (nextValues) => Object.assign(values, nextValues)),
remove: jest.fn(async (keys) => keys.forEach((key) => delete values[key])),
};
const store = createBrowserCredentialStore(storageArea);
expect(await store.load()).toEqual({ userId: "promise-user", registrationConfirmed: true });
await store.save({ userId: "replacement-user", registrationConfirmed: true });
expect(values).toEqual({ userId: "replacement-user", registrationConfirmed: true });
await store.clear();
expect(await store.load()).toBeNull();
});
it("does not expose a partially confirmed legacy identity", async () => {
const storageArea = {
get: jest.fn(async () => ({ userId: "unconfirmed-user", registrationConfirmed: false })),
set: jest.fn(async () => undefined),
};
expect(await createBrowserCredentialStore(storageArea).load()).toBeNull();
});
it("clears through set when remove is unavailable", async () => {
const storageArea = {
get: jest.fn(async () => ({})),
set: jest.fn(async () => undefined),
};
await createBrowserCredentialStore(storageArea).clear();
expect(storageArea.set).toHaveBeenCalledWith({ userId: null, registrationConfirmed: false }, expect.any(Function));
});
it("propagates callback storage failures", async () => {
const storageArea = {
get: jest.fn((_keys, callback) => callback({})),
set: jest.fn((_values, callback) => callback()),
};
const store = createBrowserCredentialStore(storageArea, () => ({ message: "storage unavailable" }));
await expect(store.load()).rejects.toThrow("storage unavailable");
});
it("rejects an invalid storage adapter", () => {
expect(() => createBrowserCredentialStore()).toThrow(TypeError);
expect(() => createBrowserCredentialStore({ get() {} })).toThrow(TypeError);
});
});
+317
View File
@@ -0,0 +1,317 @@
const DEFAULT_API_BASE_URL = "https://returnyoutubedislikeapi.com";
const USER_ID_CHARSET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
const VALID_VIDEO_ID = /^[A-Za-z0-9_-]{11}$/;
const VALID_VOTE_VALUES = new Set([-1, 0, 1]);
class VoteClientError extends Error {
constructor(message, options = {}) {
super(message);
this.name = "VoteClientError";
this.status = options.status;
this.cause = options.cause;
}
}
function countLeadingZeroes(bytes, limit = Infinity) {
let zeroes = 0;
for (const originalValue of bytes) {
let value = originalValue;
if (value === 0) {
zeroes += 8;
} else {
let count = 1;
if (value >>> 4 === 0) {
count += 4;
value <<= 4;
}
if (value >>> 6 === 0) {
count += 2;
value <<= 2;
}
zeroes += count - (value >>> 7);
break;
}
if (zeroes >= limit) break;
}
return zeroes;
}
function generateUserId(cryptoImpl = globalThis.crypto, length = 36) {
if (!Number.isInteger(length) || length <= 0) {
throw new TypeError("User ID length must be a positive integer");
}
if (!cryptoImpl?.getRandomValues) {
throw new VoteClientError("Web Crypto random generation is unavailable");
}
const values = new Uint32Array(length);
cryptoImpl.getRandomValues(values);
let result = "";
for (const value of values) {
result += USER_ID_CHARSET[value % USER_ID_CHARSET.length];
}
return result;
}
function decodeBase64(value) {
if (typeof atob !== "function") {
throw new VoteClientError("Base64 decoding is unavailable");
}
return Uint8Array.from(atob(value), (character) => character.charCodeAt(0));
}
function encodeBase64(bytes) {
if (typeof btoa !== "function") {
throw new VoteClientError("Base64 encoding is unavailable");
}
return btoa(String.fromCharCode(...bytes));
}
async function solvePuzzle(puzzle, cryptoImpl = globalThis.crypto, maxAttempts) {
if (!puzzle || typeof puzzle.challenge !== "string" || !Number.isInteger(puzzle.difficulty)) {
throw new VoteClientError("The API returned an invalid puzzle");
}
if (!cryptoImpl?.subtle?.digest) {
throw new VoteClientError("Web Crypto hashing is unavailable");
}
const challenge = decodeBase64(puzzle.challenge);
if (challenge.length !== 16) {
throw new VoteClientError("The API returned an invalid puzzle challenge");
}
const attempts = maxAttempts ?? Math.pow(2, puzzle.difficulty) * 3;
if (!Number.isSafeInteger(attempts) || attempts <= 0) {
throw new VoteClientError("The puzzle attempt limit is invalid");
}
const buffer = new ArrayBuffer(20);
const byteView = new Uint8Array(buffer);
const integerView = new Uint32Array(buffer);
byteView.set(challenge, 4);
for (let counter = 0; counter < attempts; counter++) {
integerView[0] = counter;
const hash = await cryptoImpl.subtle.digest("SHA-512", buffer);
if (countLeadingZeroes(new Uint8Array(hash), puzzle.difficulty) >= puzzle.difficulty) {
return { solution: encodeBase64(byteView.slice(0, 4)) };
}
}
return null;
}
function isConfirmedCredential(value) {
return Boolean(value?.userId && value.registrationConfirmed === true);
}
function createVoteClient({
apiBaseUrl = DEFAULT_API_BASE_URL,
fetchImpl = globalThis.fetch?.bind(globalThis),
credentialStore,
cryptoImpl = globalThis.crypto,
puzzleAttempts = 2,
votePuzzleAttempts = 3,
} = {}) {
if (typeof fetchImpl !== "function") throw new TypeError("fetchImpl is required");
if (!credentialStore?.load || !credentialStore?.save || !credentialStore?.clear) {
throw new TypeError("credentialStore must provide load, save, and clear");
}
if (!Number.isInteger(puzzleAttempts) || puzzleAttempts <= 0) {
throw new TypeError("puzzleAttempts must be a positive integer");
}
if (!Number.isInteger(votePuzzleAttempts) || votePuzzleAttempts <= 0) {
throw new TypeError("votePuzzleAttempts must be a positive integer");
}
const baseUrl = apiBaseUrl.replace(/\/$/, "");
const voteQueues = new Map();
let registrationPromise = null;
let registrationIsForced = false;
async function readJson(response, operation) {
try {
return await response.json();
} catch (error) {
throw new VoteClientError(`${operation} returned invalid JSON`, { status: response.status, cause: error });
}
}
function isSuccessful(response) {
if (typeof response.ok === "boolean") return response.ok;
return response.status >= 200 && response.status < 300;
}
async function request(path, options, operation) {
let response;
try {
response = await fetchImpl(`${baseUrl}${path}`, options);
} catch (error) {
throw new VoteClientError(`${operation} request failed`, { cause: error });
}
if (!response || !Number.isInteger(response.status)) {
throw new VoteClientError(`${operation} returned an invalid response`);
}
return response;
}
async function postJson(path, body, operation) {
const response = await request(
path,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
},
operation,
);
return response;
}
async function registerNewCredential() {
const userId = generateUserId(cryptoImpl);
for (let attempt = 0; attempt < puzzleAttempts; attempt++) {
const path = `/puzzle/registration?userId=${encodeURIComponent(userId)}`;
const puzzleResponse = await request(
path,
{ method: "GET", headers: { Accept: "application/json" } },
"Registration puzzle",
);
if (!isSuccessful(puzzleResponse)) {
throw new VoteClientError("Registration puzzle request was rejected", { status: puzzleResponse.status });
}
const puzzle = await readJson(puzzleResponse, "Registration puzzle");
const solvedPuzzle = await solvePuzzle(puzzle, cryptoImpl);
if (!solvedPuzzle) continue;
const confirmResponse = await postJson(path, solvedPuzzle, "Registration confirmation");
if (!isSuccessful(confirmResponse)) {
throw new VoteClientError("Registration confirmation was rejected", { status: confirmResponse.status });
}
const confirmed = await readJson(confirmResponse, "Registration confirmation");
if (confirmed !== true) {
throw new VoteClientError("Registration confirmation failed");
}
const credential = { userId, registrationConfirmed: true };
await credentialStore.save(credential);
return { userId };
}
throw new VoteClientError("Unable to solve the registration puzzle");
}
async function ensureRegisteredInternal(force) {
if (force) {
await credentialStore.clear();
} else {
const credential = await credentialStore.load();
if (isConfirmedCredential(credential)) return { userId: credential.userId };
}
return registerNewCredential();
}
function trackRegistration(work, force) {
let trackedPromise;
trackedPromise = work.finally(() => {
if (registrationPromise === trackedPromise) {
registrationPromise = null;
registrationIsForced = false;
}
});
registrationPromise = trackedPromise;
registrationIsForced = force;
return trackedPromise;
}
function ensureRegistered(options = {}) {
const force = options.force === true;
if (!registrationPromise) {
return trackRegistration(ensureRegisteredInternal(force), force);
}
if (!force || registrationIsForced) return registrationPromise;
const pendingRegistration = registrationPromise;
return trackRegistration(
pendingRegistration.catch(() => undefined).then(() => ensureRegisteredInternal(true)),
true,
);
}
async function performVote(videoId, value, authenticationRetriesRemaining) {
let { userId } = await ensureRegistered();
for (let attempt = 0; attempt < votePuzzleAttempts; attempt++) {
const voteResponse = await postJson("/interact/vote", { userId, videoId, value }, "Vote submission");
if (voteResponse.status === 401) {
if (authenticationRetriesRemaining <= 0) {
throw new VoteClientError("Vote submission was unauthorized after re-registration", { status: 401 });
}
({ userId } = await ensureRegistered({ force: true }));
return performVote(videoId, value, authenticationRetriesRemaining - 1);
}
if (!isSuccessful(voteResponse)) {
throw new VoteClientError("Vote submission was rejected", { status: voteResponse.status });
}
const puzzle = await readJson(voteResponse, "Vote submission");
const solvedPuzzle = await solvePuzzle(puzzle, cryptoImpl);
if (!solvedPuzzle) continue;
const confirmResponse = await postJson(
"/interact/confirmVote",
{ ...solvedPuzzle, userId, videoId },
"Vote confirmation",
);
if (confirmResponse.status === 401) {
if (authenticationRetriesRemaining <= 0) {
throw new VoteClientError("Vote confirmation was unauthorized after re-registration", { status: 401 });
}
await ensureRegistered({ force: true });
return performVote(videoId, value, authenticationRetriesRemaining - 1);
}
if (!isSuccessful(confirmResponse)) {
throw new VoteClientError("Vote confirmation was rejected", { status: confirmResponse.status });
}
const confirmed = await readJson(confirmResponse, "Vote confirmation");
if (confirmed !== true) throw new VoteClientError("Vote confirmation failed");
return true;
}
throw new VoteClientError("Unable to solve the vote puzzle");
}
function submitVote(videoId, value) {
if (typeof videoId !== "string" || !VALID_VIDEO_ID.test(videoId)) {
return Promise.reject(new TypeError("videoId must be an 11-character YouTube video ID"));
}
if (!VALID_VOTE_VALUES.has(value)) {
return Promise.reject(new TypeError("value must be -1, 0, or 1"));
}
const previous = voteQueues.get(videoId) ?? Promise.resolve();
const current = previous.catch(() => undefined).then(() => performVote(videoId, value, 1));
voteQueues.set(videoId, current);
current.then(
() => {
if (voteQueues.get(videoId) === current) voteQueues.delete(videoId);
},
() => {
if (voteQueues.get(videoId) === current) voteQueues.delete(videoId);
},
);
return current;
}
return { ensureRegistered, submitVote };
}
export { DEFAULT_API_BASE_URL, VoteClientError, countLeadingZeroes, generateUserId, solvePuzzle, createVoteClient };
+720
View File
@@ -0,0 +1,720 @@
import { countLeadingZeroes, createVoteClient, generateUserId, solvePuzzle } from "./vote-client";
import { webcrypto } from "crypto";
const API_BASE_URL = "https://api.example";
const VIDEO_A = "dQw4w9WgXcQ";
const VIDEO_B = "abcdefghijk";
const CHALLENGE = Buffer.alloc(16, 7).toString("base64");
const ZERO_SOLUTION = "AAAAAA==";
const GENERATED_USER_ID = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghij";
function jsonResponse(body, status = 200) {
return {
ok: status >= 200 && status < 300,
status,
json: jest.fn().mockResolvedValue(body),
};
}
function invalidJsonResponse(status = 200) {
return {
ok: status >= 200 && status < 300,
status,
json: jest.fn().mockRejectedValue(new SyntaxError("invalid JSON")),
};
}
function deferred() {
let resolve;
let reject;
const promise = new Promise((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, resolve, reject };
}
async function waitUntil(predicate) {
for (let attempt = 0; attempt < 30; attempt++) {
if (predicate()) return;
await new Promise((resolve) => setTimeout(resolve, 0));
}
throw new Error("Timed out waiting for asynchronous vote-client work");
}
function createCredentialStore(initialCredentials = null) {
let credentials = initialCredentials ? { ...initialCredentials } : null;
return {
load: jest.fn(async () => (credentials ? { ...credentials } : null)),
save: jest.fn(async (nextCredentials) => {
credentials = { ...nextCredentials };
}),
clear: jest.fn(async () => {
credentials = null;
}),
peek: () => (credentials ? { ...credentials } : null),
};
}
function createCryptoImpl(hashFactory = () => new Uint8Array(64)) {
return {
getRandomValues: jest.fn((values) => {
for (let index = 0; index < values.length; index++) {
values[index] = index;
}
return values;
}),
subtle: {
digest: jest.fn(async (algorithm, value) => {
const hash = hashFactory(algorithm, new Uint8Array(value));
return hash.buffer.slice(hash.byteOffset, hash.byteOffset + hash.byteLength);
}),
},
};
}
function createClient({
fetchImpl,
credentialStore,
cryptoImpl = createCryptoImpl(),
puzzleAttempts,
votePuzzleAttempts,
} = {}) {
return createVoteClient({
apiBaseUrl: API_BASE_URL,
fetchImpl,
credentialStore,
cryptoImpl,
...(puzzleAttempts === undefined ? {} : { puzzleAttempts }),
...(votePuzzleAttempts === undefined ? {} : { votePuzzleAttempts }),
});
}
function registrationPuzzle() {
return { challenge: CHALLENGE, difficulty: 1 };
}
function votePuzzle() {
return { challenge: CHALLENGE, difficulty: 1 };
}
function parseBody(call) {
return JSON.parse(call[1].body);
}
describe("proof-of-work helpers", () => {
it.each([
[[], 0],
[[0x80], 0],
[[0x40], 1],
[[0x08], 4],
[[0x00, 0x01], 15],
[[0x00, 0x00, 0x10], 19],
])("counts leading zero bits in %p", (bytes, expected) => {
expect(countLeadingZeroes(Uint8Array.from(bytes))).toBe(expected);
});
it("stops once the requested leading-zero limit is reached", () => {
expect(countLeadingZeroes(Uint8Array.from([0x00, 0x00, 0xff]), 8)).toBe(8);
});
it("generates an alphanumeric ID of the requested length using injected crypto", () => {
const cryptoImpl = createCryptoImpl();
expect(generateUserId(cryptoImpl, 8)).toBe("ABCDEFGH");
expect(cryptoImpl.getRandomValues).toHaveBeenCalledTimes(1);
expect(cryptoImpl.getRandomValues.mock.calls[0][0]).toBeInstanceOf(Uint32Array);
});
it("rejects invalid ID lengths and unavailable random generation", () => {
expect(() => generateUserId(createCryptoImpl(), 0)).toThrow(TypeError);
expect(() => generateUserId({}, 36)).toThrow(/crypto|random/i);
});
it("solves the SHA-512 puzzle and returns the four-byte counter as base64", async () => {
let digestCall = 0;
const cryptoImpl = createCryptoImpl(() => {
digestCall++;
const hash = new Uint8Array(64);
hash[0] = digestCall === 1 ? 0xff : 0x0f;
return hash;
});
await expect(solvePuzzle({ challenge: CHALLENGE, difficulty: 4 }, cryptoImpl, 2)).resolves.toEqual({
solution: "AQAAAA==",
});
expect(cryptoImpl.subtle.digest).toHaveBeenCalledTimes(2);
expect(cryptoImpl.subtle.digest.mock.calls[0][0]).toBe("SHA-512");
expect(Array.from(new Uint8Array(cryptoImpl.subtle.digest.mock.calls[1][1]).slice(0, 4))).toEqual([1, 0, 0, 0]);
expect(Array.from(new Uint8Array(cryptoImpl.subtle.digest.mock.calls[1][1]).slice(4))).toEqual(
Array.from(Buffer.from(CHALLENGE, "base64")),
);
});
it("matches a known low-difficulty SHA-512 proof-of-work vector", async () => {
await expect(solvePuzzle({ challenge: CHALLENGE, difficulty: 8 }, webcrypto, 116)).resolves.toEqual({
solution: "cwAAAA==",
});
});
it("returns null when the hash-attempt bound is exhausted", async () => {
const cryptoImpl = createCryptoImpl(() => new Uint8Array(64).fill(0xff));
await expect(solvePuzzle({ challenge: CHALLENGE, difficulty: 1 }, cryptoImpl, 3)).resolves.toBeNull();
expect(cryptoImpl.subtle.digest).toHaveBeenCalledTimes(3);
});
});
describe("createVoteClient registration", () => {
it("validates required client dependencies and retry bounds", () => {
expect(() => createVoteClient()).toThrow(TypeError);
expect(() => createVoteClient({ fetchImpl: jest.fn(), credentialStore: {} })).toThrow(TypeError);
expect(() =>
createVoteClient({
fetchImpl: jest.fn(),
credentialStore: createCredentialStore(),
puzzleAttempts: 0,
}),
).toThrow(TypeError);
expect(() =>
createVoteClient({
fetchImpl: jest.fn(),
credentialStore: createCredentialStore(),
votePuzzleAttempts: 0,
}),
).toThrow(TypeError);
});
it("registers cold credentials with exact request shapes and persists the confirmed identity", async () => {
const credentialStore = createCredentialStore();
const fetchImpl = jest
.fn()
.mockResolvedValueOnce(jsonResponse(registrationPuzzle()))
.mockResolvedValueOnce(jsonResponse(true));
const client = createClient({ fetchImpl, credentialStore });
await expect(client.ensureRegistered()).resolves.toEqual({ userId: GENERATED_USER_ID });
expect(fetchImpl).toHaveBeenCalledTimes(2);
expect(fetchImpl.mock.calls[0]).toEqual([
`${API_BASE_URL}/puzzle/registration?userId=${GENERATED_USER_ID}`,
{ method: "GET", headers: { Accept: "application/json" } },
]);
expect(fetchImpl.mock.calls[1][0]).toBe(`${API_BASE_URL}/puzzle/registration?userId=${GENERATED_USER_ID}`);
expect(fetchImpl.mock.calls[1][1]).toMatchObject({
method: "POST",
headers: { "Content-Type": "application/json" },
});
expect(parseBody(fetchImpl.mock.calls[1])).toEqual({ solution: ZERO_SOLUTION });
expect(credentialStore.save).toHaveBeenCalledWith({
userId: GENERATED_USER_ID,
registrationConfirmed: true,
});
expect(credentialStore.peek()).toEqual({ userId: GENERATED_USER_ID, registrationConfirmed: true });
expect(credentialStore.clear).not.toHaveBeenCalled();
});
it("reuses warm confirmed credentials without making a registration request", async () => {
const credentials = { userId: "existing-user", registrationConfirmed: true };
const credentialStore = createCredentialStore(credentials);
const fetchImpl = jest.fn();
const client = createClient({ fetchImpl, credentialStore });
await expect(client.ensureRegistered()).resolves.toEqual({ userId: credentials.userId });
expect(fetchImpl).not.toHaveBeenCalled();
expect(credentialStore.save).not.toHaveBeenCalled();
expect(credentialStore.clear).not.toHaveBeenCalled();
});
it("reuses a persisted identity after client recreation", async () => {
const credentialStore = createCredentialStore();
const fetchImpl = jest
.fn()
.mockResolvedValueOnce(jsonResponse(registrationPuzzle()))
.mockResolvedValueOnce(jsonResponse(true));
await expect(createClient({ fetchImpl, credentialStore }).ensureRegistered()).resolves.toEqual({
userId: GENERATED_USER_ID,
});
await expect(createClient({ fetchImpl, credentialStore }).ensureRegistered()).resolves.toEqual({
userId: GENERATED_USER_ID,
});
expect(fetchImpl).toHaveBeenCalledTimes(2);
expect(credentialStore.save).toHaveBeenCalledTimes(1);
});
it("deduplicates concurrent cold registration", async () => {
const credentialStore = createCredentialStore();
const registrationResponse = deferred();
const fetchImpl = jest
.fn()
.mockImplementationOnce(() => registrationResponse.promise)
.mockResolvedValueOnce(jsonResponse(true));
const client = createClient({ fetchImpl, credentialStore });
const first = client.ensureRegistered();
const second = client.ensureRegistered();
await waitUntil(() => fetchImpl.mock.calls.length === 1);
registrationResponse.resolve(jsonResponse(registrationPuzzle()));
await expect(Promise.all([first, second])).resolves.toEqual([
{ userId: GENERATED_USER_ID },
{ userId: GENERATED_USER_ID },
]);
expect(fetchImpl).toHaveBeenCalledTimes(2);
expect(credentialStore.save).toHaveBeenCalledTimes(1);
});
it("bounds fresh registration challenges when no puzzle can be solved", async () => {
const credentialStore = createCredentialStore();
const cryptoImpl = createCryptoImpl(() => new Uint8Array(64).fill(0xff));
const fetchImpl = jest.fn().mockResolvedValue(jsonResponse(registrationPuzzle()));
const client = createClient({ fetchImpl, credentialStore, cryptoImpl, puzzleAttempts: 2 });
await expect(client.ensureRegistered()).rejects.toThrow(/registration|puzzle/i);
expect(fetchImpl).toHaveBeenCalledTimes(2);
expect(
fetchImpl.mock.calls.every(([url, options]) => url.includes("/puzzle/registration") && options.method === "GET"),
).toBe(true);
expect(cryptoImpl.subtle.digest).toHaveBeenCalledTimes(12);
expect(credentialStore.save).not.toHaveBeenCalled();
});
it("fails without persisting credentials when registration confirmation is false", async () => {
const credentialStore = createCredentialStore();
const fetchImpl = jest
.fn()
.mockResolvedValueOnce(jsonResponse(registrationPuzzle()))
.mockResolvedValueOnce(jsonResponse(false));
const client = createClient({ fetchImpl, credentialStore, puzzleAttempts: 1 });
await expect(client.ensureRegistered()).rejects.toThrow(/registration|confirm/i);
expect(credentialStore.save).not.toHaveBeenCalled();
});
it("surfaces registration HTTP failures and does not continue to confirmation", async () => {
const credentialStore = createCredentialStore();
const fetchImpl = jest.fn().mockResolvedValue(jsonResponse({ error: "unavailable" }, 503));
const client = createClient({ fetchImpl, credentialStore });
await expect(client.ensureRegistered()).rejects.toThrow(/503|registration/i);
expect(fetchImpl).toHaveBeenCalledTimes(1);
expect(credentialStore.save).not.toHaveBeenCalled();
});
it("surfaces registration network and malformed JSON failures without recursion", async () => {
const networkStore = createCredentialStore();
const networkFetch = jest.fn().mockRejectedValue(new Error("offline"));
await expect(
createClient({ fetchImpl: networkFetch, credentialStore: networkStore }).ensureRegistered(),
).rejects.toThrow(/request failed/i);
expect(networkFetch).toHaveBeenCalledTimes(1);
expect(networkStore.save).not.toHaveBeenCalled();
const malformedStore = createCredentialStore();
const malformedFetch = jest.fn().mockResolvedValue(invalidJsonResponse());
await expect(
createClient({ fetchImpl: malformedFetch, credentialStore: malformedStore }).ensureRegistered(),
).rejects.toThrow(/invalid JSON/i);
expect(malformedFetch).toHaveBeenCalledTimes(1);
expect(malformedStore.save).not.toHaveBeenCalled();
});
it("propagates credential load and save failures", async () => {
const loadFailureStore = createCredentialStore();
loadFailureStore.load.mockRejectedValue(new Error("load failed"));
const unusedFetch = jest.fn();
await expect(
createClient({ fetchImpl: unusedFetch, credentialStore: loadFailureStore }).ensureRegistered(),
).rejects.toThrow("load failed");
expect(unusedFetch).not.toHaveBeenCalled();
const saveFailureStore = createCredentialStore();
saveFailureStore.save.mockRejectedValue(new Error("save failed"));
const fetchImpl = jest
.fn()
.mockResolvedValueOnce(jsonResponse(registrationPuzzle()))
.mockResolvedValueOnce(jsonResponse(true));
await expect(createClient({ fetchImpl, credentialStore: saveFailureStore }).ensureRegistered()).rejects.toThrow(
"save failed",
);
expect(saveFailureStore.peek()).toBeNull();
});
});
describe("createVoteClient submission", () => {
it.each([null, undefined, "", "short", "way-too-long-video-id", "invalid$id"])(
"rejects invalid video ID %p before making a request",
async (videoId) => {
const credentialStore = createCredentialStore({ userId: "existing-user", registrationConfirmed: true });
const fetchImpl = jest.fn();
const client = createClient({ fetchImpl, credentialStore });
await expect(client.submitVote(videoId, 1)).rejects.toThrow(TypeError);
expect(fetchImpl).not.toHaveBeenCalled();
},
);
it.each([-2, 2, null, undefined, "1"])("rejects invalid vote value %p before making a request", async (value) => {
const credentialStore = createCredentialStore({ userId: "existing-user", registrationConfirmed: true });
const fetchImpl = jest.fn();
const client = createClient({ fetchImpl, credentialStore });
await expect(client.submitVote(VIDEO_A, value)).rejects.toThrow(TypeError);
expect(fetchImpl).not.toHaveBeenCalled();
});
it("submits and confirms a vote with exact payloads", async () => {
const credentialStore = createCredentialStore({ userId: "existing-user", registrationConfirmed: true });
const fetchImpl = jest
.fn()
.mockResolvedValueOnce(jsonResponse(votePuzzle()))
.mockResolvedValueOnce(jsonResponse(true));
const client = createClient({ fetchImpl, credentialStore });
await expect(client.submitVote(VIDEO_A, -1)).resolves.toBe(true);
expect(fetchImpl).toHaveBeenCalledTimes(2);
expect(fetchImpl.mock.calls[0][0]).toBe(`${API_BASE_URL}/interact/vote`);
expect(fetchImpl.mock.calls[0][1]).toMatchObject({
method: "POST",
headers: { "Content-Type": "application/json" },
});
expect(parseBody(fetchImpl.mock.calls[0])).toEqual({
userId: "existing-user",
videoId: VIDEO_A,
value: -1,
});
expect(fetchImpl.mock.calls[1][0]).toBe(`${API_BASE_URL}/interact/confirmVote`);
expect(fetchImpl.mock.calls[1][1]).toMatchObject({
method: "POST",
headers: { "Content-Type": "application/json" },
});
expect(parseBody(fetchImpl.mock.calls[1])).toEqual({
solution: ZERO_SOLUTION,
userId: "existing-user",
videoId: VIDEO_A,
});
});
it("shares one cold registration across concurrent first votes", async () => {
const credentialStore = createCredentialStore();
const registrationResponse = deferred();
const fetchImpl = jest.fn(async (url) => {
if (url.includes("/puzzle/registration")) {
if (fetchImpl.mock.calls.filter(([calledUrl]) => calledUrl.includes("/puzzle/registration")).length === 1) {
return registrationResponse.promise;
}
return jsonResponse(true);
}
if (url.endsWith("/interact/vote")) return jsonResponse(votePuzzle());
return jsonResponse(true);
});
const client = createClient({ fetchImpl, credentialStore });
const first = client.submitVote(VIDEO_A, 1);
const second = client.submitVote(VIDEO_B, -1);
await waitUntil(() => fetchImpl.mock.calls.length === 1);
registrationResponse.resolve(jsonResponse(registrationPuzzle()));
await expect(Promise.all([first, second])).resolves.toEqual([true, true]);
expect(fetchImpl.mock.calls.filter(([url]) => url.includes("/puzzle/registration"))).toHaveLength(2);
expect(credentialStore.save).toHaveBeenCalledTimes(1);
expect(
fetchImpl.mock.calls.filter(([url]) => url.endsWith("/interact/vote")).map((call) => parseBody(call).userId),
).toEqual([GENERATED_USER_ID, GENERATED_USER_ID]);
});
it("clears credentials, registers once, and retries once after a 401", async () => {
const credentialStore = createCredentialStore({ userId: "expired-user", registrationConfirmed: true });
const fetchImpl = jest
.fn()
.mockResolvedValueOnce(jsonResponse(null, 401))
.mockResolvedValueOnce(jsonResponse(registrationPuzzle()))
.mockResolvedValueOnce(jsonResponse(true))
.mockResolvedValueOnce(jsonResponse(votePuzzle()))
.mockResolvedValueOnce(jsonResponse(true));
const client = createClient({ fetchImpl, credentialStore });
await expect(client.submitVote(VIDEO_A, 1)).resolves.toBe(true);
expect(fetchImpl).toHaveBeenCalledTimes(5);
expect(credentialStore.clear).toHaveBeenCalledTimes(1);
expect(credentialStore.save).toHaveBeenCalledTimes(1);
expect(parseBody(fetchImpl.mock.calls[0])).toEqual({
userId: "expired-user",
videoId: VIDEO_A,
value: 1,
});
expect(fetchImpl.mock.calls[1][0]).toBe(`${API_BASE_URL}/puzzle/registration?userId=${GENERATED_USER_ID}`);
expect(parseBody(fetchImpl.mock.calls[3])).toEqual({
userId: GENERATED_USER_ID,
videoId: VIDEO_A,
value: 1,
});
expect(parseBody(fetchImpl.mock.calls[4])).toEqual({
solution: ZERO_SOLUTION,
userId: GENERATED_USER_ID,
videoId: VIDEO_A,
});
});
it("does not retry registration more than once when the retried vote is also unauthorized", async () => {
const credentialStore = createCredentialStore({ userId: "expired-user", registrationConfirmed: true });
const fetchImpl = jest
.fn()
.mockResolvedValueOnce(jsonResponse(null, 401))
.mockResolvedValueOnce(jsonResponse(registrationPuzzle()))
.mockResolvedValueOnce(jsonResponse(true))
.mockResolvedValueOnce(jsonResponse(null, 401));
const client = createClient({ fetchImpl, credentialStore });
await expect(client.submitVote(VIDEO_A, 0)).rejects.toThrow(/401|unauthorized/i);
expect(fetchImpl).toHaveBeenCalledTimes(4);
expect(credentialStore.clear).toHaveBeenCalledTimes(1);
expect(credentialStore.save).toHaveBeenCalledTimes(1);
});
it("honors forced re-registration while a normal credential read is in flight", async () => {
const expiredCredentials = { userId: "expired-user", registrationConfirmed: true };
const credentialStore = createCredentialStore(expiredCredentials);
const firstUnauthorizedVote = deferred();
let expiredVoteCount = 0;
const fetchImpl = jest.fn(async (url, options) => {
if (url.endsWith("/interact/vote")) {
const body = JSON.parse(options.body);
if (body.userId === expiredCredentials.userId) {
expiredVoteCount++;
return expiredVoteCount === 1 ? firstUnauthorizedVote.promise : jsonResponse(null, 401);
}
return jsonResponse(votePuzzle());
}
if (url.includes("/puzzle/registration")) {
return options.method === "GET" ? jsonResponse(registrationPuzzle()) : jsonResponse(true);
}
return jsonResponse(true);
});
const client = createClient({ fetchImpl, credentialStore });
const submission = client.submitVote(VIDEO_A, 1);
await waitUntil(() => expiredVoteCount === 1);
const pendingCredentialRead = deferred();
credentialStore.load.mockImplementationOnce(() => pendingCredentialRead.promise);
const ordinaryRegistration = client.ensureRegistered();
await waitUntil(() => credentialStore.load.mock.calls.length === 2);
firstUnauthorizedVote.resolve(jsonResponse(null, 401));
await new Promise((resolve) => setTimeout(resolve, 0));
expect(credentialStore.clear).not.toHaveBeenCalled();
pendingCredentialRead.resolve(expiredCredentials);
await expect(ordinaryRegistration).resolves.toEqual({ userId: expiredCredentials.userId });
await expect(submission).resolves.toBe(true);
expect(expiredVoteCount).toBe(1);
expect(credentialStore.clear).toHaveBeenCalledTimes(1);
expect(credentialStore.save).toHaveBeenCalledWith({
userId: GENERATED_USER_ID,
registrationConfirmed: true,
});
expect(fetchImpl.mock.calls.filter(([url]) => url.includes("/puzzle/registration"))).toHaveLength(2);
});
it("stops when clearing stale credentials fails after a 401", async () => {
const credentialStore = createCredentialStore({ userId: "expired-user", registrationConfirmed: true });
credentialStore.clear.mockRejectedValue(new Error("clear failed"));
const fetchImpl = jest.fn().mockResolvedValue(jsonResponse(null, 401));
const client = createClient({ fetchImpl, credentialStore });
await expect(client.submitVote(VIDEO_A, 1)).rejects.toThrow("clear failed");
expect(fetchImpl).toHaveBeenCalledTimes(1);
expect(credentialStore.save).not.toHaveBeenCalled();
});
it("uses a third fresh vote puzzle after the first two solvers are exhausted", async () => {
const credentialStore = createCredentialStore({ userId: "existing-user", registrationConfirmed: true });
let digestCall = 0;
const cryptoImpl = createCryptoImpl(() => {
digestCall++;
return new Uint8Array(64).fill(digestCall <= 12 ? 0xff : 0);
});
const fetchImpl = jest
.fn()
.mockResolvedValueOnce(jsonResponse(votePuzzle()))
.mockResolvedValueOnce(jsonResponse(votePuzzle()))
.mockResolvedValueOnce(jsonResponse(votePuzzle()))
.mockResolvedValueOnce(jsonResponse(true));
const client = createClient({ fetchImpl, credentialStore, cryptoImpl });
await expect(client.submitVote(VIDEO_A, 1)).resolves.toBe(true);
expect(fetchImpl).toHaveBeenCalledTimes(4);
expect(fetchImpl.mock.calls.map(([url]) => url)).toEqual([
`${API_BASE_URL}/interact/vote`,
`${API_BASE_URL}/interact/vote`,
`${API_BASE_URL}/interact/vote`,
`${API_BASE_URL}/interact/confirmVote`,
]);
expect(fetchImpl.mock.calls.slice(0, 3).map((call) => parseBody(call))).toEqual([
{ userId: "existing-user", videoId: VIDEO_A, value: 1 },
{ userId: "existing-user", videoId: VIDEO_A, value: 1 },
{ userId: "existing-user", videoId: VIDEO_A, value: 1 },
]);
expect(parseBody(fetchImpl.mock.calls[3])).toEqual({
solution: ZERO_SOLUTION,
userId: "existing-user",
videoId: VIDEO_A,
});
expect(cryptoImpl.subtle.digest).toHaveBeenCalledTimes(13);
});
it("bounds fresh vote challenges after all three puzzles are exhausted", async () => {
const credentialStore = createCredentialStore({ userId: "existing-user", registrationConfirmed: true });
const cryptoImpl = createCryptoImpl(() => new Uint8Array(64).fill(0xff));
const fetchImpl = jest.fn().mockResolvedValue(jsonResponse(votePuzzle()));
const client = createClient({ fetchImpl, credentialStore, cryptoImpl });
await expect(client.submitVote(VIDEO_A, 1)).rejects.toThrow(/vote|puzzle/i);
expect(fetchImpl).toHaveBeenCalledTimes(3);
expect(fetchImpl.mock.calls.every(([url]) => url === `${API_BASE_URL}/interact/vote`)).toBe(true);
expect(fetchImpl.mock.calls.map((call) => parseBody(call))).toEqual([
{ userId: "existing-user", videoId: VIDEO_A, value: 1 },
{ userId: "existing-user", videoId: VIDEO_A, value: 1 },
{ userId: "existing-user", videoId: VIDEO_A, value: 1 },
]);
expect(cryptoImpl.subtle.digest).toHaveBeenCalledTimes(18);
});
it("rejects a failed confirmation instead of reporting success", async () => {
const credentialStore = createCredentialStore({ userId: "existing-user", registrationConfirmed: true });
const fetchImpl = jest
.fn()
.mockResolvedValueOnce(jsonResponse(votePuzzle()))
.mockResolvedValueOnce(jsonResponse(false));
const client = createClient({ fetchImpl, credentialStore });
await expect(client.submitVote(VIDEO_A, 1)).rejects.toThrow(/confirm/i);
});
it("surfaces a non-authentication vote rejection without registering or confirming", async () => {
const credentialStore = createCredentialStore({ userId: "existing-user", registrationConfirmed: true });
const fetchImpl = jest.fn().mockResolvedValue(jsonResponse({ error: "unavailable" }, 503));
const client = createClient({ fetchImpl, credentialStore });
await expect(client.submitVote(VIDEO_A, 1)).rejects.toThrow(/503|rejected|submission/i);
expect(fetchImpl).toHaveBeenCalledTimes(1);
expect(credentialStore.clear).not.toHaveBeenCalled();
expect(credentialStore.save).not.toHaveBeenCalled();
});
it("surfaces a confirmation HTTP failure instead of reporting success", async () => {
const credentialStore = createCredentialStore({ userId: "existing-user", registrationConfirmed: true });
const fetchImpl = jest
.fn()
.mockResolvedValueOnce(jsonResponse(votePuzzle()))
.mockResolvedValueOnce(jsonResponse({ error: "unavailable" }, 503));
const client = createClient({ fetchImpl, credentialStore });
await expect(client.submitVote(VIDEO_A, -1)).rejects.toThrow(/503|confirm|rejected/i);
expect(fetchImpl).toHaveBeenCalledTimes(2);
});
it("surfaces vote and confirmation malformed JSON without retrying indefinitely", async () => {
const credentialStore = createCredentialStore({ userId: "existing-user", registrationConfirmed: true });
const malformedVoteFetch = jest.fn().mockResolvedValue(invalidJsonResponse());
await expect(
createClient({ fetchImpl: malformedVoteFetch, credentialStore }).submitVote(VIDEO_A, 1),
).rejects.toThrow(/invalid JSON/i);
expect(malformedVoteFetch).toHaveBeenCalledTimes(1);
const malformedConfirmationFetch = jest
.fn()
.mockResolvedValueOnce(jsonResponse(votePuzzle()))
.mockResolvedValueOnce(invalidJsonResponse());
await expect(
createClient({ fetchImpl: malformedConfirmationFetch, credentialStore }).submitVote(VIDEO_A, -1),
).rejects.toThrow(/invalid JSON/i);
expect(malformedConfirmationFetch).toHaveBeenCalledTimes(2);
});
it("surfaces vote and confirmation network failures without recursion", async () => {
const credentialStore = createCredentialStore({ userId: "existing-user", registrationConfirmed: true });
const voteNetworkFetch = jest.fn().mockRejectedValue(new Error("offline"));
await expect(createClient({ fetchImpl: voteNetworkFetch, credentialStore }).submitVote(VIDEO_A, 1)).rejects.toThrow(
/request failed/i,
);
expect(voteNetworkFetch).toHaveBeenCalledTimes(1);
const confirmationNetworkFetch = jest
.fn()
.mockResolvedValueOnce(jsonResponse(votePuzzle()))
.mockRejectedValueOnce(new Error("offline"));
await expect(
createClient({ fetchImpl: confirmationNetworkFetch, credentialStore }).submitVote(VIDEO_A, -1),
).rejects.toThrow(/request failed/i);
expect(confirmationNetworkFetch).toHaveBeenCalledTimes(2);
});
it("serializes submissions for the same video through confirmation", async () => {
const credentialStore = createCredentialStore({ userId: "existing-user", registrationConfirmed: true });
const firstConfirmation = deferred();
let confirmationCount = 0;
const fetchImpl = jest.fn(async (url) => {
if (url.endsWith("/interact/vote")) return jsonResponse(votePuzzle());
confirmationCount++;
return confirmationCount === 1 ? firstConfirmation.promise : jsonResponse(true);
});
const client = createClient({ fetchImpl, credentialStore });
const first = client.submitVote(VIDEO_A, 1);
const second = client.submitVote(VIDEO_A, -1);
await waitUntil(() => fetchImpl.mock.calls.some(([url]) => url.endsWith("/interact/confirmVote")));
expect(fetchImpl.mock.calls.filter(([url]) => url.endsWith("/interact/vote"))).toHaveLength(1);
firstConfirmation.resolve(jsonResponse(true));
await expect(Promise.all([first, second])).resolves.toEqual([true, true]);
expect(fetchImpl.mock.calls.map(([url]) => url.replace(API_BASE_URL, ""))).toEqual([
"/interact/vote",
"/interact/confirmVote",
"/interact/vote",
"/interact/confirmVote",
]);
expect(parseBody(fetchImpl.mock.calls[0]).value).toBe(1);
expect(parseBody(fetchImpl.mock.calls[2]).value).toBe(-1);
});
it("does not block a different video's submission behind an in-flight confirmation", async () => {
const credentialStore = createCredentialStore({ userId: "existing-user", registrationConfirmed: true });
const videoAConfirmation = deferred();
const fetchImpl = jest.fn(async (url, options) => {
const body = JSON.parse(options.body);
if (url.endsWith("/interact/vote")) return jsonResponse(votePuzzle());
return body.videoId === VIDEO_A ? videoAConfirmation.promise : jsonResponse(true);
});
const client = createClient({ fetchImpl, credentialStore });
const videoA = client.submitVote(VIDEO_A, 1);
const videoB = client.submitVote(VIDEO_B, -1);
await expect(videoB).resolves.toBe(true);
expect(
fetchImpl.mock.calls.some(([url, options]) => {
return url.endsWith("/interact/confirmVote") && JSON.parse(options.body).videoId === VIDEO_B;
}),
).toBe(true);
videoAConfirmation.resolve(jsonResponse(true));
await expect(videoA).resolves.toBe(true);
});
});
+57
View File
@@ -0,0 +1,57 @@
const LIKED_STATE = "LIKED_STATE";
const DISLIKED_STATE = "DISLIKED_STATE";
const NEUTRAL_STATE = "NEUTRAL_STATE";
const LIKE_ACTION = "like";
const DISLIKE_ACTION = "dislike";
const TRANSITIONS = {
[NEUTRAL_STATE]: {
[LIKE_ACTION]: { nextState: LIKED_STATE, value: 1, likesDelta: 1, dislikesDelta: 0 },
[DISLIKE_ACTION]: { nextState: DISLIKED_STATE, value: -1, likesDelta: 0, dislikesDelta: 1 },
},
[LIKED_STATE]: {
[LIKE_ACTION]: { nextState: NEUTRAL_STATE, value: 0, likesDelta: -1, dislikesDelta: 0 },
[DISLIKE_ACTION]: { nextState: DISLIKED_STATE, value: -1, likesDelta: -1, dislikesDelta: 1 },
},
[DISLIKED_STATE]: {
[LIKE_ACTION]: { nextState: LIKED_STATE, value: 1, likesDelta: 1, dislikesDelta: -1 },
[DISLIKE_ACTION]: { nextState: NEUTRAL_STATE, value: 0, likesDelta: 0, dislikesDelta: -1 },
},
};
function resolveVoteTransition(previousState, action) {
const transition = TRANSITIONS[previousState]?.[action];
if (!transition) {
throw new TypeError(`Unsupported vote transition: ${previousState} -> ${action}`);
}
return { ...transition };
}
function applyVoteTransitionCounts(likes, dislikes, transition) {
if (!transition || !Number.isFinite(transition.likesDelta) || !Number.isFinite(transition.dislikesDelta)) {
throw new TypeError("A valid vote transition is required");
}
const normalizedLikes = Number.isFinite(likes) ? likes : 0;
const normalizedDislikes = Number.isFinite(dislikes) ? dislikes : 0;
return {
likes: Math.max(0, normalizedLikes + transition.likesDelta),
dislikes: Math.max(0, normalizedDislikes + transition.dislikesDelta),
};
}
function shouldSubmitVote({ disableVoteSubmission = false, signedOut = false } = {}) {
return disableVoteSubmission !== true && signedOut !== true;
}
export {
LIKED_STATE,
DISLIKED_STATE,
NEUTRAL_STATE,
LIKE_ACTION,
DISLIKE_ACTION,
resolveVoteTransition,
applyVoteTransitionCounts,
shouldSubmitVote,
};
+101
View File
@@ -0,0 +1,101 @@
import {
LIKED_STATE,
DISLIKED_STATE,
NEUTRAL_STATE,
resolveVoteTransition,
applyVoteTransitionCounts,
shouldSubmitVote,
} from "./vote-transition";
describe("resolveVoteTransition", () => {
it("keeps the state constants compatible with the existing extension state", () => {
expect(LIKED_STATE).toBe("LIKED_STATE");
expect(DISLIKED_STATE).toBe("DISLIKED_STATE");
expect(NEUTRAL_STATE).toBe("NEUTRAL_STATE");
});
it.each([
{
description: "likes a neutral video",
previousState: NEUTRAL_STATE,
action: "like",
expected: { nextState: LIKED_STATE, value: 1, likesDelta: 1, dislikesDelta: 0 },
},
{
description: "removes an existing like",
previousState: LIKED_STATE,
action: "like",
expected: { nextState: NEUTRAL_STATE, value: 0, likesDelta: -1, dislikesDelta: 0 },
},
{
description: "changes a dislike to a like",
previousState: DISLIKED_STATE,
action: "like",
expected: { nextState: LIKED_STATE, value: 1, likesDelta: 1, dislikesDelta: -1 },
},
{
description: "dislikes a neutral video",
previousState: NEUTRAL_STATE,
action: "dislike",
expected: { nextState: DISLIKED_STATE, value: -1, likesDelta: 0, dislikesDelta: 1 },
},
{
description: "removes an existing dislike",
previousState: DISLIKED_STATE,
action: "dislike",
expected: { nextState: NEUTRAL_STATE, value: 0, likesDelta: 0, dislikesDelta: -1 },
},
{
description: "changes a like to a dislike",
previousState: LIKED_STATE,
action: "dislike",
expected: { nextState: DISLIKED_STATE, value: -1, likesDelta: -1, dislikesDelta: 1 },
},
])("$description", ({ previousState, action, expected }) => {
expect(resolveVoteTransition(previousState, action)).toEqual(expected);
});
it.each([undefined, null, "", "UNKNOWN_STATE", 1])("rejects invalid previous state %p", (previousState) => {
expect(() => resolveVoteTransition(previousState, "like")).toThrow(TypeError);
});
it.each([undefined, null, "", "LIKED_STATE", "upvote", 1])("rejects invalid action %p", (action) => {
expect(() => resolveVoteTransition(NEUTRAL_STATE, action)).toThrow(TypeError);
});
});
describe("applyVoteTransitionCounts", () => {
it("applies a transition without mutating it", () => {
const transition = resolveVoteTransition(NEUTRAL_STATE, "dislike");
expect(applyVoteTransitionCounts(10, 4, transition)).toEqual({ likes: 10, dislikes: 5 });
expect(transition).toEqual({ nextState: DISLIKED_STATE, value: -1, likesDelta: 0, dislikesDelta: 1 });
});
it.each([
[LIKED_STATE, "like", 0, 5, { likes: 0, dislikes: 5 }],
[DISLIKED_STATE, "dislike", 5, 0, { likes: 5, dislikes: 0 }],
[LIKED_STATE, "dislike", 0, 0, { likes: 0, dislikes: 1 }],
[DISLIKED_STATE, "like", 0, 0, { likes: 1, dislikes: 0 }],
])("never produces negative counts for %s -> %s", (state, action, likes, dislikes, expected) => {
expect(applyVoteTransitionCounts(likes, dislikes, resolveVoteTransition(state, action))).toEqual(expected);
});
it("normalizes non-finite counts and rejects malformed transitions", () => {
const transition = resolveVoteTransition(NEUTRAL_STATE, "like");
expect(applyVoteTransitionCounts(Number.NaN, Infinity, transition)).toEqual({ likes: 1, dislikes: 0 });
expect(() => applyVoteTransitionCounts(1, 1, null)).toThrow(TypeError);
});
});
describe("shouldSubmitVote", () => {
it.each([
[{}, true],
[{ disableVoteSubmission: false, signedOut: false }, true],
[{ disableVoteSubmission: true, signedOut: false }, false],
[{ disableVoteSubmission: false, signedOut: true }, false],
[{ disableVoteSubmission: true, signedOut: true }, false],
])("gates submission for %p", (options, expected) => {
expect(shouldSubmitVote(options)).toBe(expected);
});
});
+71
View File
@@ -0,0 +1,71 @@
# Shared extension/userscript browser contract
`shared-live-scenarios.js` publishes the ordered behavioral scenario IDs used by both runtimes. The runtime adapters
bind the selected runtime, version, and exact generated live-build ID into the existing live driver while retaining
runtime-specific ratio-bar, tooltip, credential-store, transport, and Shorts-control capabilities.
`hermetic-artifact-smoke.js` makes three shared scenarios executable against both generated artifacts and one
extension-specific stale-response race executable against the generated MV3 artifact:
- `watch-render` verifies the first rendered count and visible ratio bar.
- `watch-spa-side-panel` clicks the related-video link from A to B using non-proportional 90/10 and 35/65 fixtures.
It retains hidden copies of A's initialized controls both before and inside the new current root, replaces B's action
container during settling, and then requires exactly one B-owned count/bar/tooltip with no RYD bars left in any
retained tree. Readiness must remain valid for 300 ms, after which the exact invariant is sampled continuously for
another second. The result includes first-valid latency and sample counts.
- `watch-spa-dislike-activation` reuses that exact A-to-B replacement setup, clicks the one visible B-owned Dislike
button once, and requires one ordered `/interact/vote` plus `/interact/confirmVote` chain for B with value `-1` and
the same confirmed 36-character user ID. Confirmation must return HTTP 200 with the literal JSON value `true`, and
the exact one-vote/one-confirmation invariant must remain stable for another second so a duplicated listener fails.
The userscript chain is observed through its routed fake backend; the extension chain is observed at the loopback MV3
background server. Both paths reject unexpected or escaped traffic and page errors, unhandled rejections, and console
errors.
- `extension-watch-spa-delayed-outgoing-failure` holds A's `/votes` response, navigates to B, releases A as malformed,
and requires B to initialize exactly once without ever showing A's error or data. This proves that navigation queued
during an in-flight initialization is not swallowed and that stale failures are as harmless as stale successes.
```powershell
node Extensions/e2e/hermetic-artifact-smoke.js
```
The userscript is injected into an isolated context with GM shims. The extension is loaded as a real unpacked MV3
artifact in an isolated persistent Chromium profile, and the smoke requires its service worker to start.
## Extension API safety
The extension background eagerly registers as soon as its service worker starts. That can happen before Playwright can
install a context route, so routing the production origin after `launchPersistentContext()` is not a safe hermetic
strategy.
The adapter therefore starts a loopback fake API first, copies the current `dist/chrome` artifact into an owned OS
temporary directory, replaces the exact production origin in the background bundle, and adds only the corresponding
loopback host permission. It refuses non-loopback origins or a missing replacement point. The content-script bundle is
left byte-for-byte intact: Chromium blocks HTTPS-page content scripts from reaching loopback under Private Network
Access, so a BrowserContext route fulfills its normal API-origin requests instead. That route is installed before any
YouTube page is created or navigated. The temporary background bundle also replaces the exact first-install changelog
listener with a no-op so a fresh profile cannot open the changelog and request its remote font before the catch-all is
ready. A missing exact listener fails preparation. The tracked build output is never modified. Both the derived
artifact and its browser profile are removed after the run.
A future dedicated Webpack test build can replace this derivation with the following equivalent hook:
1. Define an API-base compile constant only when a hermetic-build flag is enabled.
2. Reject a hermetic API base whose parsed hostname is not `127.0.0.1`, `::1`, or `localhost`.
3. Use that constant from `Extensions/combined/src/config.js` for both background and content-script bundles.
4. Add the loopback host match to the generated test manifest only; never to release manifests.
5. Start the fake server before building so its allocated origin is compiled into the artifact, then launch the
persistent context.
The artifact smoke is part of `npm run test:e2e:systematic` and `npm run test:all`. Its colocated Jest tests cover the
shared catalog, runtime binding, capability validation, loopback guard, derived-artifact transformation, signal
collection, and cleanup behavior. The browser run rejects stale/current ownership mistakes, missed initialization,
duplicate activation, unexpected traffic, console errors, page errors, unhandled rejections, and a missing auxiliary
extension script.
## Authenticated live-build identity
The authenticated Brave suite is intentionally separate from this hermetic runner. `build:live:userscript` and
`build:live:extension` generate a fresh 32-character build ID, compile it into the page marker, and write the same ID to
the runtime's `live-build.json`. The live suite reads that file and requires an exact marker match before every shared
scenario. Rebuilding without reinstalling or reloading the runtime therefore fails even when the semantic version did
not change. See `Extensions/UserScript/e2e/live/README.md` for the complete preparation and safety contract.
+122
View File
@@ -0,0 +1,122 @@
function defaultNow() {
return Date.now();
}
function defaultSleep(milliseconds) {
return new Promise((resolve) => setTimeout(resolve, milliseconds));
}
function validateTiming({ intervalMs, stableForMs, timeoutMs }) {
for (const [name, value] of Object.entries({ intervalMs, stableForMs, timeoutMs })) {
if (!Number.isFinite(value) || value < 0) {
throw new TypeError(`${name} must be a non-negative finite number.`);
}
}
if (intervalMs === 0) throw new TypeError("intervalMs must be greater than zero.");
if (stableForMs > timeoutMs) throw new TypeError("stableForMs cannot exceed timeoutMs.");
}
function compactSamples(samples, limit = 12) {
const selected = samples.length <= limit ? samples : [...samples.slice(0, 3), ...samples.slice(-(limit - 3))];
return selected.map(({ elapsedMs, ok, value }) => ({ elapsedMs, ok, value }));
}
async function waitForStableInvariant({
intervalMs = 50,
isValid,
label,
now = defaultNow,
read,
sleep = defaultSleep,
stableForMs = 500,
timeoutMs = 5_000,
}) {
if (typeof read !== "function") throw new TypeError("read must be a function.");
if (typeof isValid !== "function") throw new TypeError("isValid must be a function.");
if (typeof now !== "function") throw new TypeError("now must be a function.");
if (typeof sleep !== "function") throw new TypeError("sleep must be a function.");
if (typeof label !== "string" || label.trim() === "") throw new TypeError("label must be a non-empty string.");
validateTiming({ intervalMs, stableForMs, timeoutMs });
const startedAt = now();
let firstValidMs = null;
let stableSince = null;
let invalidSamples = 0;
const samples = [];
while (true) {
const value = await read();
const sampledAt = now();
const elapsedMs = sampledAt - startedAt;
const ok = Boolean(await isValid(value));
samples.push({ elapsedMs, ok, value });
if (ok) {
if (firstValidMs === null) firstValidMs = elapsedMs;
if (stableSince === null) stableSince = sampledAt;
if (sampledAt - stableSince >= stableForMs) {
return {
elapsedMs,
firstValidMs,
invalidSamples,
sampleCount: samples.length,
stableForMs: sampledAt - stableSince,
value,
};
}
} else {
invalidSamples += 1;
stableSince = null;
}
if (elapsedMs >= timeoutMs) {
const error = new Error(
`${label} did not remain valid for ${stableForMs}ms within ${timeoutMs}ms. ` +
`Samples: ${JSON.stringify(compactSamples(samples))}`,
);
error.samples = samples;
throw error;
}
await sleep(Math.min(intervalMs, timeoutMs - elapsedMs));
}
}
async function assertInvariantContinuously({
durationMs = 1_000,
intervalMs = 50,
isValid,
label,
now = defaultNow,
read,
sleep = defaultSleep,
}) {
if (typeof read !== "function") throw new TypeError("read must be a function.");
if (typeof isValid !== "function") throw new TypeError("isValid must be a function.");
if (typeof label !== "string" || label.trim() === "") throw new TypeError("label must be a non-empty string.");
validateTiming({ intervalMs, stableForMs: durationMs, timeoutMs: durationMs });
const startedAt = now();
const samples = [];
while (true) {
const value = await read();
const elapsedMs = now() - startedAt;
const ok = Boolean(await isValid(value));
samples.push({ elapsedMs, ok, value });
if (!ok) {
const error = new Error(
`${label} became invalid after ${elapsedMs}ms. Samples: ${JSON.stringify(compactSamples(samples))}`,
);
error.samples = samples;
throw error;
}
if (elapsedMs >= durationMs) {
return { elapsedMs, sampleCount: samples.length, value };
}
await sleep(Math.min(intervalMs, durationMs - elapsedMs));
}
}
module.exports = {
assertInvariantContinuously,
waitForStableInvariant,
};
@@ -0,0 +1,92 @@
const { assertInvariantContinuously, waitForStableInvariant } = require("./continuous-invariants");
function createClock() {
let time = 0;
return {
now: () => time,
sleep: async (milliseconds) => {
time += milliseconds;
},
};
}
describe("continuous browser invariants", () => {
test("reports readiness latency and requires an uninterrupted stable window", async () => {
const clock = createClock();
const values = [false, false, true, true, false, true, true, true];
let index = 0;
const result = await waitForStableInvariant({
intervalMs: 100,
isValid: Boolean,
label: "ratio bar",
now: clock.now,
read: async () => values[Math.min(index++, values.length - 1)],
sleep: clock.sleep,
stableForMs: 200,
timeoutMs: 1_000,
});
expect(result).toMatchObject({
elapsedMs: 700,
firstValidMs: 200,
invalidSamples: 3,
sampleCount: 8,
stableForMs: 200,
value: true,
});
});
test("includes sampled phase evidence when stability never arrives", async () => {
const clock = createClock();
let phase = "missing";
await expect(
waitForStableInvariant({
intervalMs: 100,
isValid: (sample) => sample.phase === "ready",
label: "destination ownership",
now: clock.now,
read: async () => ({ phase: (phase = phase === "missing" ? "ready" : "missing") }),
sleep: clock.sleep,
stableForMs: 200,
timeoutMs: 400,
}),
).rejects.toThrow(/destination ownership.*Samples:.*phase/);
});
test("continuous assertion rejects an invalid first sample", async () => {
const clock = createClock();
let calls = 0;
await expect(
assertInvariantContinuously({
durationMs: 200,
intervalMs: 100,
isValid: Boolean,
label: "settled watch UI",
now: clock.now,
read: async () => calls++ > 0,
sleep: clock.sleep,
}),
).rejects.toThrow(/settled watch UI became invalid after 0ms/);
});
test.each([
["negative timeout", { timeoutMs: -1 }],
["zero interval", { intervalMs: 0 }],
["stable duration beyond timeout", { stableForMs: 101, timeoutMs: 100 }],
])("rejects %s", async (_name, timing) => {
await expect(
waitForStableInvariant({
intervalMs: 10,
isValid: Boolean,
label: "invalid timing",
read: async () => true,
stableForMs: 10,
timeoutMs: 100,
...timing,
}),
).rejects.toThrow();
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,544 @@
const fs = require("node:fs");
const { EventEmitter } = require("node:events");
const os = require("node:os");
const path = require("node:path");
const {
ARTIFACT_SMOKE_SCENARIO_ID,
ARTIFACT_WATCH_SPA_SCENARIO_ID,
ARTIFACT_WATCH_SPA_VOTE_SCENARIO_ID,
SHARED_ARTIFACT_SCENARIO_IDS,
assertLoopbackOrigin,
createPageSignalCollector,
isArtifactVoteHandshakeValid,
isSpaDestinationValid,
prepareHermeticExtensionArtifact,
readArtifactVoteHandshake,
runArtifactWatchRenderScenario,
runArtifactWatchSpaScenario,
runArtifactWatchSpaVoteScenario,
} = require("./hermetic-artifact-smoke");
const PRODUCTION_API_ORIGIN = "https://returnyoutubedislikeapi.com";
const temporaryDirectories = [];
function createExtensionFixture() {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "ryd-mv3-source-fixture-"));
temporaryDirectories.push(directory);
fs.writeFileSync(
path.join(directory, "manifest.json"),
JSON.stringify({ host_permissions: ["*://returnyoutubedislikeapi.com/*"], manifest_version: 3 }),
);
fs.writeFileSync(
path.join(directory, "ryd.background.js"),
`fetch("${PRODUCTION_API_ORIGIN}/register")\napi.runtime.onInstalled.addListener((details) => {\n maybeShowChangelog(details);\n});`,
);
fs.writeFileSync(path.join(directory, "ryd.content-script.js"), `fetch("${PRODUCTION_API_ORIGIN}/votes")`);
fs.writeFileSync(path.join(directory, "menu-fixer.js"), "document.documentElement.dataset.menuFixerLoaded = 'true';");
return directory;
}
afterEach(() => {
for (const directory of temporaryDirectories.splice(0)) {
fs.rmSync(directory, { force: true, recursive: true });
}
});
test("the artifact smoke is the shared watch-render scenario", () => {
expect(ARTIFACT_SMOKE_SCENARIO_ID).toBe("watch-render");
expect(ARTIFACT_WATCH_SPA_SCENARIO_ID).toBe("watch-spa-side-panel");
expect(ARTIFACT_WATCH_SPA_VOTE_SCENARIO_ID).toBe("watch-spa-dislike-activation");
expect(SHARED_ARTIFACT_SCENARIO_IDS).toEqual([
"watch-render",
"watch-spa-side-panel",
"watch-spa-dislike-activation",
]);
});
function validSpaSnapshot() {
const outgoing = {
barCount: 0,
containerCount: 0,
controlVideoIds: ["abcdefghijk"],
hidden: true,
present: true,
wrapperCount: 0,
};
return {
actionHostCount: 1,
barOwnedByDestination: true,
containerOwnedByDestination: true,
count: "65",
currentVideoId: "zyxwvutsrqp",
destinationBarCount: 1,
destinationContainerCount: 1,
destinationControlCount: 1,
destinationWrapperCount: 1,
fillRatio: 0.35,
globalBarCount: 1,
globalContainerCount: 1,
globalWrapperCount: 1,
insideOutgoing: { ...outgoing },
retainedBefore: { ...outgoing },
retainedDestination: { ...outgoing, controlVideoIds: ["zyxwvutsrqp"] },
tooltipText: "35 / 65",
urlVideoId: "zyxwvutsrqp",
visibleContainer: true,
visibleFill: true,
};
}
const ARTIFACT_USER_ID = "A".repeat(36);
function validVoteHandshake(change = {}) {
return {
confirmation: {
body: { solution: "AAAAAA==", userId: ARTIFACT_USER_ID, videoId: "zyxwvutsrqp" },
responded: true,
responseBody: true,
responseStatus: 200,
},
confirmationCount: 1,
expectedValue: -1,
expectedVideoId: "zyxwvutsrqp",
interactionCount: 2,
interactionPaths: ["/interact/vote", "/interact/confirmVote"],
sharedUserId: ARTIFACT_USER_ID,
vote: {
body: { userId: ARTIFACT_USER_ID, value: -1, videoId: "zyxwvutsrqp" },
},
voteCount: 1,
...change,
};
}
test.each(["http://127.0.0.1:43127", "http://localhost:43127", "http://[::1]:43127"])(
"accepts a loopback-only API origin: %s",
(origin) => {
expect(assertLoopbackOrigin(origin)).toBe(origin);
},
);
test.each(["https://returnyoutubedislikeapi.com", "https://api.example.test", "http://192.168.1.20:43127"])(
"rejects a non-loopback API origin: %s",
(origin) => {
expect(() => assertLoopbackOrigin(origin)).toThrow("Refusing to prepare a hermetic extension artifact");
},
);
test("redirects eager MV3 background traffic while leaving routed content-script traffic intact", () => {
const sourceDirectory = createExtensionFixture();
const prepared = prepareHermeticExtensionArtifact(sourceDirectory, "http://127.0.0.1:43127");
temporaryDirectories.push(prepared.temporaryRoot);
expect(prepared.replacements).toEqual({ "ryd.background.js": 1, firstInstallChangelogListener: 1 });
expect(prepared.routedBundles).toEqual(["ryd.content-script.js"]);
expect(fs.readFileSync(path.join(prepared.extensionDirectory, "ryd.background.js"), "utf8")).toContain(
"http://127.0.0.1:43127/register",
);
expect(fs.readFileSync(path.join(prepared.extensionDirectory, "ryd.background.js"), "utf8")).toContain(
"api.runtime.onInstalled.addListener(() => {});",
);
expect(fs.readFileSync(path.join(prepared.extensionDirectory, "ryd.content-script.js"), "utf8")).toContain(
`${PRODUCTION_API_ORIGIN}/votes`,
);
expect(fs.readFileSync(path.join(prepared.extensionDirectory, "menu-fixer.js"), "utf8")).toContain("menuFixerLoaded");
expect(JSON.parse(fs.readFileSync(path.join(prepared.extensionDirectory, "manifest.json"), "utf8"))).toMatchObject({
host_permissions: expect.arrayContaining(["http://127.0.0.1/*"]),
manifest_version: 3,
});
expect(fs.readFileSync(path.join(sourceDirectory, "ryd.background.js"), "utf8")).toContain(PRODUCTION_API_ORIGIN);
});
test("rejects an extension artifact whose injected auxiliary script was dropped by the build", () => {
const sourceDirectory = createExtensionFixture();
fs.rmSync(path.join(sourceDirectory, "menu-fixer.js"));
expect(() => prepareHermeticExtensionArtifact(sourceDirectory, "http://127.0.0.1:43127")).toThrow(
/missing menu-fixer\.js/,
);
});
test.each(["userscript", "extension"])("runs one shared artifact scenario contract for %s", async (runtime) => {
const events = [];
const adapter = {
assertNoPageSignals: jest.fn(async (scenarioId) => events.push(`signals:${scenarioId}`)),
close: jest.fn(async () => events.push("close")),
openWatch: jest.fn(async (videoId) => events.push(`open:${videoId}`)),
runtime,
start: jest.fn(async () => events.push("start")),
waitForWatchResult: jest.fn(async (videoId) => ({
count: "25",
fillVisible: true,
rateBarVisible: true,
videoId,
})),
};
await expect(runArtifactWatchRenderScenario(adapter, { videoId: "abcdefghijk" })).resolves.toMatchObject({
count: "25",
runtime,
scenarioId: "watch-render",
videoId: "abcdefghijk",
});
expect(events).toEqual(["start", "open:abcdefghijk", "signals:watch-render", "close"]);
});
test.each(["userscript", "extension"])("runs the same continuous A-to-B SPA contract for %s", async (runtime) => {
const events = [];
const adapter = {
assertNoPageSignals: jest.fn(async (scenarioId) => events.push(`signals:${scenarioId}`)),
assertSpaNetwork: jest.fn(async () => ({ fromVideoRequests: 1, interactionRequests: 0, toVideoRequests: 1 })),
close: jest.fn(async () => events.push("close")),
navigateSpaWatch: jest.fn(async () => ({
destination: { destinationReplaced: true },
outgoing: { beforeBarCount: 1 },
})),
openSpaWatch: jest.fn(async (videoId) => events.push(`open:${videoId}`)),
readSpaWatchSnapshot: jest.fn(async () => validSpaSnapshot()),
runtime,
start: jest.fn(async () => events.push("start")),
waitForWatchResult: jest.fn(async (videoId) => ({ count: "10", fillRatio: 0.9, videoId })),
};
await expect(
runArtifactWatchSpaScenario(adapter, {
intervalMs: 1,
stabilityDurationMs: 0,
stableForMs: 0,
timeoutMs: 1,
}),
).resolves.toMatchObject({
destination: { count: "65", fillRatio: 0.35, videoId: "zyxwvutsrqp" },
initial: { count: "10", fillRatio: 0.9, videoId: "abcdefghijk" },
readiness: { maxFirstValidMs: 1_000 },
runtime,
scenarioId: "watch-spa-side-panel",
traffic: { fromVideoRequests: 1, interactionRequests: 0, toVideoRequests: 1 },
});
expect(adapter.navigateSpaWatch).toHaveBeenCalledWith("abcdefghijk", "zyxwvutsrqp");
expect(events).toEqual(["start", "open:abcdefghijk", "signals:watch-spa-side-panel", "close"]);
});
test("recognizes only one ordered, successful destination dislike handshake", () => {
const records = [
{ method: "POST", pathname: "/puzzle/registration" },
{
body: { userId: ARTIFACT_USER_ID, value: -1, videoId: "zyxwvutsrqp" },
method: "POST",
pathname: "/interact/vote",
},
{
body: { solution: "AAAAAA==", userId: ARTIFACT_USER_ID, videoId: "zyxwvutsrqp" },
method: "POST",
pathname: "/interact/confirmVote",
respondedAt: 123,
responseBody: true,
responseStatus: 200,
},
];
const handshake = readArtifactVoteHandshake(records, 1, "zyxwvutsrqp", -1);
expect(handshake).toEqual(validVoteHandshake());
expect(isArtifactVoteHandshakeValid(handshake)).toBe(true);
});
test.each([
["duplicate listener requests", (value) => ({ ...value, interactionCount: 4, voteCount: 2, confirmationCount: 2 })],
["reversed request order", (value) => ({ ...value, interactionPaths: ["/interact/confirmVote", "/interact/vote"] })],
[
"the wrong destination video",
(value) => ({ ...value, vote: { body: { ...value.vote.body, videoId: "abcdefghijk" } } }),
],
["the wrong vote value", (value) => ({ ...value, vote: { body: { ...value.vote.body, value: 1 } } })],
[
"different vote and confirmation identities",
(value) => ({
...value,
confirmation: {
...value.confirmation,
body: { ...value.confirmation.body, userId: "B".repeat(36) },
},
}),
],
["a malformed identity", (value) => ({ ...value, sharedUserId: "short-user" })],
["a false confirmation", (value) => ({ ...value, confirmation: { ...value.confirmation, responseBody: false } })],
[
"a failed confirmation status",
(value) => ({ ...value, confirmation: { ...value.confirmation, responseStatus: 500 } }),
],
[
"a malformed proof solution",
(value) => ({
...value,
confirmation: { ...value.confirmation, body: { ...value.confirmation.body, solution: "AA==" } },
}),
],
["an extra vote field", (value) => ({ ...value, vote: { body: { ...value.vote.body, duplicate: true } } })],
])("rejects a post-SPA vote handshake with %s", (_label, mutate) => {
expect(isArtifactVoteHandshakeValid(mutate(validVoteHandshake()))).toBe(false);
});
test.each(["userscript", "extension"])(
"runs one post-SPA dislike activation and confirmation contract for %s",
async (runtime) => {
const events = [];
const adapter = {
activateSpaDislike: jest.fn(async (videoId) => ({
ariaPressedBefore: "false",
interactionStartIndex: 7,
videoId,
})),
assertNoPageSignals: jest.fn(async (scenarioId) => events.push(`signals:${scenarioId}`)),
assertSpaVoteNetwork: jest.fn(async () => ({ fromVideoRequests: 1, toVideoRequests: 1 })),
close: jest.fn(async () => events.push("close")),
navigateSpaWatch: jest.fn(async () => ({
destination: { destinationReplaced: true },
outgoing: { beforeBarCount: 1 },
})),
openSpaWatch: jest.fn(async (videoId) => events.push(`open:${videoId}`)),
readSpaVoteHandshake: jest.fn(async () => validVoteHandshake()),
readSpaWatchSnapshot: jest.fn(async () => validSpaSnapshot()),
runtime,
start: jest.fn(async () => events.push("start")),
waitForWatchResult: jest.fn(async (videoId) => ({ count: "10", fillRatio: 0.9, videoId })),
};
await expect(
runArtifactWatchSpaVoteScenario(adapter, {
handshakeStableForMs: 0,
handshakeTimeoutMs: 1,
intervalMs: 1,
stabilityDurationMs: 0,
stableForMs: 0,
timeoutMs: 1,
}),
).resolves.toMatchObject({
activation: { ariaPressedBefore: "false", videoId: "zyxwvutsrqp" },
destination: { count: "65", fillRatio: 0.35, videoId: "zyxwvutsrqp" },
handshake: {
confirmationRequests: 1,
confirmationStatus: 200,
confirmed: true,
interactionRequests: 2,
userId: ARTIFACT_USER_ID,
value: -1,
videoId: "zyxwvutsrqp",
voteRequests: 1,
},
runtime,
scenarioId: "watch-spa-dislike-activation",
traffic: { confirmationRequests: 1, interactionRequests: 2, voteRequests: 1 },
});
expect(adapter.activateSpaDislike).toHaveBeenCalledTimes(1);
expect(adapter.activateSpaDislike).toHaveBeenCalledWith("zyxwvutsrqp");
expect(adapter.readSpaVoteHandshake).toHaveBeenCalledWith(7, "zyxwvutsrqp", -1);
expect(adapter.assertSpaVoteNetwork).toHaveBeenCalledWith("abcdefghijk", "zyxwvutsrqp", 7);
expect(events).toEqual(["start", "open:abcdefghijk", "signals:watch-spa-dislike-activation", "close"]);
},
);
test("rejects a duplicated post-SPA vote chain and still closes the adapter", async () => {
const duplicatedHandshake = validVoteHandshake({
confirmationCount: 2,
interactionCount: 4,
interactionPaths: ["/interact/vote", "/interact/confirmVote", "/interact/vote", "/interact/confirmVote"],
voteCount: 2,
});
const adapter = {
activateSpaDislike: jest.fn(async (videoId) => ({ interactionStartIndex: 0, videoId })),
assertNoPageSignals: jest.fn(),
assertSpaVoteNetwork: jest.fn(),
close: jest.fn(),
navigateSpaWatch: jest.fn(async () => ({
destination: { destinationReplaced: true },
outgoing: { beforeBarCount: 1 },
})),
openSpaWatch: jest.fn(),
readSpaVoteHandshake: jest.fn(async () => duplicatedHandshake),
readSpaWatchSnapshot: jest.fn(async () => validSpaSnapshot()),
runtime: "userscript",
start: jest.fn(),
waitForWatchResult: jest.fn(async (videoId) => ({ count: "10", fillRatio: 0.9, videoId })),
};
await expect(
runArtifactWatchSpaVoteScenario(adapter, {
handshakeStableForMs: 0,
handshakeTimeoutMs: 1,
intervalMs: 1,
stabilityDurationMs: 0,
stableForMs: 0,
timeoutMs: 1,
}),
).rejects.toThrow("post-SPA dislike handshake did not remain valid");
expect(adapter.assertSpaVoteNetwork).not.toHaveBeenCalled();
expect(adapter.assertNoPageSignals).not.toHaveBeenCalled();
expect(adapter.close).toHaveBeenCalledTimes(1);
});
test("rejects a destination that becomes correct after the explicit latency budget", async () => {
const adapter = {
assertNoPageSignals: jest.fn(),
assertSpaNetwork: jest.fn(),
close: jest.fn(),
navigateSpaWatch: jest.fn(async () => ({
destination: { destinationReplaced: true },
outgoing: { beforeBarCount: 1 },
})),
openSpaWatch: jest.fn(),
readSpaWatchSnapshot: jest.fn(async () => {
await new Promise((resolve) => setTimeout(resolve, 5));
return validSpaSnapshot();
}),
runtime: "userscript",
start: jest.fn(),
waitForWatchResult: jest.fn(async (videoId) => ({ count: "10", fillRatio: 0.9, videoId })),
};
await expect(
runArtifactWatchSpaScenario(adapter, {
intervalMs: 1,
maxFirstValidMs: 1,
stabilityDurationMs: 0,
stableForMs: 0,
timeoutMs: 20,
}),
).rejects.toThrow(/first became valid after .*the budget is 1ms/);
expect(adapter.close).toHaveBeenCalledTimes(1);
});
test.each([
["duplicate global bar", { globalBarCount: 2 }],
["stale outgoing bar", { insideOutgoing: { ...validSpaSnapshot().insideOutgoing, barCount: 1 } }],
["wrong destination ratio", { fillRatio: 0.9 }],
["wrong destination count", { count: "10" }],
])("rejects a settled SPA snapshot with %s", (_label, change) => {
expect(
isSpaDestinationValid(
{ ...validSpaSnapshot(), ...change },
{
expectedCount: 65,
expectedRatio: 0.35,
fromVideoId: "abcdefghijk",
toVideoId: "zyxwvutsrqp",
},
),
).toBe(false);
});
test("always closes an artifact adapter after a failed visual assertion", async () => {
const adapter = {
assertNoPageSignals: jest.fn(),
close: jest.fn(),
openWatch: jest.fn(),
runtime: "extension",
start: jest.fn(),
waitForWatchResult: jest.fn(async () => ({
count: "25",
fillVisible: true,
rateBarVisible: false,
videoId: "abcdefghijk",
})),
};
await expect(runArtifactWatchRenderScenario(adapter, { videoId: "abcdefghijk" })).rejects.toThrow(
"extension did not render a visible watch ratio bar",
);
expect(adapter.close).toHaveBeenCalledTimes(1);
});
test("turns an otherwise successful artifact result into a failure when the page emitted an error", async () => {
const adapter = {
assertNoPageSignals: jest.fn(async () => {
throw new Error("unexpected browser signals");
}),
close: jest.fn(),
openWatch: jest.fn(),
runtime: "userscript",
start: jest.fn(),
waitForWatchResult: jest.fn(async (videoId) => ({
count: "25",
fillVisible: true,
rateBarVisible: true,
videoId,
})),
};
await expect(runArtifactWatchRenderScenario(adapter, { videoId: "abcdefghijk" })).rejects.toThrow(
"unexpected browser signals",
);
expect(adapter.close).toHaveBeenCalledTimes(1);
});
function createPageDouble() {
const page = new EventEmitter();
page.addInitScript = jest.fn(async () => {});
page.evaluate = jest.fn(async (callback) => callback());
page.exposeBinding = jest.fn(async (name, callback) => {
page.exposedBinding = { callback, name };
});
return page;
}
function consoleMessage(
type,
text,
location = { columnNumber: 5, lineNumber: 4, url: "https://www.youtube.com/watch" },
) {
return {
location: () => location,
text: () => text,
type: () => type,
};
}
test.each(["userscript", "extension"])(
"collects clean page signals through one shared %s collector",
async (runtime) => {
const page = createPageDouble();
const collector = await createPageSignalCollector(page, runtime);
page.emit("console", consoleMessage("warning", "harmless warning"));
await expect(collector.assertClean("watch-render")).resolves.toEqual({
consoleErrors: [],
pageErrors: [],
runtime,
unhandledRejections: [],
});
expect(page.exposeBinding).toHaveBeenCalledWith("__rydArtifactReportUnhandledRejection", expect.any(Function));
expect(page.addInitScript).toHaveBeenCalledTimes(1);
},
);
test.each([
["console error", (page) => page.emit("console", consoleMessage("error", "fixture exploded")), "fixture exploded"],
[
"failed browser resource load",
(page) => page.emit("console", consoleMessage("error", "Failed to load resource: net::ERR_FILE_NOT_FOUND")),
"ERR_FILE_NOT_FOUND",
],
[
"failed console assertion",
(page) => page.emit("console", consoleMessage("assert", "bad assertion")),
"bad assertion",
],
["page error", (page) => page.emit("pageerror", new TypeError("page exploded")), "page exploded"],
[
"unhandled rejection",
(page) =>
page.exposedBinding.callback(
{ frame: { url: () => "https://www.youtube.com/watch?v=abcdefghijk" } },
{ message: "promise exploded", name: "Error", stack: "Error: promise exploded" },
),
"promise exploded",
],
])("fails a successful scenario on %s with diagnostics", async (_label, emitSignal, expectedDiagnostic) => {
const page = createPageDouble();
const collector = await createPageSignalCollector(page, "userscript");
emitSignal(page);
await expect(collector.assertClean("watch-render")).rejects.toThrow(
new RegExp(`userscript emitted unexpected browser signals.*${expectedDiagnostic}`, "s"),
);
});
+162
View File
@@ -0,0 +1,162 @@
const assert = require("node:assert/strict");
function deepFreeze(value) {
if (!value || typeof value !== "object" || Object.isFrozen(value)) return value;
Object.values(value).forEach(deepFreeze);
return Object.freeze(value);
}
const LIVE_RUNTIME_PROFILES = deepFreeze({
extension: {
capabilities: {
backgroundVoteTransport: true,
credentialStore: "browser.storage",
ownsShortsDislikeControl: false,
shortsControlModel: "native-youtube",
shortsVisualModel: "native-pair",
},
buildMarkerAttribute: "data-ryd-extension-build",
markerAttribute: "data-ryd-extension-version",
runtime: "extension",
selectors: {
rateBar: "#ryd-bar",
rateBarContainer: "#ryd-bar-container",
shortsDislikeControl: null,
tooltipContent: "#ryd-dislike-tooltip",
tooltipTrigger: ".ryd-tooltip",
},
},
userscript: {
capabilities: {
backgroundVoteTransport: false,
credentialStore: "gm-storage",
ownsShortsDislikeControl: true,
shortsControlModel: "synthetic-owned",
shortsVisualModel: "strict-synthetic",
},
buildMarkerAttribute: "data-ryd-userscript-build",
markerAttribute: "data-ryd-userscript-version",
runtime: "userscript",
selectors: {
rateBar: "#return-youtube-dislike-bar",
rateBarContainer: "#return-youtube-dislike-bar-container",
shortsDislikeControl: "[data-ryd-synthetic-shorts-dislike]",
tooltipContent: ".ryd-tooltip-label",
tooltipTrigger: ".ryd-tooltip",
},
},
});
function requireDriverMethod(driver, method, scenarioId = null) {
if (typeof driver?.[method] === "function") return;
const scenario = scenarioId ? ` for shared scenario ${scenarioId}` : "";
throw new TypeError(`The ${driver ? "live driver" : "missing driver"}${scenario} must implement ${method}().`);
}
function assertRuntimeArgument(value, expected, label) {
if (value === undefined) return;
assert.equal(value, expected, `${label} does not match the selected live runtime adapter.`);
}
function createRuntimeBoundDriver(driver, runtime, expectedVersion, expectedBuildId) {
return new Proxy(driver, {
get(target, property, receiver) {
if (property === "assertRuntime") {
return (requestedRuntime, requestedVersion, requestedBuildId) => {
assertRuntimeArgument(requestedRuntime, runtime, "The asserted runtime");
assertRuntimeArgument(requestedVersion, expectedVersion, "The asserted runtime version");
assertRuntimeArgument(requestedBuildId, expectedBuildId, "The asserted live build ID");
requireDriverMethod(target, "assertRuntime");
return target.assertRuntime(runtime, expectedVersion, expectedBuildId);
};
}
if (property === "assertCurrentShortsControl") {
return (videoId, requestedRuntime) => {
assertRuntimeArgument(requestedRuntime, runtime, "The Shorts-control runtime");
requireDriverMethod(target, "assertCurrentShortsControl");
return target.assertCurrentShortsControl(videoId, runtime);
};
}
if (property === "captureWatchRatioVisual") {
return (requestedRuntime, screenshotPath, options) => {
assertRuntimeArgument(requestedRuntime, runtime, "The watch-visual runtime");
requireDriverMethod(target, "captureWatchRatioVisual");
return options === undefined
? target.captureWatchRatioVisual(runtime, screenshotPath)
: target.captureWatchRatioVisual(runtime, screenshotPath, options);
};
}
if (property === "captureReactionStateVisual") {
return (request) => {
assertRuntimeArgument(request?.runtime, runtime, "The reaction-visual runtime");
requireDriverMethod(target, "captureReactionStateVisual");
return target.captureReactionStateVisual({ ...request, runtime });
};
}
const value = Reflect.get(target, property, receiver);
return typeof value === "function" ? value.bind(target) : value;
},
});
}
class LiveRuntimeAdapter {
constructor({ driver, expectedBuildId, expectedVersion, runtime }) {
const profile = LIVE_RUNTIME_PROFILES[runtime];
if (!profile) throw new TypeError(`Unsupported live runtime adapter: ${runtime}`);
if (!driver || (typeof driver !== "object" && typeof driver !== "function")) {
throw new TypeError("A live YouTube driver is required.");
}
if (typeof expectedVersion !== "string" || expectedVersion.trim() === "") {
throw new TypeError("A non-empty expected runtime version is required.");
}
if (!/^[a-f0-9]{32}$/.test(expectedBuildId)) {
throw new TypeError("The expected live build ID must be a 32-character lowercase hexadecimal value.");
}
this.capabilities = profile.capabilities;
this.driver = createRuntimeBoundDriver(driver, runtime, expectedVersion, expectedBuildId);
this.expectedBuildId = expectedBuildId;
this.expectedVersion = expectedVersion;
this.profile = profile;
this.runtime = runtime;
this.selectors = profile.selectors;
}
createScenarioOptions(options = {}) {
if (!options || typeof options !== "object" || Array.isArray(options)) {
throw new TypeError("Shared live scenario options must be an object.");
}
assertRuntimeArgument(options.runtime, this.runtime, "The configured scenario runtime");
assertRuntimeArgument(options.expectedBuildId, this.expectedBuildId, "The configured live build ID");
assertRuntimeArgument(options.expectedVersion, this.expectedVersion, "The configured scenario runtime version");
return {
...options,
expectedBuildId: this.expectedBuildId,
expectedVersion: this.expectedVersion,
runtime: this.runtime,
};
}
assertDriverMethods(methods, scenarioId) {
methods.forEach((method) => requireDriverMethod(this.driver, method, scenarioId));
}
}
function createExtensionLiveRuntimeAdapter(options) {
return new LiveRuntimeAdapter({ ...options, runtime: "extension" });
}
function createUserscriptLiveRuntimeAdapter(options) {
return new LiveRuntimeAdapter({ ...options, runtime: "userscript" });
}
module.exports = {
LIVE_RUNTIME_PROFILES,
LiveRuntimeAdapter,
createExtensionLiveRuntimeAdapter,
createUserscriptLiveRuntimeAdapter,
};
+199
View File
@@ -0,0 +1,199 @@
const defaultImplementations = require("../UserScript/e2e/live/live-scenarios");
const COMMON_PRECONDITION_METHODS = ["assertRuntime", "assertSignedIn"];
const SHARED_LIVE_SCENARIOS = Object.freeze([
{
driverMethods: [
...COMMON_PRECONDITION_METHODS,
"assertCurrentShortsControl",
"navigateFromColdChannelToShort",
"navigateToNextShort",
"pausePlayback",
"withNoProductionInteractions",
],
id: "channel-shorts-navigation",
implementation: "runChannelShortsNavigationScenario",
},
{
driverMethods: [
...COMMON_PRECONDITION_METHODS,
"navigateFromColdChannelToWatch",
"waitForDislikeText",
"withNoProductionInteractions",
],
id: "channel-watch-navigation",
implementation: "runChannelWatchNavigationScenario",
},
{
driverMethods: [
...COMMON_PRECONDITION_METHODS,
"openPlaylist",
"waitForDislikeText",
"withNoProductionInteractions",
],
id: "watch-render",
implementation: "runWatchRenderScenario",
},
{
driverMethods: [
...COMMON_PRECONDITION_METHODS,
"openPlaylist",
"reload",
"waitForDislikeText",
"withNoProductionInteractions",
],
id: "reload",
implementation: "runReloadScenario",
},
{
driverMethods: [
...COMMON_PRECONDITION_METHODS,
"navigateWithinPlaylist",
"openPlaylist",
"waitForDislikeText",
"withNoProductionInteractions",
],
id: "spa-navigation",
implementation: "runSpaNavigationScenario",
},
{
driverMethods: [
...COMMON_PRECONDITION_METHODS,
"assertCurrentVideo",
"captureWatchRatioVisual",
"navigateToRelatedWatch",
"openWatch",
"soakWatchRatioVisual",
"withNoProductionInteractions",
],
id: "sidebar-navigation-stress",
implementation: "runSidebarStressScenario",
},
{
driverMethods: [...COMMON_PRECONDITION_METHODS, "openShort", "waitForDislikeText", "withNoProductionInteractions"],
id: "shorts-render",
implementation: "runShortsRenderScenario",
},
{
driverMethods: [
...COMMON_PRECONDITION_METHODS,
"assertCurrentShortsControl",
"captureWatchRatioVisual",
"openWatch",
"readViewportSize",
"setViewportSize",
"waitForDislikeText",
"withNoProductionInteractions",
],
driverMethodsByCapability: {
"native-pair": ["captureNativeShortsVisual", "openShort"],
"strict-synthetic": ["captureSyntheticShortsVisual", "openShort"],
},
id: "responsive-visual",
implementation: "runResponsiveVisualScenario",
},
{
driverMethods: [
...COMMON_PRECONDITION_METHODS,
"assertCurrentVideo",
"captureReactionStateVisual",
"clickAction",
"openShort",
"openWatch",
"readReactionState",
"waitForDislikeText",
"waitForReactionState",
],
id: "reaction-matrix",
implementation: "runProductionReactionMatrixScenario",
},
]);
const SHARED_LIVE_SCENARIO_IDS = Object.freeze(SHARED_LIVE_SCENARIOS.map(({ id }) => id));
const SCENARIOS_BY_ID = new Map(SHARED_LIVE_SCENARIOS.map((scenario) => [scenario.id, scenario]));
function scenarioDriverMethods(scenario, adapter) {
const capabilityMethods = scenario.driverMethodsByCapability?.[adapter.capabilities.shortsVisualModel] ?? [];
return [...new Set([...scenario.driverMethods, ...capabilityMethods])];
}
function assertSharedLiveScenarioAdapter(adapter, scenarioIds = SHARED_LIVE_SCENARIO_IDS) {
if (
!adapter ||
typeof adapter.createScenarioOptions !== "function" ||
typeof adapter.assertDriverMethods !== "function"
) {
throw new TypeError("A LiveRuntimeAdapter is required to run shared live scenarios.");
}
scenarioIds.forEach((scenarioId) => {
const scenario = SCENARIOS_BY_ID.get(scenarioId);
if (!scenario) throw new TypeError(`Unknown shared live scenario: ${scenarioId}`);
adapter.assertDriverMethods(scenarioDriverMethods(scenario, adapter), scenarioId);
});
return [...scenarioIds];
}
function requireService(services, name, scenarioId) {
if (typeof services?.[name] === "function") return services[name];
throw new TypeError(`Shared scenario ${scenarioId} requires services.${name}().`);
}
function requireImplementation(implementations, scenario) {
const implementation = implementations?.[scenario.implementation];
if (typeof implementation !== "function") {
throw new TypeError(`Shared scenario ${scenario.id} has no ${scenario.implementation}() implementation.`);
}
return implementation;
}
function createSharedLiveScenarioRunner({ implementations = defaultImplementations } = {}) {
async function run(adapter, scenarioId, options = {}, services = {}) {
const scenario = SCENARIOS_BY_ID.get(scenarioId);
if (!scenario) throw new TypeError(`Unknown shared live scenario: ${scenarioId}`);
assertSharedLiveScenarioAdapter(adapter, [scenarioId]);
const implementation = requireImplementation(implementations, scenario);
const scenarioOptions = adapter.createScenarioOptions(options);
if (scenarioId === "responsive-visual") {
return implementation(adapter.driver, scenarioOptions, services.visualOptions);
}
if (scenarioId === "sidebar-navigation-stress") {
return implementation(adapter.driver, scenarioOptions, services.sidebarOptions);
}
if (scenarioId === "reaction-matrix") {
return implementation(
adapter.driver,
scenarioOptions,
requireService(services, "createRecorder", scenarioId),
requireService(services, "consumeVoteApproval", scenarioId),
services.visualOptions,
);
}
return implementation(adapter.driver, scenarioOptions);
}
async function runAll(adapter, options = {}, services = {}, scenarioIds = SHARED_LIVE_SCENARIO_IDS) {
assertSharedLiveScenarioAdapter(adapter, scenarioIds);
const results = [];
for (const scenarioId of scenarioIds) {
results.push({ id: scenarioId, result: await run(adapter, scenarioId, options, services) });
}
return results;
}
return Object.freeze({
ids: SHARED_LIVE_SCENARIO_IDS,
run,
runAll,
validateAdapter: assertSharedLiveScenarioAdapter,
});
}
module.exports = {
SHARED_LIVE_SCENARIO_IDS,
SHARED_LIVE_SCENARIOS,
assertSharedLiveScenarioAdapter,
createSharedLiveScenarioRunner,
};
@@ -0,0 +1,252 @@
const {
LIVE_RUNTIME_PROFILES,
createExtensionLiveRuntimeAdapter,
createUserscriptLiveRuntimeAdapter,
} = require("./live-runtime-adapter");
const {
SHARED_LIVE_SCENARIO_IDS,
assertSharedLiveScenarioAdapter,
createSharedLiveScenarioRunner,
} = require("./shared-live-scenarios");
const EXPECTED_SCENARIO_IDS = [
"channel-shorts-navigation",
"channel-watch-navigation",
"watch-render",
"reload",
"spa-navigation",
"sidebar-navigation-stress",
"shorts-render",
"responsive-visual",
"reaction-matrix",
];
const EXPECTED_BUILD_ID = "0123456789abcdef0123456789abcdef";
const ALL_DRIVER_METHODS = [
"assertCurrentShortsControl",
"assertCurrentVideo",
"assertRuntime",
"assertSignedIn",
"captureReactionStateVisual",
"captureNativeShortsVisual",
"captureSyntheticShortsVisual",
"captureWatchRatioVisual",
"clickAction",
"navigateFromColdChannelToShort",
"navigateFromColdChannelToWatch",
"navigateToNextShort",
"navigateToRelatedWatch",
"navigateWithinPlaylist",
"openPlaylist",
"openShort",
"openWatch",
"pausePlayback",
"readReactionState",
"readViewportSize",
"reload",
"setViewportSize",
"soakWatchRatioVisual",
"waitForDislikeText",
"waitForReactionState",
"withNoProductionInteractions",
];
function createDriver(overrides = {}) {
return {
...Object.fromEntries(ALL_DRIVER_METHODS.map((method) => [method, jest.fn()])),
...overrides,
};
}
function createAdapter(runtime, driver = createDriver(), expectedBuildId = EXPECTED_BUILD_ID) {
const options = { driver, expectedBuildId, expectedVersion: runtime === "userscript" ? "3.2.0" : "4.0.4" };
return runtime === "userscript"
? createUserscriptLiveRuntimeAdapter(options)
: createExtensionLiveRuntimeAdapter(options);
}
function createImplementations(events) {
const implementationNames = [
"runChannelShortsNavigationScenario",
"runChannelWatchNavigationScenario",
"runWatchRenderScenario",
"runReloadScenario",
"runSpaNavigationScenario",
"runSidebarStressScenario",
"runShortsRenderScenario",
"runResponsiveVisualScenario",
"runProductionReactionMatrixScenario",
];
return Object.fromEntries(
implementationNames.map((name) => [
name,
jest.fn(async (_driver, options) => {
events.push({ implementation: name, runtime: options.runtime });
return `${options.runtime}:${name}`;
}),
]),
);
}
describe("shared live runtime profiles", () => {
test("preserves each runtime's selectors and control-ownership capabilities", () => {
expect(LIVE_RUNTIME_PROFILES.userscript).toMatchObject({
capabilities: {
backgroundVoteTransport: false,
credentialStore: "gm-storage",
ownsShortsDislikeControl: true,
shortsControlModel: "synthetic-owned",
shortsVisualModel: "strict-synthetic",
},
buildMarkerAttribute: "data-ryd-userscript-build",
markerAttribute: "data-ryd-userscript-version",
selectors: {
rateBar: "#return-youtube-dislike-bar",
rateBarContainer: "#return-youtube-dislike-bar-container",
shortsDislikeControl: "[data-ryd-synthetic-shorts-dislike]",
},
});
expect(LIVE_RUNTIME_PROFILES.extension).toMatchObject({
capabilities: {
backgroundVoteTransport: true,
credentialStore: "browser.storage",
ownsShortsDislikeControl: false,
shortsControlModel: "native-youtube",
shortsVisualModel: "native-pair",
},
buildMarkerAttribute: "data-ryd-extension-build",
markerAttribute: "data-ryd-extension-version",
selectors: {
rateBar: "#ryd-bar",
rateBarContainer: "#ryd-bar-container",
shortsDislikeControl: null,
},
});
expect(Object.isFrozen(LIVE_RUNTIME_PROFILES.userscript.capabilities)).toBe(true);
expect(Object.isFrozen(LIVE_RUNTIME_PROFILES.extension.selectors)).toBe(true);
});
test.each(["userscript", "extension"])("binds runtime-sensitive driver calls for %s", async (runtime) => {
const driver = createDriver();
const adapter = createAdapter(runtime, driver);
await adapter.driver.assertRuntime(runtime, adapter.expectedVersion, adapter.expectedBuildId);
await adapter.driver.assertCurrentShortsControl("abcdefghijk", runtime);
await adapter.driver.captureWatchRatioVisual(runtime, "watch.png");
await adapter.driver.captureReactionStateVisual({
expectedState: "neutral",
isShort: false,
runtime,
screenshotPath: "reaction.png",
videoId: "abcdefghijk",
});
expect(driver.assertRuntime).toHaveBeenCalledWith(runtime, adapter.expectedVersion, adapter.expectedBuildId);
expect(driver.assertCurrentShortsControl).toHaveBeenCalledWith("abcdefghijk", runtime);
expect(driver.captureWatchRatioVisual).toHaveBeenCalledWith(runtime, "watch.png");
expect(driver.captureReactionStateVisual).toHaveBeenCalledWith(
expect.objectContaining({ runtime, videoId: "abcdefghijk" }),
);
});
test("rejects scenario options and calls for another runtime", async () => {
const adapter = createAdapter("userscript");
expect(() => adapter.createScenarioOptions({ runtime: "extension" })).toThrow(
"The configured scenario runtime does not match",
);
expect(() => adapter.driver.assertRuntime("extension", adapter.expectedVersion)).toThrow(
"The asserted runtime does not match",
);
});
test.each([undefined, "", "stale", "A".repeat(32)])(
"rejects a missing or malformed exact live build ID: %p",
(expectedBuildId) => {
expect(() =>
createUserscriptLiveRuntimeAdapter({
driver: createDriver(),
expectedBuildId,
expectedVersion: "3.2.0",
}),
).toThrow("expected live build ID must be a 32-character lowercase hexadecimal value");
},
);
test("binds the exact generated live build ID into runtime checks", async () => {
const driver = createDriver();
const expectedBuildId = "0123456789abcdef0123456789abcdef";
const adapter = createAdapter("userscript", driver, expectedBuildId);
await adapter.driver.assertRuntime("userscript", adapter.expectedVersion, expectedBuildId);
expect(driver.assertRuntime).toHaveBeenCalledWith("userscript", adapter.expectedVersion, expectedBuildId);
expect(adapter.createScenarioOptions()).toMatchObject({ expectedBuildId });
expect(() => adapter.driver.assertRuntime("userscript", adapter.expectedVersion, "f".repeat(32))).toThrow(
"live build ID does not match",
);
});
});
describe("shared live scenario contract", () => {
test("publishes one stable ordered scenario ID list", () => {
expect(SHARED_LIVE_SCENARIO_IDS).toEqual(EXPECTED_SCENARIO_IDS);
expect(Object.isFrozen(SHARED_LIVE_SCENARIO_IDS)).toBe(true);
});
test.each(["userscript", "extension"])("validates the complete contract for %s", (runtime) => {
const adapter = createAdapter(runtime);
expect(assertSharedLiveScenarioAdapter(adapter)).toEqual(EXPECTED_SCENARIO_IDS);
});
test("requires each runtime's Shorts visual model from the shared responsive scenario", () => {
const driver = createDriver();
delete driver.captureSyntheticShortsVisual;
expect(() => assertSharedLiveScenarioAdapter(createAdapter("userscript", driver), ["responsive-visual"])).toThrow(
"must implement captureSyntheticShortsVisual()",
);
expect(assertSharedLiveScenarioAdapter(createAdapter("extension", driver), ["responsive-visual"])).toEqual([
"responsive-visual",
]);
const extensionDriver = createDriver();
delete extensionDriver.captureNativeShortsVisual;
expect(() =>
assertSharedLiveScenarioAdapter(createAdapter("extension", extensionDriver), ["responsive-visual"]),
).toThrow("must implement captureNativeShortsVisual()");
});
test("executes the same scenario IDs through both runtime adapters", async () => {
const events = [];
const implementations = createImplementations(events);
const runner = createSharedLiveScenarioRunner({ implementations });
const services = {
consumeVoteApproval: jest.fn(),
createRecorder: jest.fn(),
visualOptions: { outputDirectory: "evidence" },
sidebarOptions: { outputDirectory: "sidebar-evidence" },
};
const userscriptResults = await runner.runAll(createAdapter("userscript"), {}, services);
const extensionResults = await runner.runAll(createAdapter("extension"), {}, services);
expect(userscriptResults.map(({ id }) => id)).toEqual(EXPECTED_SCENARIO_IDS);
expect(extensionResults.map(({ id }) => id)).toEqual(EXPECTED_SCENARIO_IDS);
expect(events.map(({ runtime }) => runtime)).toEqual([
...EXPECTED_SCENARIO_IDS.map(() => "userscript"),
...EXPECTED_SCENARIO_IDS.map(() => "extension"),
]);
expect(Object.values(implementations).every((implementation) => implementation.mock.calls.length === 2)).toBe(true);
});
test("rejects unknown IDs before invoking a scenario implementation", async () => {
const implementations = createImplementations([]);
const runner = createSharedLiveScenarioRunner({ implementations });
await expect(runner.run(createAdapter("userscript"), "not-a-scenario")).rejects.toThrow(
"Unknown shared live scenario: not-a-scenario",
);
expect(Object.values(implementations).every((implementation) => implementation.mock.calls.length === 0)).toBe(true);
});
});
+113
View File
@@ -0,0 +1,113 @@
const { NAVIGATION_MATRIX } = require("../UserScript/e2e/navigation-matrix");
const { SHARED_LIVE_SCENARIO_IDS } = require("./shared-live-scenarios");
const REQUIRED_SURFACE_PAIRS = ["watch->watch", "watch->shorts", "shorts->watch", "shorts->shorts"];
const REQUIRED_TRIGGERS = [
"autoplay-ended",
"direct-link",
"dom-corruption",
"dom-replacement",
"history-back-forward",
"next-control",
"sidebar-link",
];
const REQUIRED_DOM_FEATURES = [
"active-reel-switch",
"connected-collapsed-rate-bar",
"connected-hidden-rate-bar",
"delayed-hydration",
"hidden-outgoing-first",
"legacy-segmented-duplicate-ids",
"malformed-rate-bar",
"no-useful-control-mutation",
"positive-size-offscreen-outgoing-first",
"prune-current-bar",
"replace-controls",
"replace-current-action-container",
"reuse-exact-control-nodes",
"retain-hidden-outgoing",
"same-current-root",
"stripped-rate-bar-wrapper-class",
];
const REQUIRED_TIMINGS = [
"destination-count-gated",
"finish-before-hydration",
"no-navigate-finish",
"no-navigation-event",
"navigate-finish",
"same-video",
"settled",
];
const REQUIRED_SHARED_LIVE_SCENARIOS = [
"channel-shorts-navigation",
"channel-watch-navigation",
"watch-render",
"reload",
"spa-navigation",
"sidebar-navigation-stress",
"shorts-render",
"responsive-visual",
"reaction-matrix",
];
function collectCoverage(axis) {
return new Set(
NAVIGATION_MATRIX.flatMap((scenario) => {
const value = scenario.coverage[axis];
return Array.isArray(value) ? value : [value];
}),
);
}
describe("systematic browser coverage contract", () => {
test("keeps every declarative scenario uniquely identified and completely classified", () => {
expect(new Set(NAVIGATION_MATRIX.map(({ id }) => id)).size).toBe(NAVIGATION_MATRIX.length);
for (const scenario of NAVIGATION_MATRIX) {
expect(scenario).toEqual(
expect.objectContaining({
coverage: expect.objectContaining({
destination: expect.any(String),
dom: expect.any(Array),
origin: expect.any(String),
timing: expect.any(Array),
trigger: expect.any(String),
width: expect.any(String),
}),
destination: expect.objectContaining({ counts: expect.any(Object), kind: expect.any(String) }),
id: expect.any(String),
origin: expect.objectContaining({ counts: expect.any(Object), kind: expect.any(String) }),
viewport: expect.objectContaining({ height: expect.any(Number), width: expect.any(Number) }),
}),
);
}
});
test("covers every watch and Shorts direction and every required navigation trigger", () => {
const surfacePairs = new Set(
NAVIGATION_MATRIX.map(({ coverage }) => `${coverage.origin}->${coverage.destination}`),
);
REQUIRED_SURFACE_PAIRS.forEach((pair) => expect(surfacePairs).toContain(pair));
const triggers = collectCoverage("trigger");
REQUIRED_TRIGGERS.forEach((trigger) => expect(triggers).toContain(trigger));
});
test("keeps the retained, replacement, hydration, pruning, and event-order stress cells", () => {
const domFeatures = collectCoverage("dom");
REQUIRED_DOM_FEATURES.forEach((feature) => expect(domFeatures).toContain(feature));
const timings = collectCoverage("timing");
REQUIRED_TIMINGS.forEach((timing) => expect(timings).toContain(timing));
});
test("uses distinguishable ratios whenever a scenario changes videos", () => {
for (const scenario of NAVIGATION_MATRIX.filter(
({ destination, origin }) => destination.videoId !== origin.videoId,
)) {
const ratio = ({ likes, dislikes }) => likes / (likes + dislikes);
expect(ratio(scenario.origin.counts)).not.toBe(ratio(scenario.destination.counts));
}
});
test("keeps the same authenticated scenario catalog for both runtime adapters", () => {
expect(SHARED_LIVE_SCENARIO_IDS).toEqual(REQUIRED_SHARED_LIVE_SCENARIOS);
});
});
+3
View File
@@ -2,7 +2,10 @@ module.exports = {
resetMocks: true,
collectCoverage: false,
collectCoverageFrom: [
"Extensions/common/**/*.js",
"Extensions/combined/src/**/*.js",
"Extensions/combined/*.js",
"Extensions/UserScript/src/**/*.js",
],
testPathIgnorePatterns: ["/node_modules/", "<rootDir>/Extensions/UserScript/e2e/"],
};
+89
View File
@@ -18,6 +18,7 @@
"@babel/core": "^7.23.5",
"@babel/preset-env": "^7.23.5",
"@babel/runtime": "^7.23.5",
"@playwright/test": "1.62.0",
"babel-loader": "^10.0.0",
"babel-plugin-rewire": "^1.2.0",
"copy-webpack-plugin": "^11.0.0",
@@ -91,6 +92,7 @@
"version": "7.23.5",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@ampproject/remapping": "^2.2.0",
"@babel/code-frame": "^7.23.5",
@@ -1740,6 +1742,7 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
@@ -1763,6 +1766,7 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
}
@@ -2537,6 +2541,22 @@
"node": ">=14"
}
},
"node_modules/@playwright/test": {
"version": "1.62.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.0.tgz",
"integrity": "sha512-9zOJ6ZQRAena31MpOH9VSzIz8Ou3YJ/wtY/eQm5T2uhfhG7/U3COrMS8xOtUrZrp9OgdmzEnIYODye3nY1VqzA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.62.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@sinclair/typebox": {
"version": "0.24.51",
"dev": true,
@@ -2974,6 +2994,7 @@
"version": "8.11.2",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -3003,6 +3024,7 @@
"version": "6.12.6",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
@@ -3504,6 +3526,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"caniuse-lite": "^1.0.30001565",
"electron-to-chromium": "^1.4.601",
@@ -4515,6 +4538,21 @@
"dev": true,
"license": "ISC"
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/function-bind": {
"version": "1.1.1",
"dev": true,
@@ -5912,6 +5950,7 @@
"integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"cssstyle": "^4.2.1",
"data-urls": "^5.0.0",
@@ -6644,6 +6683,53 @@
"node": ">=8"
}
},
"node_modules/playwright": {
"version": "1.62.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.0.tgz",
"integrity": "sha512-Z14dG305dgaLu6foB1TXQagFiW8JfSUIUaUuPaKQ6NtBPKF1P/qXcqfh6c6K/icPqdy37JmjbiBXf6JNg6Sylw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.62.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.62.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.0.tgz",
"integrity": "sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/prettier": {
"version": "3.3.2",
"dev": true,
@@ -7080,6 +7166,7 @@
"version": "8.12.0",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"json-schema-traverse": "^1.0.0",
@@ -7872,6 +7959,7 @@
"version": "5.89.0",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/eslint-scope": "^3.7.3",
"@types/estree": "^1.0.0",
@@ -7920,6 +8008,7 @@
"integrity": "sha512-MfwFQ6SfwinsUVi0rNJm7rHZ31GyTcpVE5pgVA3hwFRb7COD4TzjUUwhGWKfO50+xdc2MQPuEBBJoqIMGt3JDw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@discoveryjs/json-ext": "^0.6.1",
"@webpack-cli/configtest": "^3.0.1",
+12 -2
View File
@@ -7,7 +7,16 @@
"start": "echo To build for development, please use \"npm run dev\". To build for production, please use \"npm run build\".",
"dev": "webpack --mode=development --watch",
"build": "webpack --mode=production",
"build:userscript": "webpack --mode=production --config webpack.userscript.config.js",
"build:live:userscript": "webpack --mode=production --config webpack.userscript.config.js --env liveTest=true",
"build:live:extension": "webpack --mode=production --env liveTest=true",
"test": "jest",
"test:e2e:artifacts": "node Extensions/e2e/hermetic-artifact-smoke.js",
"test:e2e:systematic": "npm run build && npm run build:userscript && npm run test:e2e:artifacts && playwright test --config playwright.userscript.config.js",
"test:e2e:userscript": "npm run build:userscript && playwright test --config playwright.userscript.config.js",
"test:live:youtube": "playwright test --config playwright.live-youtube.config.js",
"test:live:youtube:interactive": "node Extensions/UserScript/e2e/live/live-interactive-runner.js",
"test:all": "npm test -- --runInBand && npm run test:e2e:systematic",
"build:safari": "webpack --mode=production && xcrun safari-web-extension-converter Extensions/combined/dist/safari --project-location Extensions/combined/dist --bundle-identifier com.returnyoutubedislike.safari-ext --force",
"prepare": "husky install"
},
@@ -33,13 +42,14 @@
"country-code-lookup": "^0.1.3",
"echarts": "^5.5.0",
"topojson-client": "^3.1.0",
"world-atlas": "^2.0.2",
"us-atlas": "^3.0.0"
"us-atlas": "^3.0.0",
"world-atlas": "^2.0.2"
},
"devDependencies": {
"@babel/core": "^7.23.5",
"@babel/preset-env": "^7.23.5",
"@babel/runtime": "^7.23.5",
"@playwright/test": "1.62.0",
"babel-loader": "^10.0.0",
"babel-plugin-rewire": "^1.2.0",
"copy-webpack-plugin": "^11.0.0",
+16
View File
@@ -0,0 +1,16 @@
const { defineConfig } = require("@playwright/test");
module.exports = defineConfig({
testDir: "./Extensions/UserScript/e2e/live",
testMatch: "**/*.live.e2e.js",
fullyParallel: false,
forbidOnly: true,
retries: 0,
workers: 1,
timeout: 90_000,
expect: {
timeout: 20_000,
},
reporter: "list",
outputDir: "test-results/live-youtube",
});
+25
View File
@@ -0,0 +1,25 @@
const { defineConfig, devices } = require("@playwright/test");
module.exports = defineConfig({
testDir: "./Extensions/UserScript/e2e",
testMatch: "**/*.e2e.js",
testIgnore: "**/live/**",
fullyParallel: false,
forbidOnly: Boolean(process.env.CI),
retries: process.env.CI ? 1 : 0,
workers: process.env.CI ? 1 : undefined,
timeout: 15_000,
expect: {
timeout: 5_000,
},
reporter: process.env.CI ? [["line"], ["html", { open: "never", outputFolder: "playwright-report" }]] : "list",
use: {
...devices["Desktop Chrome"],
browserName: "chromium",
serviceWorkers: "block",
trace: "retain-on-failure",
screenshot: "only-on-failure",
video: "off",
},
outputDir: "test-results/userscript",
});
+88 -68
View File
@@ -1,8 +1,10 @@
const path = require("path");
const fs = require("fs");
const CopyPlugin = require("copy-webpack-plugin");
const webpack = require("webpack");
const { LiveBuildMarkerPlugin, createLiveBuildId } = require("./webpack.live-build-marker");
const extensionVersion = process.env.npm_package_version.replace("-", ".");
const extensionVersion = (process.env.npm_package_version || require("./package.json").version).replace("-", ".");
const entries = ["ryd.content-script", "ryd.background", "popup", "ryd.changelog"];
const ignorePatterns = [
@@ -51,8 +53,8 @@ class MirrorJsOutputsPlugin {
const existingFiles = await fsp.readdir(targetDir).catch(() => []);
await Promise.all(
existingFiles
.filter((file) => file.endsWith(".js"))
.map((file) => fsp.rm(path.join(targetDir, file), { force: true }))
.filter((file) => jsAssets.includes(file))
.map((file) => fsp.rm(path.join(targetDir, file), { force: true })),
);
await Promise.all(
@@ -66,71 +68,89 @@ class MirrorJsOutputsPlugin {
}
}
module.exports = (env, argv) => ({
entry: Object.fromEntries(
entries.map((entry) => [entry, path.join(__dirname, "./Extensions/combined/", `${entry}.js`)]),
),
output: {
filename: "[name].js",
path: path.resolve(__dirname, "Extensions/combined/dist"),
clean: true,
},
cache: false,
optimization: {
minimize: false,
},
watchOptions: {
ignored: "**/dist/**",
},
plugins: [
// exclude locale files in moment
new CopyPlugin({
patterns: [
{
from: "./Extensions/combined",
to: "./chrome",
globOptions: {
ignore: ignorePatterns,
module.exports = (env = {}, argv = {}) => {
const liveTestBuild = env.liveTest === true || env.liveTest === "true";
const liveBuildId = createLiveBuildId(liveTestBuild);
return {
entry: Object.fromEntries(
entries.map((entry) => [entry, path.join(__dirname, "./Extensions/combined/", `${entry}.js`)]),
),
output: {
filename: "[name].js",
path: path.resolve(__dirname, "Extensions/combined/dist"),
clean: true,
},
cache: false,
optimization: {
minimize: false,
},
watchOptions: {
ignored: "**/dist/**",
},
plugins: [
new webpack.DefinePlugin({
__RYD_LIVE_BUILD_ID__: JSON.stringify(liveBuildId),
__RYD_LIVE_TEST_BUILD__: JSON.stringify(liveTestBuild),
}),
...(liveTestBuild
? [
new LiveBuildMarkerPlugin(liveBuildId, [
"chrome/live-build.json",
"firefox/live-build.json",
"safari/live-build.json",
]),
]
: []),
// exclude locale files in moment
new CopyPlugin({
patterns: [
{
from: "./Extensions/combined",
to: "./chrome",
globOptions: {
ignore: ignorePatterns,
},
transform: i18nTransform,
},
transform: i18nTransform,
},
{
from: "./Extensions/combined/manifest-chrome.json",
to: "./chrome/manifest.json",
transform: manifestTransform,
},
{
from: "./Extensions/combined",
to: "./firefox",
globOptions: {
ignore: ignorePatterns,
{
from: "./Extensions/combined/manifest-chrome.json",
to: "./chrome/manifest.json",
transform: manifestTransform,
},
transform: i18nTransform,
},
{
from: "./Extensions/combined/manifest-firefox.json",
to: "./firefox/manifest.json",
transform: manifestTransform,
},
{
from: "./Extensions/combined",
to: "./safari",
globOptions: {
ignore: ignorePatterns,
{
from: "./Extensions/combined",
to: "./firefox",
globOptions: {
ignore: ignorePatterns,
},
transform: i18nTransform,
},
transform: i18nTransform,
},
{
from: "./Extensions/combined/manifest-safari.json",
to: "./safari/manifest.json",
transform: manifestTransform,
},
],
}),
new MirrorJsOutputsPlugin(["chrome", "firefox", "safari"]),
],
experiments: {
topLevelAwait: true,
},
devtool: argv.mode === "development" ? "inline-source-map" : false,
});
{
from: "./Extensions/combined/manifest-firefox.json",
to: "./firefox/manifest.json",
transform: manifestTransform,
},
{
from: "./Extensions/combined",
to: "./safari",
globOptions: {
ignore: ignorePatterns,
},
transform: i18nTransform,
},
{
from: "./Extensions/combined/manifest-safari.json",
to: "./safari/manifest.json",
transform: manifestTransform,
},
],
}),
new MirrorJsOutputsPlugin(["chrome", "firefox", "safari"]),
],
experiments: {
topLevelAwait: true,
},
devtool: argv.mode === "development" ? "inline-source-map" : false,
};
};
+40
View File
@@ -0,0 +1,40 @@
const crypto = require("node:crypto");
const LIVE_BUILD_ID_PATTERN = /^[a-f0-9]{32}$/;
function createLiveBuildId(enabled) {
return enabled ? crypto.randomBytes(16).toString("hex") : "";
}
class LiveBuildMarkerPlugin {
constructor(buildId, assetNames) {
if (!LIVE_BUILD_ID_PATTERN.test(buildId)) {
throw new TypeError("A live build marker requires a 32-character hexadecimal build ID.");
}
this.assetNames = assetNames;
this.buildId = buildId;
}
apply(compiler) {
compiler.hooks.thisCompilation.tap("LiveBuildMarkerPlugin", (compilation) => {
compilation.hooks.processAssets.tap(
{
name: "LiveBuildMarkerPlugin",
stage: compiler.webpack.Compilation.PROCESS_ASSETS_STAGE_ADDITIONAL,
},
() => {
const source = new compiler.webpack.sources.RawSource(`${JSON.stringify({ buildId: this.buildId })}\n`);
for (const assetName of this.assetNames) {
compilation.emitAsset(assetName, source);
}
},
);
});
}
}
module.exports = {
LIVE_BUILD_ID_PATTERN,
LiveBuildMarkerPlugin,
createLiveBuildId,
};
+59
View File
@@ -0,0 +1,59 @@
const { LIVE_BUILD_ID_PATTERN, LiveBuildMarkerPlugin, createLiveBuildId } = require("./webpack.live-build-marker");
const createExtensionConfig = require("./webpack.config");
const createUserscriptConfig = require("./webpack.userscript.config");
function liveIdentity(config) {
const definePlugin = config.plugins.find(({ constructor }) => constructor.name === "DefinePlugin");
const markerPlugin = config.plugins.find((plugin) => plugin instanceof LiveBuildMarkerPlugin);
return {
definedBuildId: JSON.parse(definePlugin.definitions.__RYD_LIVE_BUILD_ID__),
definedLiveFlag: definePlugin.definitions.__RYD_LIVE_TEST_BUILD__,
markerAssetNames: markerPlugin?.assetNames,
markerBuildId: markerPlugin?.buildId,
};
}
describe("live build marker", () => {
test("keeps production builds deterministic and gives each live build a fresh exact identity", () => {
expect(createLiveBuildId(false)).toBe("");
const first = createLiveBuildId(true);
const second = createLiveBuildId(true);
expect(first).toMatch(LIVE_BUILD_ID_PATTERN);
expect(second).toMatch(LIVE_BUILD_ID_PATTERN);
expect(second).not.toBe(first);
});
test("refuses malformed marker IDs", () => {
expect(() => new LiveBuildMarkerPlugin("stale", ["live-build.json"])).toThrow("32-character hexadecimal build ID");
});
test.each([
["userscript", createUserscriptConfig, ["live-build.json"]],
[
"extension",
createExtensionConfig,
["chrome/live-build.json", "firefox/live-build.json", "safari/live-build.json"],
],
])("binds one exact nonce into the %s live bundle and its marker file", (_runtime, createConfig, assetNames) => {
const identity = liveIdentity(createConfig({ liveTest: true }, { mode: "production" }));
expect(identity.definedBuildId).toMatch(LIVE_BUILD_ID_PATTERN);
expect(identity.definedBuildId).toBe(identity.markerBuildId);
expect(identity.definedLiveFlag).toBe("true");
expect(identity.markerAssetNames).toEqual(assetNames);
});
test.each([
["userscript", createUserscriptConfig],
["extension", createExtensionConfig],
])("does not emit or expose a live identity in a normal %s build", (_runtime, createConfig) => {
const identity = liveIdentity(createConfig({}, { mode: "production" }));
expect(identity).toEqual({
definedBuildId: "",
definedLiveFlag: "false",
markerAssetNames: undefined,
markerBuildId: undefined,
});
});
});
+71
View File
@@ -0,0 +1,71 @@
const path = require("path");
const webpack = require("webpack");
const userscriptMeta = require("./Extensions/UserScript/userscript.meta");
const { LiveBuildMarkerPlugin, createLiveBuildId } = require("./webpack.live-build-marker");
function buildUserscriptBanner(liveTestBuild) {
const metadata = [
"// ==UserScript==",
`// @name ${userscriptMeta.name}${liveTestBuild ? " [Live Test]" : ""}`,
`// @namespace ${userscriptMeta.namespace}`,
`// @homepage ${userscriptMeta.homepage}`,
`// @version ${userscriptMeta.version}`,
`// @encoding ${userscriptMeta.encoding}`,
`// @description ${userscriptMeta.description}`,
`// @icon ${userscriptMeta.icon}`,
`// @author ${userscriptMeta.author}`,
...userscriptMeta.match.map((value) => `// @match ${value}`),
...userscriptMeta.exclude.map((value) => `// @exclude ${value}`),
...userscriptMeta.compatible.map((value) => `// @compatible ${value}`),
...(liveTestBuild
? []
: [`// @downloadURL ${userscriptMeta.downloadURL}`, `// @updateURL ${userscriptMeta.updateURL}`]),
...userscriptMeta.grants.map((value) => `// @grant ${value}`),
`// @run-at ${userscriptMeta.runAt}`,
"// ==/UserScript==",
"",
"// This file is generated by `npm run build:userscript`.",
"// Edit Extensions/UserScript/src instead of the generated file.",
];
return metadata.join("\n");
}
module.exports = (env = {}, argv = {}) => {
const liveTestBuild = env.liveTest === true || env.liveTest === "true";
const liveBuildId = createLiveBuildId(liveTestBuild);
return {
name: "userscript",
mode: argv.mode ?? "production",
entry: path.resolve(__dirname, "Extensions/UserScript/src/userscript-entry.js"),
output: {
path: liveTestBuild
? path.resolve(__dirname, "test-results/live-build/userscript")
: path.resolve(__dirname, "Extensions/UserScript"),
filename: "Return Youtube Dislike.user.js",
clean: false,
iife: true,
},
cache: false,
optimization: {
minimize: false,
},
plugins: [
new webpack.DefinePlugin({
__RYD_LIVE_BUILD_ID__: JSON.stringify(liveBuildId),
__RYD_LIVE_TEST_BUILD__: JSON.stringify(liveTestBuild),
}),
...(liveTestBuild ? [new LiveBuildMarkerPlugin(liveBuildId, ["live-build.json"])] : []),
new webpack.BannerPlugin({
banner: buildUserscriptBanner(liveTestBuild),
raw: true,
entryOnly: true,
}),
],
devtool: false,
performance: {
hints: false,
},
};
};