diff --git a/.gitattributes b/.gitattributes
index dfe0770..521060f 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -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
diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml
index 1ec69ed..0c39c42 100644
--- a/.github/workflows/build.yaml
+++ b/.github/workflows/build.yaml
@@ -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
diff --git a/.gitignore b/.gitignore
index 9a2d0e1..ec88121 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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/
diff --git a/Extensions/UserScript/Return Youtube Dislike.user.js b/Extensions/UserScript/Return Youtube Dislike.user.js
index c513630..42b95a5 100644
--- a/Extensions/UserScript/Return Youtube Dislike.user.js
+++ b/Extensions/UserScript/Return Youtube Dislike.user.js
@@ -2,7 +2,7 @@
// @name Return YouTube Dislike
// @namespace https://www.returnyoutubedislike.com/
// @homepage https://www.returnyoutubedislike.com/
-// @version 3.1.5
+// @version 3.2.0
// @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
@@ -17,12 +17,574 @@
// @compatible 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
-// @grant GM.xmlHttpRequest
-// @connect youtube.com
+// @grant GM.getValue
+// @grant GM.setValue
+// @grant GM.deleteValue
+// @grant GM_getValue
+// @grant GM_setValue
+// @grant GM_deleteValue
// @grant GM_addStyle
// @run-at document-end
// ==/UserScript==
+// This file is generated by `npm run build:userscript`.
+// Edit Extensions/UserScript/src instead of the generated file.
+/******/ (() => { // webpackBootstrap
+/******/ "use strict";
+var __webpack_exports__ = {};
+
+;// CONCATENATED MODULE: ./Extensions/common/vote-client.js
+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 };
+}
+
+
+
+;// CONCATENATED MODULE: ./Extensions/common/vote-transition.js
+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;
+}
+
+
+
+;// CONCATENATED MODULE: ./Extensions/UserScript/src/gm-credential-store.js
+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();
+ },
+ };
+}
+
+
+
+;// CONCATENATED MODULE: ./Extensions/UserScript/src/gm-synthetic-dislike-store.js
+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 gm_synthetic_dislike_store_hasModernMethod(name) {
+ return typeof GM !== "undefined" && typeof GM?.[name] === "function";
+}
+
+async function gm_synthetic_dislike_store_getStoredValue(key, fallbackValue) {
+ if (gm_synthetic_dislike_store_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 gm_synthetic_dislike_store_setStoredValue(key, value) {
+ if (gm_synthetic_dislike_store_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 gm_synthetic_dislike_store_deleteStoredValue(key) {
+ if (gm_synthetic_dislike_store_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 gm_synthetic_dislike_store_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 gm_synthetic_dislike_store_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 ? gm_synthetic_dislike_store_setStoredValue(syntheticDislikeKey(videoId), true) : gm_synthetic_dislike_store_deleteStoredValue(syntheticDislikeKey(videoId)),
+ );
+ },
+ };
+}
+
+
+
+;// CONCATENATED MODULE: ./Extensions/UserScript/userscript-version.json
+const userscript_version_namespaceObject = "3.2.0";
+;// CONCATENATED MODULE: ./Extensions/UserScript/src/userscript-entry.js
+
+
+
+
+
+
+if (false) {}
+
+const API_BASE_URL = "https://returnyoutubedislikeapi.com";
+const fetchImpl = globalThis.fetch.bind(globalThis);
+const voteClient = createVoteClient({
+ apiBaseUrl: API_BASE_URL,
+ fetchImpl,
+ credentialStore: createGmCredentialStore(),
+ cryptoImpl: globalThis.crypto,
+});
+const syntheticDislikeStore = createGmSyntheticDislikeStore();
+
const extConfig = {
// BEGIN USER OPTIONS
// You may change the following variables to allowed values listed in the corresponding brackets (* means default). Keep the style and keywords intact.
@@ -36,25 +598,53 @@ const extConfig = {
numberDisplayRoundDown: true, // [true*, false] Round down numbers (Show rounded down numbers)
tooltipPercentageMode: "none", // [none*, dash_like, dash_dislike, both, only_like, only_dislike] Mode of showing percentage in like/dislike bar tooltip.
numberDisplayReformatLikes: false, // [true, false*] Re-format like numbers (Make likes and dislikes format consistent)
- rateBarEnabled: false, // [true, false*] Enables ratio bar under like/dislike buttons
+ rateBarEnabled: true, // [true*, false] Enables ratio bar under like/dislike buttons
// END USER OPTIONS
};
-const LIKED_STATE = "LIKED_STATE";
-const DISLIKED_STATE = "DISLIKED_STATE";
-const NEUTRAL_STATE = "NEUTRAL_STATE";
-let previousState = 3; //1=LIKED, 2=DISLIKED, 3=NEUTRAL
+let previousState = NEUTRAL_STATE;
let likesvalue = 0;
let dislikesvalue = 0;
-let preNavigateLikeButton = null;
let isMobile = location.hostname == "m.youtube.com";
-let isShorts = () => location.pathname.startsWith("/shorts");
+
+function getShortVideoIdFromPathname(pathname) {
+ return pathname.match(/^\/shorts\/([^/]+)\/?$/)?.[1] ?? null;
+}
+
+let isShorts = () => getShortVideoIdFromPathname(location.pathname) !== null;
let mobileDislikes = 0;
+let suppressNextLikeActivation = false;
+const boundLikeButtons = new WeakSet();
+const boundDislikeButtons = new WeakSet();
+const boundActivationVideoIds = new WeakMap();
+const suppressedStaleRefreshTargets = new WeakSet();
+const removingSyntheticShortsDislikes = new WeakSet();
+const pendingWatchControlResets = new Map();
+let pendingWatchNavigationBoundary = null;
+const hydratingShortsActivationTargets = new WeakMap();
+const shortsHydrationTails = new Map();
+let shortsLifecycleObserver = null;
+let shortsLifecycleObserverTarget = null;
+let initializationGeneration = 0;
+let initializationTimer = null;
+let activeCountRequest = null;
+let countStateVideoId = null;
+let countStateLoaded = false;
+let countStateEpoch = 0;
+let shortsSubmittedStateVideoId = null;
+let shortsSubmittedState = NEUTRAL_STATE;
+let watchRateBarObserver = null;
+let watchRateBarObserverTarget = null;
+let watchRateBarObserverVideoId = null;
+let watchRateBarRepairTimer = null;
+const SYNTHETIC_SHORTS_DISLIKE_SELECTOR = "[data-ryd-synthetic-shorts-dislike]";
+const SHORTS_DISLIKE_ICON_PATH =
+ "m8.482 1.5.294.005a9.01 9.01 0 013.918 1.04l.257.143.203.116c.17.097.357.16.55.185l.194.012h1.477l.115.006c.53.054.95.475 1.004 1.005l.006.114v4.499c0 .621-.504 1.125-1.125 1.125h-1.343a.75.75 0 00-.66.395l-.048.107-2.24 6.402a.75.75 0 01-.832.491l-.78-.13a3 3 0 01-2.439-3.587L7.5 11.25H4.454a2.749 2.749 0 01-2.683-2.151 2.762 2.762 0 01.479-2.237l-.016-.065A2.862 2.862 0 013 4.125v-.032c0-.227.037-.453.108-.668l.08-.211A2.816 2.816 0 015.78 1.5h2.703ZM5.78 3c-.566 0-1.069.362-1.248.9a.613.613 0 00-.031.193v.654l-.44.44c-.333.332-.47.813-.364 1.271l.015.065.157.675-.413.557a1.248 1.248 0 00.999 1.995H7.5a1.501 1.501 0 011.467 1.815L8.5 13.742a1.5 1.5 0 001.22 1.794l.157.027 2.031-5.806a2.25 2.25 0 012.124-1.507H15V4.501h-1.102a3.001 3.001 0 01-1.489-.396l-.202-.116A7.504 7.504 0 008.482 3H5.78Z";
function cLog(text, subtext = "") {
if (!extConfig.disableLogging) {
- subtext = subtext.trim() === "" ? "" : `(${subtext})`;
- console.log(`[Return YouTube Dislikes] ${text} ${subtext}`);
+ subtext = subtext.trim() === "" ? "" : `(${subtext})`;
+ console.log(`[Return YouTube Dislikes] ${text} ${subtext}`);
}
}
@@ -73,16 +663,507 @@ function isInViewport(element) {
);
}
+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 hasRenderedBox(element) {
+ if (!element?.isConnected || element.closest("[hidden], [aria-hidden='true'], [inert]")) {
+ return false;
+ }
+ for (let current = element; current; current = current.parentElement) {
+ const style = 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 getRendererShortVideoIds(renderer) {
+ const identities = new Set();
+ const attributeVideoId = renderer.getAttribute("video-id");
+ if (attributeVideoId) {
+ identities.add(attributeVideoId);
+ return identities;
+ }
+ for (const link of renderer.querySelectorAll('a[href*="/shorts/"]')) {
+ try {
+ const linkVideoId = getShortVideoIdFromPathname(new URL(link.getAttribute("href"), location.origin).pathname);
+ if (linkVideoId) {
+ identities.add(linkVideoId);
+ }
+ } catch {
+ // Ignore malformed or incomplete links while YouTube hydrates the reel.
+ }
+ }
+ return identities;
+}
+
+function rendererMatchesShort(renderer, videoId) {
+ return Boolean(videoId) && getRendererShortVideoIds(renderer).has(videoId);
+}
+
+function getControlOwnershipVideoIds(container) {
+ const identities = new Set();
+ if (!container) {
+ return identities;
+ }
+ const ownedElements = [container, ...container.querySelectorAll("[data-ryd-video-id]")];
+ for (const element of ownedElements) {
+ const ownedVideoId = element.getAttribute?.("data-ryd-video-id");
+ if (ownedVideoId) {
+ identities.add(ownedVideoId);
+ }
+ }
+ for (const target of container.querySelectorAll("button, tp-yt-paper-button#button")) {
+ const boundVideoId = boundActivationVideoIds.get(target);
+ if (boundVideoId) {
+ identities.add(boundVideoId);
+ }
+ }
+ return identities;
+}
+
+function hasConflictingControlOwnership(container, videoId) {
+ return Array.from(getControlOwnershipVideoIds(container)).some((ownedVideoId) => ownedVideoId !== videoId);
+}
+
+function clearPendingWatchControlObservers() {
+ for (const resetState of pendingWatchControlResets.values()) {
+ resetState.observer.disconnect();
+ }
+ pendingWatchControlResets.clear();
+}
+
+function clearPendingWatchNavigationBoundary() {
+ pendingWatchNavigationBoundary?.observer?.disconnect();
+ pendingWatchNavigationBoundary = null;
+}
+
+function activationTargetHasVideoIdentity(target, videoId, buttons) {
+ let current = target;
+ while (current) {
+ if (current.getAttribute?.("video-id") === videoId || current.getAttribute?.("data-video-id") === videoId) {
+ return true;
+ }
+ if (current === buttons) {
+ break;
+ }
+ current = current.parentElement;
+ }
+ return false;
+}
+
+function watchTargetIsReady(targetState, buttons, videoId) {
+ return (
+ !buttons.contains(targetState.activationTarget) ||
+ activationTargetHasVideoIdentity(targetState.activationTarget, videoId, buttons) ||
+ targetState.refreshed
+ );
+}
+
+function originalWatchTargetsAreReady(resetState, buttons, videoId) {
+ return [resetState.like, resetState.dislike].every((targetState) =>
+ watchTargetIsReady(targetState, buttons, videoId),
+ );
+}
+
+function currentWatchTargetIsReady(target, targetState, buttons, videoId) {
+ const boundVideoId = boundActivationVideoIds.get(target);
+ return (
+ !boundVideoId ||
+ boundVideoId === videoId ||
+ activationTargetHasVideoIdentity(target, videoId, buttons) ||
+ (target === targetState.activationTarget && targetState.refreshed)
+ );
+}
+
+function elementIsOwnedDisplayMutation(element, targetState) {
+ if (!element || !targetState.host.contains(element)) {
+ return false;
+ }
+ return Boolean(
+ element.closest(
+ "#text, [role='text'], yt-formatted-string, #return-youtube-dislike-bar-container, #return-youtube-dislike-bar, .ryd-tooltip, [data-ryd-synthetic-shorts-dislike]",
+ ),
+ );
+}
+
+function mutationIsMeaningfulWatchRefresh(mutation, targetState, videoId, buttons) {
+ if (suppressedStaleRefreshTargets.has(targetState.activationTarget)) {
+ return false;
+ }
+ const mutationElement =
+ mutation.target.nodeType === Node.ELEMENT_NODE ? mutation.target : mutation.target.parentElement;
+ if (!mutationElement || !targetState.host.contains(mutationElement)) {
+ return false;
+ }
+
+ if (mutation.type === "attributes") {
+ if (["data-video-id", "video-id"].includes(mutation.attributeName)) {
+ return activationTargetHasVideoIdentity(targetState.activationTarget, videoId, buttons);
+ }
+ return (
+ ["aria-disabled", "aria-label", "disabled", "title"].includes(mutation.attributeName) &&
+ (mutationElement === targetState.activationTarget || mutationElement === targetState.host)
+ );
+ }
+
+ if (mutation.type !== "childList" || elementIsOwnedDisplayMutation(mutationElement, targetState)) {
+ return false;
+ }
+ const changedElements = [...mutation.addedNodes, ...mutation.removedNodes]
+ .map((node) => (node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement))
+ .filter(Boolean);
+ return changedElements.some((element) => !elementIsOwnedDisplayMutation(element, targetState));
+}
+
+function captureMeaningfulWatchRefreshes(resetState, mutations, videoId, buttons) {
+ for (const targetState of [resetState.like, resetState.dislike]) {
+ if (
+ !targetState.refreshed &&
+ mutations.some((mutation) => mutationIsMeaningfulWatchRefresh(mutation, targetState, videoId, buttons))
+ ) {
+ targetState.refreshed = true;
+ }
+ }
+}
+
+function captureWatchNavigationBoundaryRefreshes(boundary, mutations) {
+ for (const targetState of [boundary.like, boundary.dislike]) {
+ if (
+ !targetState.refreshed &&
+ mutations.some((mutation) => mutationIsMeaningfulWatchRefresh(mutation, targetState, "", boundary.buttons))
+ ) {
+ targetState.refreshed = true;
+ }
+ }
+}
+
+function seedWatchTargetFromNavigationBoundary(targetState, boundaryTargetState, buttons, videoId) {
+ if (targetState.activationTarget === boundaryTargetState.activationTarget) {
+ targetState.refreshed ||= boundaryTargetState.refreshed;
+ return;
+ }
+
+ const boundVideoId = boundActivationVideoIds.get(targetState.activationTarget);
+ targetState.refreshed ||=
+ !boundVideoId ||
+ boundVideoId === videoId ||
+ activationTargetHasVideoIdentity(targetState.activationTarget, videoId, buttons);
+}
+
+function seedWatchResetFromNavigationBoundary(resetState, buttons, videoId) {
+ const boundary = pendingWatchNavigationBoundary;
+ if (!boundary || boundary.sourceVideoId === videoId) {
+ return;
+ }
+
+ captureWatchNavigationBoundaryRefreshes(boundary, boundary.observer.takeRecords());
+ clearPendingWatchNavigationBoundary();
+ if (boundary.buttons !== buttons) {
+ return;
+ }
+
+ seedWatchTargetFromNavigationBoundary(resetState.like, boundary.like, buttons, videoId);
+ seedWatchTargetFromNavigationBoundary(resetState.dislike, boundary.dislike, buttons, videoId);
+}
+
+function suppressStaleTargetRefresh(target) {
+ suppressedStaleRefreshTargets.add(target);
+ setTimeout(() => suppressedStaleRefreshTargets.delete(target), 0);
+}
+
+function watchControlsAreReadyForVideo(buttons, likeButton, dislikeButton, videoId) {
+ if (!hasConflictingControlOwnership(buttons, videoId)) {
+ clearPendingWatchControlObservers();
+ if (pendingWatchNavigationBoundary?.sourceVideoId !== videoId) {
+ clearPendingWatchNavigationBoundary();
+ }
+ return true;
+ }
+
+ // YouTube occasionally completes a watch-to-watch navigation while reusing
+ // the exact same reaction-control nodes and without mutating anything inside
+ // them. In that state the old per-node ownership is the only conflicting
+ // signal, so waiting for a control mutation can never finish. A completed
+ // navigation plus a matching current watch root is the route-level ownership
+ // proof for this otherwise indistinguishable case.
+ const watchRoot = buttons.closest("ytd-watch-flexy, ytd-watch-grid");
+ if (
+ pendingWatchNavigationBoundary?.completedVideoId === videoId &&
+ pendingWatchNavigationBoundary.sourceVideoId !== videoId &&
+ pendingWatchNavigationBoundary.buttons === buttons &&
+ watchRoot?.getAttribute("video-id") === videoId &&
+ getButtons() === buttons &&
+ hasRenderedBox(buttons)
+ ) {
+ clearPendingWatchControlObservers();
+ clearPendingWatchNavigationBoundary();
+ return true;
+ }
+
+ const existingReset = pendingWatchControlResets.get(buttons);
+ if (existingReset?.videoId === videoId) {
+ seedWatchResetFromNavigationBoundary(existingReset, buttons, videoId);
+ if (!originalWatchTargetsAreReady(existingReset, buttons, videoId)) {
+ return false;
+ }
+
+ const currentLikeButton = getLikeButton();
+ const currentDislikeButton = getDislikeButton();
+ if (!currentLikeButton || !currentDislikeButton || getButtons() !== buttons) {
+ return false;
+ }
+ const currentLikeTarget = getActivationTarget(currentLikeButton);
+ const currentDislikeTarget = getActivationTarget(currentDislikeButton);
+ if (
+ !currentWatchTargetIsReady(currentLikeTarget, existingReset.like, buttons, videoId) ||
+ !currentWatchTargetIsReady(currentDislikeTarget, existingReset.dislike, buttons, videoId)
+ ) {
+ return false;
+ }
+
+ existingReset.observer.disconnect();
+ pendingWatchControlResets.delete(buttons);
+ return true;
+ }
+
+ existingReset?.observer.disconnect();
+ const likeActivationTarget = getActivationTarget(likeButton);
+ const dislikeActivationTarget = getActivationTarget(dislikeButton);
+ const resetState = {
+ dislike: {
+ activationTarget: dislikeActivationTarget,
+ host: dislikeButton,
+ refreshed: false,
+ },
+ like: {
+ activationTarget: likeActivationTarget,
+ host: likeButton,
+ refreshed: false,
+ },
+ observer: null,
+ videoId,
+ };
+ seedWatchResetFromNavigationBoundary(resetState, buttons, videoId);
+ if (
+ originalWatchTargetsAreReady(resetState, buttons, videoId) &&
+ currentWatchTargetIsReady(likeActivationTarget, resetState.like, buttons, videoId) &&
+ currentWatchTargetIsReady(dislikeActivationTarget, resetState.dislike, buttons, videoId)
+ ) {
+ pendingWatchControlResets.delete(buttons);
+ return true;
+ }
+ const observer = new MutationObserver((mutations) => {
+ captureMeaningfulWatchRefreshes(resetState, mutations, videoId, buttons);
+ if (originalWatchTargetsAreReady(resetState, buttons, videoId)) {
+ observer.disconnect();
+ setEventListeners();
+ }
+ });
+ resetState.observer = observer;
+ pendingWatchControlResets.set(buttons, resetState);
+ observer.observe(buttons, {
+ attributeFilter: ["aria-disabled", "aria-label", "data-video-id", "disabled", "title", "video-id"],
+ attributes: true,
+ childList: true,
+ subtree: true,
+ });
+ return false;
+}
+
+function getActiveDesktopShortsActionBar() {
+ const videoId = getVideoId();
+ const candidates = Array.from(document.querySelectorAll("ytd-reel-video-renderer"))
+ .filter((renderer) => intersectsViewport(renderer))
+ .map((renderer) => ({
+ actionBar: renderer.querySelector("reel-action-bar-view-model"),
+ renderer,
+ }))
+ .filter(({ actionBar }) => actionBar);
+
+ const matchingCandidate = candidates.find(({ renderer }) => rendererMatchesShort(renderer, videoId));
+ if (matchingCandidate) {
+ return matchingCandidate.actionBar;
+ }
+
+ // During channel/watch -> Shorts SPA transitions, YouTube can render the active
+ // reel before its video-id/link metadata is hydrated. A single visible reel is
+ // still unambiguous; waiting for metadata in this state leaves the controls
+ // permanently uninitialized on some page variants.
+ if (
+ candidates.length === 1 &&
+ getRendererShortVideoIds(candidates[0].renderer).size === 0 &&
+ !hasConflictingControlOwnership(candidates[0].actionBar, videoId)
+ ) {
+ return candidates[0].actionBar;
+ }
+
+ const fullyVisibleCandidates = candidates.filter(
+ ({ actionBar, renderer }) =>
+ isInViewport(renderer) &&
+ getRendererShortVideoIds(renderer).size === 0 &&
+ !hasConflictingControlOwnership(actionBar, videoId),
+ );
+ return fullyVisibleCandidates.length === 1 ? fullyVisibleCandidates[0].actionBar : null;
+}
+
+function getActiveMobileShortsButtons() {
+ const videoId = getVideoId();
+ const candidates = Array.from(document.querySelectorAll("ytm-like-button-renderer"))
+ .filter((buttons) => isInViewport(buttons))
+ .map((buttons) => ({ buttons, ...getMobileShortOwnership(buttons) }));
+
+ const matchingCandidates = candidates.filter(({ identities }) => identities.has(videoId));
+ if (matchingCandidates.length === 1) {
+ return matchingCandidates[0].buttons;
+ }
+
+ if (
+ candidates.length === 1 &&
+ candidates[0].identities.size === 0 &&
+ !hasConflictingControlOwnership(candidates[0].buttons, videoId)
+ ) {
+ return candidates[0].buttons;
+ }
+ return null;
+}
+
+function getExactShortLinkVideoIds(element) {
+ const identities = new Set();
+ for (const link of element.querySelectorAll('a[href*="/shorts/"]')) {
+ try {
+ const linkVideoId = getShortVideoIdFromPathname(new URL(link.getAttribute("href"), location.origin).pathname);
+ if (linkVideoId) {
+ identities.add(linkVideoId);
+ }
+ } catch {
+ // Ignore malformed or incomplete links while YouTube hydrates the reel.
+ }
+ }
+ return identities;
+}
+
+function getMobileShortOwnership(buttons) {
+ const ancestors = [];
+ let current = buttons;
+ while (current && current !== document.body) {
+ if (current.matches("ytm-shorts, ytm-shorts-container, #shorts-container, #shorts-inner-container")) {
+ break;
+ }
+ ancestors.push(current);
+ current = current.parentElement;
+ }
+
+ for (const ancestor of ancestors) {
+ const attributeVideoId = ancestor.getAttribute("video-id") || ancestor.getAttribute("data-video-id");
+ if (attributeVideoId) {
+ return { identities: new Set([attributeVideoId]), owner: ancestor };
+ }
+ }
+
+ for (const ancestor of ancestors) {
+ const identities = getExactShortLinkVideoIds(ancestor);
+ if (identities.size > 0) {
+ return { identities, owner: ancestor };
+ }
+ }
+
+ const owner =
+ ancestors.find((ancestor) => ancestor.matches("ytm-reel-video-renderer, ytm-shorts-video-renderer")) ??
+ ancestors.find((ancestor) => ancestor.matches("ytm-reel-player-overlay-renderer")) ??
+ buttons;
+ return { identities: new Set(), owner };
+}
+
+function getDesktopWatchButtonCandidates() {
+ return Array.from(
+ new Set(
+ document.querySelectorAll(
+ "#menu-container #top-level-buttons-computed, ytd-menu-renderer.ytd-watch-metadata > div, ytd-menu-renderer.ytd-video-primary-info-renderer > div",
+ ),
+ ),
+ ).filter((candidate) =>
+ candidate.querySelector(
+ "segmented-like-dislike-button-view-model, ytd-segmented-like-dislike-button-renderer, like-button-view-model, #segmented-like-button",
+ ),
+ );
+}
+
+function selectCurrentWatchButtons(candidates) {
+ const videoId = getVideoId();
+ return (
+ 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 rendered = hasRenderedBox(candidate);
+ const inViewport = rendered && intersectsViewport(candidate);
+ const conflicts = Boolean(videoId && hasConflictingControlOwnership(candidate, videoId));
+ // YouTube can retain several button groups under the same current
+ // ytd-watch-flexy while an SPA navigation settles. A matching root is
+ // therefore useful ownership evidence, but it must never make a
+ // hidden stale group outrank the rendered controls the user can see.
+ const tier =
+ rootMatches && inViewport && !conflicts
+ ? 10
+ : rootMatches && inViewport
+ ? 9
+ : inViewport && !conflicts
+ ? 8
+ : inViewport
+ ? 7
+ : rootMatches && rendered && !conflicts
+ ? 6
+ : rootMatches && rendered
+ ? 5
+ : rendered && !conflicts
+ ? 4
+ : rendered
+ ? 3
+ : rootMatches && !conflicts
+ ? 2
+ : !conflicts
+ ? 1
+ : 0;
+ return { candidate, index, tier };
+ })
+ .sort((left, right) => right.tier - left.tier || left.index - right.index)[0]?.candidate ?? null
+ );
+}
+
function getButtons() {
if (isShorts()) {
- let elements = document.querySelectorAll(
- isMobile ? "ytm-like-button-renderer" : "#like-button > ytd-like-button-renderer",
- );
- for (let element of elements) {
- if (isInViewport(element)) {
- return element;
+ if (!isMobile) {
+ const actionBar = getActiveDesktopShortsActionBar();
+ if (actionBar) {
+ return actionBar;
+ }
+ } else {
+ const buttons = getActiveMobileShortsButtons();
+ if (buttons) {
+ return buttons;
}
}
+
+ // Never bind watch/channel controls that are still connected while a Shorts
+ // SPA route is mounting. The initialization retry loop will pick up the real
+ // reel controls as soon as they are available.
+ return null;
}
if (isMobile) {
return (
@@ -90,40 +1171,150 @@ function getButtons() {
document.querySelector(".slim-video-action-bar-actions")
);
}
- if (document.getElementById("menu-container")?.offsetParent === null) {
- return (
- document.querySelector("ytd-menu-renderer.ytd-watch-metadata > div") ??
- document.querySelector("ytd-menu-renderer.ytd-video-primary-info-renderer > div")
- );
- } else {
- return document.getElementById("menu-container")?.querySelector("#top-level-buttons-computed");
+ return selectCurrentWatchButtons(getDesktopWatchButtonCandidates());
+}
+
+function removeSyntheticShortsDislike(syntheticDislike) {
+ if (!syntheticDislike || removingSyntheticShortsDislikes.has(syntheticDislike)) {
+ return;
+ }
+ removingSyntheticShortsDislikes.add(syntheticDislike);
+ try {
+ syntheticDislike.remove();
+ } finally {
+ removingSyntheticShortsDislikes.delete(syntheticDislike);
}
}
+function ensureSyntheticShortsDislikeButton(buttons) {
+ if (!isShorts() || isMobile || !buttons) {
+ return;
+ }
+
+ const syntheticDislike = buttons.querySelector(SYNTHETIC_SHORTS_DISLIKE_SELECTOR);
+ const nativeDislike = buttons.querySelector("dislike-button-view-model, #dislike-button");
+ if (nativeDislike) {
+ removeSyntheticShortsDislike(syntheticDislike);
+ return;
+ }
+ if (syntheticDislike) {
+ const videoId = getVideoId();
+ if (videoId && syntheticDislike.getAttribute("data-ryd-video-id") !== videoId) {
+ syntheticDislike.setAttribute("data-ryd-video-id", videoId);
+ setSyntheticShortsPressed(false, syntheticDislike);
+ const button = syntheticDislike.querySelector("button");
+ if (button) {
+ button.disabled = true;
+ button.setAttribute("aria-disabled", "true");
+ }
+ const count = syntheticDislike.querySelector("#text, [role='text']");
+ if (count) {
+ count.textContent = "";
+ }
+ }
+ return;
+ }
+
+ const likeButton = buttons.querySelector("like-button-view-model");
+ const nativeLikeButton = likeButton?.querySelector("button");
+ if (!likeButton || !nativeLikeButton) {
+ return;
+ }
+
+ const ownedDislike = document.createElement("div");
+ ownedDislike.className = likeButton.getAttribute("class") || "";
+ ownedDislike.setAttribute("data-ryd-synthetic-shorts-dislike", "true");
+ ownedDislike.setAttribute("data-ryd-role", "dislike");
+ ownedDislike.setAttribute("data-ryd-video-id", getVideoId());
+ ownedDislike.classList.add("ryd-synthetic-shorts-dislike");
+
+ const button = document.createElement("button");
+ button.type = "button";
+ button.className = nativeLikeButton.className;
+ button.setAttribute("aria-label", "Dislike this video");
+ button.setAttribute("aria-pressed", "false");
+ button.setAttribute("aria-disabled", "true");
+ button.disabled = true;
+
+ const icon = document.createElement("div");
+ icon.className =
+ nativeLikeButton.querySelector(".ytSpecButtonShapeNextIcon")?.getAttribute("class") || "ytSpecButtonShapeNextIcon";
+ icon.setAttribute("aria-hidden", "true");
+ const svgNamespace = "http://www.w3.org/2000/svg";
+ const svg = document.createElementNS(svgNamespace, "svg");
+ svg.setAttribute("height", "24");
+ svg.setAttribute("viewBox", "0 0 18 18");
+ svg.setAttribute("width", "24");
+ svg.setAttribute("focusable", "false");
+ svg.setAttribute("aria-hidden", "true");
+ const path = document.createElementNS(svgNamespace, "path");
+ path.setAttribute("d", SHORTS_DISLIKE_ICON_PATH);
+ svg.appendChild(path);
+ icon.appendChild(svg);
+
+ const countContainer = document.createElement("div");
+ countContainer.className =
+ likeButton.querySelector(".ytSpecButtonShapeWithLabelLabel")?.className || "ytSpecButtonShapeWithLabelLabel";
+ const count = document.createElement("span");
+ count.id = "text";
+ count.className =
+ likeButton.querySelector('span[role="text"]')?.className ||
+ "ytAttributedStringHost ytAttributedStringTextAlignmentCenter";
+ count.setAttribute("role", "text");
+ countContainer.appendChild(count);
+ button.appendChild(icon);
+ const buttonAndCount = document.createElement("label");
+ buttonAndCount.className = nativeLikeButton.closest("label")?.className || "ytSpecButtonShapeWithLabelHost";
+ buttonAndCount.classList.add("ryd-synthetic-shorts-dislike-label");
+ buttonAndCount.append(button, countContainer);
+ ownedDislike.appendChild(buttonAndCount);
+ setSyntheticShortsPressed(false, ownedDislike);
+ likeButton.insertAdjacentElement("afterend", ownedDislike);
+}
+
function getDislikeButton() {
- if (getButtons().children[0].tagName === "YTD-SEGMENTED-LIKE-DISLIKE-BUTTON-RENDERER") {
- if (getButtons().children[0].children[1] === undefined) {
- return document.querySelector("#segmented-dislike-button");
+ const buttons = getButtons();
+ ensureSyntheticShortsDislikeButton(buttons);
+ if (buttons?.tagName === "REEL-ACTION-BAR-VIEW-MODEL") {
+ return (
+ buttons.querySelector("dislike-button-view-model, #dislike-button") ??
+ buttons.querySelector(SYNTHETIC_SHORTS_DISLIKE_SELECTOR)
+ );
+ }
+ const firstButton = buttons?.children?.[0];
+ if (!firstButton) {
+ return null;
+ }
+
+ if (firstButton.tagName === "YTD-SEGMENTED-LIKE-DISLIKE-BUTTON-RENDERER") {
+ if (firstButton.children[1] === undefined) {
+ return buttons.querySelector("#segmented-dislike-button");
} else {
- return getButtons().children[0].children[1];
+ return firstButton.children[1];
}
} else {
- if (getButtons().querySelector("segmented-like-dislike-button-view-model")) {
- const dislikeViewModel = getButtons().querySelector("dislike-button-view-model");
+ if (buttons.querySelector("segmented-like-dislike-button-view-model")) {
+ const dislikeViewModel = buttons.querySelector("dislike-button-view-model");
if (!dislikeViewModel) cLog("Dislike button wasn't added to DOM yet...");
return dislikeViewModel;
} else {
- return getButtons().children[1];
+ return buttons.children[1] ?? null;
}
}
}
function getLikeButton() {
- return getButtons().children[0].tagName === "YTD-SEGMENTED-LIKE-DISLIKE-BUTTON-RENDERER"
- ? document.querySelector("#segmented-like-button") !== null
- ? document.querySelector("#segmented-like-button")
- : getButtons().children[0].children[0]
- : getButtons().querySelector("like-button-view-model") ?? getButtons().children[0];
+ const buttons = getButtons();
+ const firstButton = buttons?.children?.[0];
+ if (!firstButton) {
+ return null;
+ }
+
+ return firstButton.tagName === "YTD-SEGMENTED-LIKE-DISLIKE-BUTTON-RENDERER"
+ ? buttons.querySelector("#segmented-like-button") !== null
+ ? buttons.querySelector("#segmented-like-button")
+ : firstButton.children[0]
+ : buttons.querySelector("like-button-view-model") ?? firstButton;
}
function getLikeTextContainer() {
@@ -151,6 +1342,45 @@ function getDislikeTextContainer() {
return result;
}
+function setSyntheticShortsPressed(pressed, dislikeButton = getDislikeButton()) {
+ if (!dislikeButton?.matches(SYNTHETIC_SHORTS_DISLIKE_SELECTOR)) {
+ return;
+ }
+ dislikeButton.classList.toggle("style-default-active", pressed);
+ dislikeButton.classList.toggle("style-text", !pressed);
+ dislikeButton.querySelector("button")?.setAttribute("aria-pressed", String(pressed));
+}
+
+function persistSyntheticShortsState(videoId, disliked) {
+ void syntheticDislikeStore.setDisliked(videoId, disliked).catch(reportVoteFailure);
+}
+
+async function readSyntheticShortsDisliked(videoId) {
+ try {
+ return await syntheticDislikeStore.isDisliked(videoId);
+ } catch (error) {
+ reportVoteFailure(error);
+ return false;
+ }
+}
+
+async function restoreSyntheticShortsState(
+ videoId,
+ dislikeButton = getDislikeButton(),
+ initialVisibleState = getState(),
+) {
+ if (!dislikeButton?.matches(SYNTHETIC_SHORTS_DISLIKE_SELECTOR)) {
+ return false;
+ }
+
+ const disliked = await readSyntheticShortsDisliked(videoId);
+
+ return {
+ disliked,
+ submittedState: initialVisibleState === LIKED_STATE ? LIKED_STATE : disliked ? DISLIKED_STATE : initialVisibleState,
+ };
+}
+
function createObserver(options, callback) {
const observerWrapper = new Object();
observerWrapper.options = options;
@@ -166,48 +1396,54 @@ function createObserver(options, callback) {
let shortsObserver = null;
-if (isShorts() && !shortsObserver) {
+function getShortsObserver() {
+ if (shortsObserver) {
+ return shortsObserver;
+ }
cLog("Initializing shorts mutation observer");
shortsObserver = createObserver(
{
attributes: true,
+ attributeFilter: ["aria-pressed"],
},
(mutationList) => {
mutationList.forEach((mutation) => {
- if (
- mutation.type === "attributes" &&
- mutation.target.nodeName === "TP-YT-PAPER-BUTTON" &&
- mutation.target.id === "button"
- ) {
+ if (mutation.type === "attributes") {
cLog("Short thumb button status changed");
if (mutation.target.getAttribute("aria-pressed") === "true") {
- mutation.target.style.color =
- mutation.target.parentElement.parentElement.id === "like-button"
- ? getColorFromTheme(true)
- : getColorFromTheme(false);
+ mutation.target.style.color = mutation.target.closest("like-button-view-model")
+ ? getColorFromTheme(true)
+ : getColorFromTheme(false);
} else {
mutation.target.style.color = "unset";
}
- return;
}
- cLog("Unexpected mutation observer event: " + mutation.target + mutation.type);
});
},
);
+ return shortsObserver;
}
function isVideoLiked() {
+ const likeButton = getLikeButton();
+ const nativeButton = likeButton?.querySelector("button");
if (isMobile) {
- return getLikeButton().querySelector("button").getAttribute("aria-label") == "true";
+ return nativeButton?.getAttribute("aria-pressed") === "true" || nativeButton?.getAttribute("aria-label") === "true";
}
- return getLikeButton().classList.contains("style-default-active");
+ return (
+ likeButton?.classList.contains("style-default-active") || nativeButton?.getAttribute("aria-pressed") === "true"
+ );
}
function isVideoDisliked() {
+ const dislikeButton = getDislikeButton();
+ const nativeButton = dislikeButton?.querySelector("button");
if (isMobile) {
- return getDislikeButton()?.querySelector("button").getAttribute("aria-label") == "true";
+ return nativeButton?.getAttribute("aria-pressed") === "true" || nativeButton?.getAttribute("aria-label") === "true";
}
- return getDislikeButton()?.classList.contains("style-default-active");
+ return (
+ dislikeButton?.classList.contains("style-default-active") || nativeButton?.getAttribute("aria-pressed") === "true"
+ );
}
function isVideoNotLiked() {
@@ -224,15 +1460,9 @@ function isVideoNotDisliked() {
return getDislikeButton()?.classList.contains("style-text");
}
-function checkForUserAvatarButton() {
- if (isMobile) {
- return;
- }
- if (document.querySelector("#avatar-btn")) {
- return true;
- } else {
- return false;
- }
+function isSignedOut() {
+ const signInLink = document.querySelector("a[href^='https://accounts.google.com/ServiceLogin']");
+ return signInLink !== null || (!isMobile && document.querySelector("#avatar-btn") === null);
}
function getState() {
@@ -260,8 +1490,11 @@ function setDislikes(dislikesCount) {
}
const _container = getDislikeTextContainer();
- _container?.removeAttribute("is-empty");
- if (_container?.innerText !== dislikesCount) {
+ if (!_container) {
+ return;
+ }
+ _container.removeAttribute("is-empty");
+ if (_container.innerText !== dislikesCount) {
_container.innerText = dislikesCount;
}
}
@@ -292,7 +1525,12 @@ function getLikeCountFromButton() {
document.head.appendChild(styleNode);
})(`
#return-youtube-dislike-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;
}
@@ -302,11 +1540,52 @@ function getLikeCountFromButton() {
transition: all 0.15s ease-in-out;
}
+ .ryd-synthetic-shorts-dislike svg {
+ display: block;
+ fill: currentColor;
+ height: 24px;
+ pointer-events: none;
+ width: 24px;
+ }
+
+ .ryd-synthetic-shorts-dislike {
+ box-sizing: content-box;
+ display: block;
+ flex: 0 0 auto;
+ height: 70px;
+ margin: 0 !important;
+ padding: 0 0 8px;
+ width: 100%;
+ }
+
+ .ryd-synthetic-shorts-dislike-label {
+ align-items: center;
+ display: flex;
+ flex-direction: column;
+ }
+
+ .ryd-synthetic-shorts-dislike .ytSpecButtonShapeNextIcon {
+ flex: 0 0 24px;
+ height: 24px;
+ min-width: 24px;
+ width: 24px;
+ }
+
+ .ryd-synthetic-shorts-dislike button {
+ color: inherit;
+ cursor: pointer;
+ }
+
+ .ryd-synthetic-shorts-dislike button[aria-pressed="true"] {
+ color: var(--yt-spec-call-to-action, #3ea6ff);
+ }
+
.ryd-tooltip {
- position: absolute;
+ bottom: -10px;
display: block;
height: 2px;
- bottom: -10px;
+ outline: none;
+ position: absolute;
}
.ryd-tooltip-bar-container {
@@ -318,22 +1597,84 @@ function getLikeCountFromButton() {
top: -6px;
}
+ .ryd-tooltip-label {
+ background: rgba(28, 28, 28, 0.96);
+ border-radius: 4px;
+ bottom: 10px;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35);
+ box-sizing: border-box;
+ color: #fff;
+ font-family: Roboto, Arial, sans-serif;
+ font-size: 12px;
+ font-weight: 500;
+ line-height: 16px;
+ max-width: calc(100vw - 24px);
+ opacity: 0;
+ overflow: hidden;
+ padding: 6px 8px;
+ pointer-events: none;
+ position: absolute;
+ right: 0;
+ text-overflow: ellipsis;
+ transform: translateY(4px);
+ transition: opacity 0.12s ease-out, transform 0.12s ease-out, visibility 0s linear 0.12s;
+ visibility: hidden;
+ white-space: nowrap;
+ width: max-content;
+ z-index: 2200;
+ }
+
+ .ryd-tooltip:hover .ryd-tooltip-label,
+ .ryd-tooltip:focus-within .ryd-tooltip-label {
+ opacity: 1;
+ transform: translateY(0);
+ transition-delay: 0s;
+ visibility: visible;
+ }
+
+ .ryd-tooltip:focus-visible .ryd-tooltip-bar-container {
+ outline: 2px solid var(--yt-spec-call-to-action, #3ea6ff);
+ outline-offset: 2px;
+ }
+
ytd-menu-renderer.ytd-watch-metadata {
overflow-y: visible !important;
}
-
+
#top-level-buttons-computed {
position: relative !important;
}
`);
function createRateBar(likes, dislikes) {
- if (isMobile || !extConfig.rateBarEnabled) {
+ if (isMobile || isShorts() || !extConfig.rateBarEnabled) {
return;
}
- let rateBar = document.getElementById("return-youtube-dislike-bar-container");
- const widthPx = getLikeButton().clientWidth + (getDislikeButton()?.clientWidth ?? 52);
+ const buttons = getButtons();
+ const likeButton = getLikeButton();
+ const dislikeButton = getDislikeButton();
+ if (!buttons || !likeButton) {
+ return;
+ }
+
+ // YouTube retains the outgoing watch metadata tree during some SPA
+ // transitions. A document-wide ID lookup can therefore find the old bar,
+ // update it with the new video's counts, and then lose it when YouTube
+ // removes that stale tree. Keep the single owned bar scoped to the active
+ // reaction controls instead.
+ for (const candidate of document.querySelectorAll("#return-youtube-dislike-bar-container")) {
+ if (!buttons.contains(candidate)) {
+ (candidate.closest(".ryd-tooltip") ?? candidate).remove();
+ }
+ }
+ let rateBar = buttons.querySelector("#return-youtube-dislike-bar-container");
+ if (rateBar && !watchRateBarIsHealthy(buttons, getVideoId())) {
+ removeWatchRateBarArtifacts(buttons);
+ rateBar = null;
+ }
+
+ const widthPx = likeButton.clientWidth + (dislikeButton?.clientWidth ?? 52);
const widthPercent = likes + dislikes > 0 ? (likes / (likes + dislikes)) * 100 : 50;
@@ -341,111 +1682,343 @@ function createRateBar(likes, dislikes) {
const dislikePercentage = (100 - likePercentage).toLocaleString();
likePercentage = likePercentage.toLocaleString();
- var tooltipInnerHTML;
+ const separator = "\u00a0/\u00a0";
+ const percentageSeparator = "\u00a0\u00a0-\u00a0\u00a0";
+ let tooltipText;
switch (extConfig.tooltipPercentageMode) {
case "dash_like":
- tooltipInnerHTML = `${likes.toLocaleString()} / ${dislikes.toLocaleString()} - ${likePercentage}%`;
+ tooltipText = `${likes.toLocaleString()}${separator}${dislikes.toLocaleString()}${percentageSeparator}${likePercentage}%`;
break;
case "dash_dislike":
- tooltipInnerHTML = `${likes.toLocaleString()} / ${dislikes.toLocaleString()} - ${dislikePercentage}%`;
+ tooltipText = `${likes.toLocaleString()}${separator}${dislikes.toLocaleString()}${percentageSeparator}${dislikePercentage}%`;
break;
case "both":
- tooltipInnerHTML = `${likePercentage}% / ${dislikePercentage}%`;
+ tooltipText = `${likePercentage}%${separator}${dislikePercentage}%`;
break;
case "only_like":
- tooltipInnerHTML = `${likePercentage}%`;
+ tooltipText = `${likePercentage}%`;
break;
case "only_dislike":
- tooltipInnerHTML = `${dislikePercentage}%`;
+ tooltipText = `${dislikePercentage}%`;
break;
default:
- tooltipInnerHTML = `${likes.toLocaleString()} / ${dislikes.toLocaleString()}`;
+ tooltipText = `${likes.toLocaleString()}${separator}${dislikes.toLocaleString()}`;
}
if (!rateBar && !isMobile) {
- let colorLikeStyle = "";
- let colorDislikeStyle = "";
+ const tooltip = document.createElement("div");
+ tooltip.className = "ryd-tooltip";
+ tooltip.setAttribute("data-ryd-rate-bar-wrapper", "true");
+ tooltip.setAttribute("data-ryd-video-id", getVideoId());
+ tooltip.style.width = `${widthPx}px`;
+ tooltip.setAttribute("aria-describedby", "ryd-dislike-tooltip");
+ tooltip.setAttribute("tabindex", "0");
+
+ const tooltipBarContainer = document.createElement("div");
+ tooltipBarContainer.className = "ryd-tooltip-bar-container";
+
+ rateBar = document.createElement("div");
+ rateBar.id = "return-youtube-dislike-bar-container";
+ rateBar.style.width = "100%";
+ rateBar.style.height = "2px";
+
+ const rateBarFill = document.createElement("div");
+ rateBarFill.id = "return-youtube-dislike-bar";
+ rateBarFill.style.width = `${widthPercent}%`;
+ rateBarFill.style.height = "100%";
if (extConfig.coloredBar) {
- colorLikeStyle = "; background-color: " + getColorFromTheme(true);
- colorDislikeStyle = "; background-color: " + getColorFromTheme(false);
+ rateBar.style.backgroundColor = getColorFromTheme(false);
+ rateBarFill.style.backgroundColor = getColorFromTheme(true);
+ }
+ rateBar.appendChild(rateBarFill);
+ tooltipBarContainer.appendChild(rateBar);
+
+ const tooltipLabel = document.createElement("div");
+ tooltipLabel.id = "ryd-dislike-tooltip";
+ tooltipLabel.className = "ryd-tooltip-label";
+ tooltipLabel.setAttribute("role", "tooltip");
+ tooltipLabel.textContent = tooltipText;
+
+ tooltip.append(tooltipBarContainer, tooltipLabel);
+ buttons.appendChild(tooltip);
+ const descriptionAndActionsElement = buttons.closest("#top-row");
+ if (descriptionAndActionsElement) {
+ descriptionAndActionsElement.style.borderBottom = "1px solid var(--yt-spec-10-percent-layer)";
+ descriptionAndActionsElement.style.paddingBottom = "10px";
+ }
+ } else {
+ const tooltip = rateBar.closest(".ryd-tooltip");
+ const rateBarFill = rateBar.querySelector("#return-youtube-dislike-bar");
+ if (!tooltip || !rateBarFill) {
+ (tooltip ?? rateBar).remove();
+ createRateBar(likes, dislikes);
+ return;
+ }
+ tooltip.setAttribute("data-ryd-video-id", getVideoId());
+ tooltip.style.width = widthPx + "px";
+ rateBarFill.style.width = widthPercent + "%";
+ const tooltipLabel = tooltip.querySelector("#ryd-dislike-tooltip");
+ if (tooltipLabel) {
+ tooltipLabel.textContent = tooltipText;
}
- getButtons().insertAdjacentHTML(
- "beforeend",
- `
-
-`,
- );
- let descriptionAndActionsElement = document.getElementById("top-row");
- descriptionAndActionsElement.style.borderBottom = "1px solid var(--yt-spec-10-percent-layer)";
- descriptionAndActionsElement.style.paddingBottom = "10px";
- } else {
- document.querySelector(".ryd-tooltip").style.width = widthPx + "px";
- document.getElementById("return-youtube-dislike-bar").style.width = widthPercent + "%";
-
if (extConfig.coloredBar) {
- document.getElementById("return-youtube-dislike-bar-container").style.backgroundColor = getColorFromTheme(false);
- document.getElementById("return-youtube-dislike-bar").style.backgroundColor = getColorFromTheme(true);
+ rateBar.style.backgroundColor = getColorFromTheme(false);
+ rateBarFill.style.backgroundColor = getColorFromTheme(true);
}
}
}
-function setState() {
- cLog("Fetching votes...");
- let statsSet = false;
+function elementTouchesWatchRateBar(element) {
+ return Boolean(
+ element?.matches?.(
+ ".ryd-tooltip, .ryd-tooltip-bar-container, .ryd-tooltip-label, #return-youtube-dislike-bar-container, #return-youtube-dislike-bar, #ryd-dislike-tooltip",
+ ) ||
+ element?.matches?.('[data-ryd-rate-bar-wrapper="true"]') ||
+ element?.closest?.(".ryd-tooltip, .ryd-tooltip-bar-container, #return-youtube-dislike-bar-container") ||
+ element?.querySelector?.(
+ ".ryd-tooltip, .ryd-tooltip-bar-container, .ryd-tooltip-label, #return-youtube-dislike-bar-container, #return-youtube-dislike-bar, #ryd-dislike-tooltip",
+ ),
+ );
+}
- fetch(`https://returnyoutubedislikeapi.com/votes?videoId=${getVideoId()}`).then((response) => {
- response.json().then((json) => {
- if (json && !("traceId" in response) && !statsSet) {
+function mutationTouchesWatchRateBar(mutation) {
+ if (mutation.type === "attributes") {
+ return elementTouchesWatchRateBar(mutation.target);
+ }
+ return (
+ mutation.type === "childList" &&
+ [...mutation.addedNodes, ...mutation.removedNodes].some(
+ (node) => node.nodeType === Node.ELEMENT_NODE && elementTouchesWatchRateBar(node),
+ )
+ );
+}
+
+function watchRateBarIsHealthy(buttons, videoId) {
+ if (!buttons || !videoId) {
+ return false;
+ }
+
+ const wrappers = Array.from(buttons.querySelectorAll('[data-ryd-rate-bar-wrapper="true"]'));
+ const containers = Array.from(buttons.querySelectorAll("#return-youtube-dislike-bar-container"));
+ const fills = Array.from(buttons.querySelectorAll("#return-youtube-dislike-bar"));
+ const labels = Array.from(buttons.querySelectorAll("#ryd-dislike-tooltip"));
+ if (wrappers.length !== 1 || containers.length !== 1 || fills.length !== 1 || labels.length !== 1) {
+ return false;
+ }
+
+ const [wrapper] = wrappers;
+ const [container] = containers;
+ const [fill] = fills;
+ const [label] = labels;
+ if (
+ wrapper.parentElement !== buttons ||
+ !wrapper.matches(".ryd-tooltip") ||
+ wrapper.getAttribute("data-ryd-video-id") !== videoId ||
+ !wrapper.contains(container) ||
+ !container.contains(fill) ||
+ !wrapper.contains(label) ||
+ !hasRenderedBox(wrapper) ||
+ !hasRenderedBox(container)
+ ) {
+ return false;
+ }
+
+ const fillStyle = getComputedStyle(fill);
+ const fillBounds = fill.getBoundingClientRect();
+ return (
+ fillStyle.display !== "none" &&
+ fillStyle.visibility !== "hidden" &&
+ fillStyle.visibility !== "collapse" &&
+ Number.parseFloat(fillStyle.opacity) !== 0 &&
+ fillBounds.height > 0
+ );
+}
+
+function removeWatchRateBarArtifacts(buttons) {
+ if (!buttons) {
+ return;
+ }
+
+ for (const wrapper of buttons.querySelectorAll('.ryd-tooltip, [data-ryd-rate-bar-wrapper="true"]')) {
+ wrapper.remove();
+ }
+ for (const fragment of buttons.querySelectorAll(
+ ".ryd-tooltip-bar-container, .ryd-tooltip-label, #return-youtube-dislike-bar-container, #return-youtube-dislike-bar, #ryd-dislike-tooltip",
+ )) {
+ fragment.remove();
+ }
+}
+
+function clearStaleWatchPresentation(buttons, dislikeButton, videoId) {
+ if (isMobile || isShorts() || !initializedVideoId || initializedVideoId === videoId) {
+ return;
+ }
+
+ removeWatchRateBarArtifacts(buttons);
+ const dislikeText =
+ dislikeButton?.querySelector("#text") ??
+ dislikeButton?.getElementsByTagName("yt-formatted-string")[0] ??
+ dislikeButton?.querySelector("span[role='text']");
+ if (dislikeText) {
+ dislikeText.textContent = "";
+ }
+}
+
+function canRepairWatchRateBar(buttons, videoId) {
+ if (
+ isMobile ||
+ isShorts() ||
+ !extConfig.rateBarEnabled ||
+ !videoId ||
+ !countStateLoaded ||
+ countStateVideoId !== videoId ||
+ initializedVideoId !== videoId ||
+ getVideoId() !== videoId ||
+ !buttons?.isConnected ||
+ getButtons() !== buttons ||
+ !hasRenderedBox(buttons)
+ ) {
+ return false;
+ }
+
+ const likeButton = getLikeButton();
+ const dislikeButton = getDislikeButton();
+ return (
+ likeButton === initializedLikeButton &&
+ dislikeButton === initializedDislikeButton &&
+ buttons.contains(likeButton) &&
+ buttons.contains(dislikeButton) &&
+ !watchRateBarIsHealthy(buttons, videoId)
+ );
+}
+
+function repairWatchRateBar(buttons = getButtons(), videoId = getVideoId()) {
+ if (canRepairWatchRateBar(buttons, videoId)) {
+ removeWatchRateBarArtifacts(buttons);
+ createRateBar(likesvalue, dislikesvalue);
+ }
+}
+
+function scheduleWatchRateBarRepair() {
+ if (watchRateBarRepairTimer !== null) {
+ return;
+ }
+ watchRateBarRepairTimer = setTimeout(() => {
+ watchRateBarRepairTimer = null;
+ repairWatchRateBar(watchRateBarObserverTarget, watchRateBarObserverVideoId);
+ }, 0);
+}
+
+function disconnectWatchRateBarObserver() {
+ watchRateBarObserver?.disconnect();
+ watchRateBarObserver = null;
+ watchRateBarObserverTarget = null;
+ watchRateBarObserverVideoId = null;
+ if (watchRateBarRepairTimer !== null) {
+ clearTimeout(watchRateBarRepairTimer);
+ watchRateBarRepairTimer = null;
+ }
+}
+
+function observeWatchRateBar(buttons, videoId) {
+ if (isMobile || isShorts() || !extConfig.rateBarEnabled || !buttons) {
+ disconnectWatchRateBarObserver();
+ return;
+ }
+
+ if (watchRateBarObserverTarget === buttons) {
+ watchRateBarObserverVideoId = videoId;
+ return;
+ }
+
+ disconnectWatchRateBarObserver();
+ watchRateBarObserverTarget = buttons;
+ watchRateBarObserverVideoId = videoId;
+ watchRateBarObserver = new MutationObserver((mutations) => {
+ if (mutations.some(mutationTouchesWatchRateBar)) {
+ scheduleWatchRateBarRepair();
+ }
+ });
+ watchRateBarObserver.observe(buttons, {
+ attributeFilter: ["aria-hidden", "class", "hidden", "inert", "style"],
+ attributes: true,
+ childList: true,
+ subtree: true,
+ });
+}
+
+function setState() {
+ const videoId = getVideoId();
+ previousState = getState();
+ if (countStateVideoId === videoId && (activeCountRequest?.videoId === videoId || countStateLoaded)) {
+ updateDOMDislikes();
+ refreshFormattedLikes();
+ return;
+ }
+ if (countStateVideoId !== videoId) {
+ likesvalue = 0;
+ dislikesvalue = 0;
+ countStateVideoId = videoId;
+ countStateLoaded = false;
+ countStateEpoch += 1;
+ }
+ const countRequest = {
+ dislikesDelta: 0,
+ likesDelta: 0,
+ videoId,
+ };
+ activeCountRequest = countRequest;
+ cLog("Fetching votes...");
+
+ fetchImpl(`${API_BASE_URL}/votes?videoId=${videoId}`)
+ .then((response) => response.json())
+ .then((json) => {
+ if (getVideoId() !== videoId || activeCountRequest !== countRequest) {
+ return;
+ }
+ if (json && !("traceId" in json)) {
const { dislikes, likes } = json;
cLog(`Received count: ${dislikes}`);
- likesvalue = likes;
- dislikesvalue = dislikes;
- setDislikes(numberFormat(dislikes));
+ likesvalue = Math.max(0, likes + countRequest.likesDelta);
+ dislikesvalue = Math.max(0, dislikes + countRequest.dislikesDelta);
+ countStateLoaded = true;
+ setDislikes(numberFormat(dislikesvalue));
if (extConfig.numberDisplayReformatLikes === true) {
const nativeLikes = getLikeCountFromButton();
if (nativeLikes !== false) {
setLikes(numberFormat(nativeLikes));
}
}
- createRateBar(likes, dislikes);
+ createRateBar(likesvalue, dislikesvalue);
if (extConfig.coloredThumbs === true) {
const dislikeButton = getDislikeButton();
if (isShorts()) {
// for shorts, leave deactived buttons in default color
- const shortLikeButton = getLikeButton().querySelector("tp-yt-paper-button#button");
- const shortDislikeButton = dislikeButton?.querySelector("tp-yt-paper-button#button");
- if (shortLikeButton.getAttribute("aria-pressed") === "true") {
+ const shortLikeButton = getLikeButton()?.querySelector("button, tp-yt-paper-button#button");
+ const shortDislikeButton = dislikeButton?.querySelector("button, tp-yt-paper-button#button");
+ if (shortLikeButton?.getAttribute("aria-pressed") === "true") {
shortLikeButton.style.color = getColorFromTheme(true);
}
- if (shortDislikeButton && shortDislikeButton.getAttribute("aria-pressed") === "true") {
+ if (shortDislikeButton?.getAttribute("aria-pressed") === "true") {
shortDislikeButton.style.color = getColorFromTheme(false);
}
- shortsObserver.observe(shortLikeButton);
- shortsObserver.observe(shortDislikeButton);
+ const observer = getShortsObserver();
+ if (shortLikeButton) observer.observe(shortLikeButton);
+ if (shortDislikeButton) observer.observe(shortDislikeButton);
} else {
getLikeButton().style.color = getColorFromTheme(true);
if (dislikeButton) dislikeButton.style.color = getColorFromTheme(false);
}
}
}
+ })
+ .catch((error) => cLog("Fetching votes failed", error instanceof Error ? error.message : String(error)))
+ .finally(() => {
+ if (activeCountRequest === countRequest) {
+ activeCountRequest = null;
+ }
});
- });
}
function updateDOMDislikes() {
@@ -453,68 +2026,204 @@ function updateDOMDislikes() {
createRateBar(likesvalue, dislikesvalue);
}
-function likeClicked() {
- if (checkForUserAvatarButton() == true) {
- if (previousState == 1) {
- likesvalue--;
- updateDOMDislikes();
- previousState = 3;
- } else if (previousState == 2) {
- likesvalue++;
- dislikesvalue--;
- updateDOMDislikes();
- previousState = 1;
- } else if (previousState == 3) {
- likesvalue++;
- updateDOMDislikes();
- previousState = 1;
- }
- if (extConfig.numberDisplayReformatLikes === true) {
- const nativeLikes = getLikeCountFromButton();
- if (nativeLikes !== false) {
- setLikes(numberFormat(nativeLikes));
- }
+function reportVoteFailure(error) {
+ const message = error instanceof Error ? error.message : String(error);
+ cLog("Vote submission failed", message);
+}
+
+function refreshFormattedLikes() {
+ if (extConfig.numberDisplayReformatLikes !== true) {
+ return;
+ }
+
+ const nativeLikes = getLikeCountFromButton();
+ if (nativeLikes !== false) {
+ setLikes(numberFormat(nativeLikes));
+ }
+}
+
+function getVoteStateCounts(state) {
+ return {
+ dislikes: state === DISLIKED_STATE ? 1 : 0,
+ likes: state === LIKED_STATE ? 1 : 0,
+ };
+}
+
+function getShortsCountTransition(videoId, transition) {
+ if (!isShorts() || shortsSubmittedStateVideoId !== videoId) {
+ return transition;
+ }
+ const previousCounts = getVoteStateCounts(shortsSubmittedState);
+ const nextCounts = getVoteStateCounts(transition.nextState);
+ return {
+ ...transition,
+ dislikesDelta: nextCounts.dislikes - previousCounts.dislikes,
+ likesDelta: nextCounts.likes - previousCounts.likes,
+ };
+}
+
+function applyCountTransition(videoId, countTransition) {
+ const counts = applyVoteTransitionCounts(likesvalue, dislikesvalue, countTransition);
+ if (activeCountRequest?.videoId === videoId) {
+ activeCountRequest.likesDelta += countTransition.likesDelta;
+ activeCountRequest.dislikesDelta += countTransition.dislikesDelta;
+ }
+ likesvalue = counts.likes;
+ dislikesvalue = counts.dislikes;
+}
+
+function applyVoteTransition(videoId, transition, syntheticShortsDislike) {
+ const countTransition = getShortsCountTransition(videoId, transition);
+ applyCountTransition(videoId, countTransition);
+ previousState = transition.nextState;
+ if (syntheticShortsDislike) {
+ setSyntheticShortsPressed(previousState === DISLIKED_STATE);
+ }
+ if (isShorts()) {
+ shortsSubmittedStateVideoId = videoId;
+ shortsSubmittedState = previousState;
+ persistSyntheticShortsState(videoId, previousState === DISLIKED_STATE);
+ }
+ updateDOMDislikes();
+ refreshFormattedLikes();
+}
+
+function applyHydratingVoteTransition(hydration, transition, syntheticShortsDislike) {
+ applyCountTransition(hydration.videoId, transition);
+ previousState = transition.nextState;
+ if (syntheticShortsDislike) {
+ setSyntheticShortsPressed(previousState === DISLIKED_STATE);
+ }
+ updateDOMDislikes();
+ refreshFormattedLikes();
+}
+
+function reconcileHydratingVoteTransition(hydration, transition, syntheticShortsDislike) {
+ const submittedCountTransition = getShortsCountTransition(hydration.videoId, transition);
+ if (transition.optimisticCountStateEpoch === countStateEpoch) {
+ applyCountTransition(hydration.videoId, {
+ ...transition,
+ dislikesDelta: submittedCountTransition.dislikesDelta - transition.dislikesDelta,
+ likesDelta: submittedCountTransition.likesDelta - transition.likesDelta,
+ });
+ }
+ previousState = transition.nextState;
+ shortsSubmittedStateVideoId = hydration.videoId;
+ shortsSubmittedState = previousState;
+ persistSyntheticShortsState(hydration.videoId, previousState === DISLIKED_STATE);
+ if (syntheticShortsDislike) {
+ setSyntheticShortsPressed(previousState === DISLIKED_STATE);
+ }
+}
+
+function submitVoteTransition(videoId, transition, signedOut) {
+ if (shouldSubmitVote({ disableVoteSubmission: extConfig.disableVoteSubmission, signedOut })) {
+ void voteClient.submitVote(videoId, transition.value).catch(reportVoteFailure);
+ }
+}
+
+function clearNativeLikeForSyntheticDislike(action, stateBeforeActivation, syntheticShortsDislike) {
+ if (!syntheticShortsDislike || action !== DISLIKE_ACTION || stateBeforeActivation !== LIKED_STATE) {
+ return;
+ }
+
+ const nativeLikeButton = getLikeButton()?.querySelector("button");
+ if (nativeLikeButton && isVideoLiked()) {
+ suppressNextLikeActivation = true;
+ try {
+ nativeLikeButton.click();
+ } finally {
+ suppressNextLikeActivation = false;
}
}
}
-function dislikeClicked() {
- if (checkForUserAvatarButton() == true) {
- if (previousState == 3) {
- dislikesvalue++;
- updateDOMDislikes();
- previousState = 2;
- } else if (previousState == 2) {
- dislikesvalue--;
- updateDOMDislikes();
- previousState = 3;
- } else if (previousState == 1) {
- likesvalue--;
- dislikesvalue++;
- updateDOMDislikes();
- previousState = 2;
- if (extConfig.numberDisplayReformatLikes === true) {
- const nativeLikes = getLikeCountFromButton();
- if (nativeLikes !== false) {
- setLikes(numberFormat(nativeLikes));
- }
- }
- }
+function handleVoteActivation(action) {
+ const signedOut = isSignedOut();
+ if (signedOut) {
+ return;
}
+
+ const videoId = getVideoId();
+ if (!videoId) {
+ return;
+ }
+
+ const transition = resolveVoteTransition(previousState, action);
+ const syntheticShortsDislike = getDislikeButton()?.matches(SYNTHETIC_SHORTS_DISLIKE_SELECTOR) === true;
+ clearNativeLikeForSyntheticDislike(action, previousState, syntheticShortsDislike);
+ applyVoteTransition(videoId, transition, syntheticShortsDislike);
+ submitVoteTransition(videoId, transition, signedOut);
}
-function setInitialState() {
- setState();
+function captureHydratingShortsActivation(event, action) {
+ const hydration = hydratingShortsActivationTargets.get(event.currentTarget);
+ if (!hydration) {
+ return false;
+ }
+ if (hydration.videoId !== getVideoId()) {
+ return true;
+ }
+
+ const signedOut = isSignedOut();
+ const syntheticShortsDislike = getDislikeButton()?.matches(SYNTHETIC_SHORTS_DISLIKE_SELECTOR) === true;
+ if (!signedOut) {
+ clearNativeLikeForSyntheticDislike(action, hydration.visibleState, syntheticShortsDislike);
+ }
+ const transition = {
+ ...resolveVoteTransition(hydration.visibleState, action),
+ optimisticCountStateEpoch: countStateEpoch,
+ };
+ hydration.visibleState = transition.nextState;
+ if (!signedOut) {
+ hydration.activations.push(transition);
+ applyHydratingVoteTransition(hydration, transition, syntheticShortsDislike);
+ submitVoteTransition(hydration.videoId, transition, signedOut);
+ }
+ return true;
+}
+
+function likeClicked(event) {
+ if (suppressNextLikeActivation) {
+ return;
+ }
+ if (boundActivationVideoIds.get(event.currentTarget) !== getVideoId()) {
+ suppressStaleTargetRefresh(event.currentTarget);
+ return;
+ }
+ if (captureHydratingShortsActivation(event, LIKE_ACTION)) {
+ return;
+ }
+ handleVoteActivation(LIKE_ACTION);
+}
+
+function dislikeClicked(event) {
+ if (boundActivationVideoIds.get(event.currentTarget) !== getVideoId()) {
+ suppressStaleTargetRefresh(event.currentTarget);
+ return;
+ }
+ if (captureHydratingShortsActivation(event, DISLIKE_ACTION)) {
+ return;
+ }
+ handleVoteActivation(DISLIKE_ACTION);
+}
+
+function refreshDislikesForBoundControl(event) {
+ if (boundActivationVideoIds.get(event.currentTarget) === getVideoId()) {
+ updateDOMDislikes();
+ }
}
function getVideoId() {
const urlObject = new URL(window.location.href);
const pathname = urlObject.pathname;
if (pathname.startsWith("/clip")) {
- return (document.querySelector("meta[itemprop='videoId']") || document.querySelector("meta[itemprop='identifier']")).content;
+ return (document.querySelector("meta[itemprop='videoId']") || document.querySelector("meta[itemprop='identifier']"))
+ ?.content;
} else {
- if (pathname.startsWith("/shorts")) {
- return pathname.slice(8);
+ const shortVideoId = getShortVideoIdFromPathname(pathname);
+ if (shortVideoId) {
+ return shortVideoId;
}
return urlObject.searchParams.get("v");
}
@@ -522,7 +2231,7 @@ function getVideoId() {
function isVideoLoaded() {
if (isMobile) {
- return document.getElementById("player").getAttribute("loading") == "false";
+ return document.getElementById("player")?.getAttribute("loading") == "false";
}
const videoId = getVideoId();
@@ -626,73 +2335,553 @@ function getColorFromTheme(voteIsLike) {
}
let smartimationObserver = null;
+let initializedVideoId = null;
+let initializedButtons = null;
+let initializedLikeButton = null;
+let initializedDislikeButton = null;
+let lifecyclePageKey = null;
+
+const SHORTS_RENDERER_SELECTOR =
+ "ytd-reel-video-renderer, ytm-reel-video-renderer, ytm-shorts-video-renderer, ytm-reel-player-overlay-renderer";
+const SHORTS_CONTROL_SELECTOR = `like-button-view-model, dislike-button-view-model, ${SYNTHETIC_SHORTS_DISLIKE_SELECTOR}`;
+
+function getShortsRenderer(element) {
+ if (!element || element.nodeType !== Node.ELEMENT_NODE) {
+ return null;
+ }
+ return element.matches(SHORTS_RENDERER_SELECTOR) ? element : element.closest?.(SHORTS_RENDERER_SELECTOR) ?? null;
+}
+
+function rendererOwnsCurrentShort(renderer) {
+ if (!renderer) {
+ return false;
+ }
+ return (
+ renderer.hasAttribute("is-active") ||
+ rendererMatchesShort(renderer, getVideoId()) ||
+ (initializedLikeButton && renderer.contains(initializedLikeButton)) ||
+ (initializedDislikeButton && renderer.contains(initializedDislikeButton))
+ );
+}
+
+function elementTouchesCurrentShortRenderer(element) {
+ const containingRenderer = getShortsRenderer(element);
+ if (containingRenderer) {
+ return rendererOwnsCurrentShort(containingRenderer);
+ }
+ return Array.from(element?.querySelectorAll?.(SHORTS_RENDERER_SELECTOR) ?? []).some(rendererOwnsCurrentShort);
+}
+
+function mutationTouchesShortsControls(mutation) {
+ if (mutation.type === "attributes") {
+ if (mutation.attributeName === "is-active" && mutation.target.matches(SHORTS_RENDERER_SELECTOR)) {
+ return true;
+ }
+ if (
+ (mutation.target.matches(SHORTS_RENDERER_SELECTOR) || mutation.target.matches("a")) &&
+ elementTouchesCurrentShortRenderer(mutation.target)
+ ) {
+ return true;
+ }
+ return false;
+ }
+ if (mutation.type !== "childList") {
+ return false;
+ }
+ const changedElements = [...mutation.addedNodes, ...mutation.removedNodes].filter(
+ (node) => node.nodeType === Node.ELEMENT_NODE,
+ );
+ if (
+ changedElements.some(
+ (node) =>
+ node.matches(`reel-action-bar-view-model, ${SHORTS_CONTROL_SELECTOR}`) ||
+ node.querySelector?.(`reel-action-bar-view-model, ${SHORTS_CONTROL_SELECTOR}`),
+ )
+ ) {
+ return [mutation.target, ...changedElements].some(elementTouchesCurrentShortRenderer);
+ }
+ return (
+ mutation.target.closest?.(SHORTS_CONTROL_SELECTOR) &&
+ elementTouchesCurrentShortRenderer(mutation.target) &&
+ changedElements.some(
+ (node) =>
+ node.matches("button, tp-yt-paper-button#button") || node.querySelector?.("button, tp-yt-paper-button#button"),
+ )
+ );
+}
+
+function shortsControlsNeedInitialization() {
+ const videoId = getVideoId();
+ if (!isShorts() || !videoId) {
+ return false;
+ }
+
+ const buttons = getButtons();
+ const likeButton = getLikeButton();
+ const dislikeButton = getDislikeButton();
+ if (!buttons || !likeButton || !dislikeButton) {
+ return true;
+ }
+ if (
+ initializedVideoId !== videoId ||
+ initializedLikeButton !== likeButton ||
+ initializedDislikeButton !== dislikeButton
+ ) {
+ return true;
+ }
+
+ return (
+ boundActivationVideoIds.get(getActivationTarget(likeButton)) !== videoId ||
+ boundActivationVideoIds.get(getActivationTarget(dislikeButton)) !== videoId
+ );
+}
+
+function disconnectShortsLifecycleObserver() {
+ shortsLifecycleObserver?.disconnect();
+ shortsLifecycleObserverTarget = null;
+}
+
+function observeShortsLifecycle(buttons) {
+ if (!isShorts() || !buttons) {
+ disconnectShortsLifecycleObserver();
+ return;
+ }
+ clearPendingWatchControlObservers();
+
+ const isDesktopActionBar = buttons.tagName === "REEL-ACTION-BAR-VIEW-MODEL";
+ const isMobileActionBar = isMobile && buttons.tagName === "YTM-LIKE-BUTTON-RENDERER";
+ if (!isDesktopActionBar && !isMobileActionBar) {
+ disconnectShortsLifecycleObserver();
+ return;
+ }
+
+ const renderer = isMobileActionBar
+ ? buttons.closest("ytm-reel-video-renderer, ytm-shorts-video-renderer") ?? getMobileShortOwnership(buttons).owner
+ : buttons.closest("ytd-reel-video-renderer") ?? buttons;
+ const observerTarget =
+ renderer.closest(
+ "ytd-shorts, ytd-shorts-container, ytm-shorts, ytm-shorts-container, #shorts-container, #shorts-inner-container",
+ ) ??
+ renderer.parentElement ??
+ document.body;
+ if (shortsLifecycleObserverTarget === observerTarget) {
+ return;
+ }
+ disconnectShortsLifecycleObserver();
+ shortsLifecycleObserver = new MutationObserver((mutations) => {
+ if (mutations.some(mutationTouchesShortsControls) && shortsControlsNeedInitialization()) {
+ setEventListeners();
+ }
+ });
+ shortsLifecycleObserver.observe(observerTarget, {
+ attributeFilter: ["href", "is-active", "video-id"],
+ attributes: true,
+ childList: true,
+ subtree: true,
+ });
+ shortsLifecycleObserverTarget = observerTarget;
+}
+
+function getActivationTarget(control) {
+ if (control.matches("button, tp-yt-paper-button#button")) {
+ return control;
+ }
+ return control.querySelector("button, tp-yt-paper-button#button") ?? control;
+}
+
+function beginShortsHydration(videoId, likeButton, dislikeButton, initialVisibleState) {
+ const previousCompletion = shortsHydrationTails.get(videoId) ?? Promise.resolve();
+ let resolveCompletion;
+ const completion = new Promise((resolve) => {
+ resolveCompletion = resolve;
+ });
+ const hydration = {
+ activations: [],
+ completion,
+ initialVisibleState,
+ previousCompletion,
+ resolveCompletion,
+ videoId,
+ visibleState: initialVisibleState,
+ };
+ shortsHydrationTails.set(videoId, completion);
+ hydratingShortsActivationTargets.set(getActivationTarget(likeButton), hydration);
+ hydratingShortsActivationTargets.set(getActivationTarget(dislikeButton), hydration);
+ return hydration;
+}
+
+function finishShortsHydration(hydration, likeButton, dislikeButton) {
+ for (const target of [getActivationTarget(likeButton), getActivationTarget(dislikeButton)]) {
+ if (hydratingShortsActivationTargets.get(target) === hydration) {
+ hydratingShortsActivationTargets.delete(target);
+ }
+ }
+ if (shortsHydrationTails.get(hydration.videoId) === hydration.completion) {
+ shortsHydrationTails.delete(hydration.videoId);
+ }
+ hydration.resolveCompletion();
+}
+
+function persistFinalHydratingActivation(hydration) {
+ const finalActivation = hydration.activations[hydration.activations.length - 1];
+ if (finalActivation) {
+ persistSyntheticShortsState(hydration.videoId, finalActivation.nextState === DISLIKED_STATE);
+ }
+}
+
+function reconcileStaleShortsHydration(hydration, submittedState, storedDisliked) {
+ if (getVideoId() !== hydration.videoId || hydration.activations.length === 0) {
+ persistFinalHydratingActivation(hydration);
+ return;
+ }
+
+ const currentDislikeButton = getDislikeButton();
+ if (!currentDislikeButton) {
+ persistFinalHydratingActivation(hydration);
+ return;
+ }
+
+ applyHydratedShortsState(hydration, submittedState, currentDislikeButton, storedDisliked);
+}
+
+function applyHydratedShortsState(hydration, submittedState, dislikeButton, storedDisliked) {
+ const syntheticShortsDislike = dislikeButton.matches(SYNTHETIC_SHORTS_DISLIKE_SELECTOR);
+ shortsSubmittedStateVideoId = hydration.videoId;
+ shortsSubmittedState = submittedState;
+
+ if (hydration.activations.length === 0) {
+ previousState = syntheticShortsDislike ? submittedState : hydration.initialVisibleState;
+ if (syntheticShortsDislike) {
+ setSyntheticShortsPressed(submittedState === DISLIKED_STATE, dislikeButton);
+ }
+ if (hydration.initialVisibleState === LIKED_STATE && storedDisliked) {
+ persistSyntheticShortsState(hydration.videoId, false);
+ }
+ return;
+ }
+
+ for (const transition of hydration.activations) {
+ reconcileHydratingVoteTransition(hydration, transition, syntheticShortsDislike);
+ }
+ updateDOMDislikes();
+ refreshFormattedLikes();
+}
+
+function bindVoteButtonListeners(likeButton, dislikeButton, { enableSynthetic = true } = {}) {
+ const likeActivationTarget = getActivationTarget(likeButton);
+ const dislikeActivationTarget = getActivationTarget(dislikeButton);
+ const videoId = getVideoId();
+ boundActivationVideoIds.set(likeActivationTarget, videoId);
+ boundActivationVideoIds.set(dislikeActivationTarget, videoId);
+ if (!boundLikeButtons.has(likeActivationTarget)) {
+ likeActivationTarget.addEventListener("click", likeClicked);
+ boundLikeButtons.add(likeActivationTarget);
+ }
+ if (!boundDislikeButtons.has(dislikeActivationTarget)) {
+ dislikeActivationTarget.addEventListener("click", dislikeClicked);
+ dislikeActivationTarget.addEventListener("focusin", refreshDislikesForBoundControl);
+ dislikeActivationTarget.addEventListener("focusout", refreshDislikesForBoundControl);
+ boundDislikeButtons.add(dislikeActivationTarget);
+ }
+ if (enableSynthetic && dislikeButton.matches(SYNTHETIC_SHORTS_DISLIKE_SELECTOR)) {
+ dislikeActivationTarget.disabled = false;
+ dislikeActivationTarget.setAttribute("aria-disabled", "false");
+ }
+}
+
+async function initializeCurrentButtons(generation) {
+ const videoId = getVideoId();
+ if (!videoId) {
+ // Channel/search/home pages have no video controls to initialize. The
+ // lightweight lifecycle monitor will restart initialization when a video
+ // route appears, instead of polling the whole page every 111 ms forever.
+ return true;
+ }
+
+ if (!(isShorts() || (hasRenderedBox(getButtons()) && isVideoLoaded()))) {
+ return false;
+ }
+
+ const buttons = getButtons();
+ const likeButton = getLikeButton();
+ const dislikeButton = getDislikeButton();
+ if (!buttons || !likeButton || !dislikeButton) {
+ return false;
+ }
+ if (!isShorts() && !watchControlsAreReadyForVideo(buttons, likeButton, dislikeButton, videoId)) {
+ return false;
+ }
+
+ observeShortsLifecycle(buttons);
+ observeWatchRateBar(buttons, videoId);
+ const stateNeedsInitialization =
+ initializedVideoId !== videoId ||
+ initializedButtons !== buttons ||
+ initializedLikeButton !== likeButton ||
+ initializedDislikeButton !== dislikeButton;
+ if (stateNeedsInitialization) {
+ clearStaleWatchPresentation(buttons, dislikeButton, videoId);
+ initializedVideoId = videoId;
+ initializedButtons = buttons;
+ initializedLikeButton = likeButton;
+ initializedDislikeButton = dislikeButton;
+ setState();
+ }
+
+ if (isShorts()) {
+ const initialVisibleState = getState();
+ const likeActivationTarget = getActivationTarget(likeButton);
+ const dislikeActivationTarget = getActivationTarget(dislikeButton);
+ const existingLikeHydration = hydratingShortsActivationTargets.get(likeActivationTarget);
+ const existingDislikeHydration = hydratingShortsActivationTargets.get(dislikeActivationTarget);
+ if (
+ existingLikeHydration &&
+ existingLikeHydration === existingDislikeHydration &&
+ existingLikeHydration.videoId === videoId
+ ) {
+ return false;
+ }
+ const hydration = beginShortsHydration(videoId, likeButton, dislikeButton, initialVisibleState);
+ bindVoteButtonListeners(likeButton, dislikeButton, { enableSynthetic: false });
+ let storedDisliked;
+ let submittedState;
+ try {
+ await hydration.previousCompletion;
+ if (dislikeButton.matches(SYNTHETIC_SHORTS_DISLIKE_SELECTOR)) {
+ const restored = await restoreSyntheticShortsState(videoId, dislikeButton, initialVisibleState);
+ if (!restored) {
+ persistFinalHydratingActivation(hydration);
+ return false;
+ }
+ storedDisliked = restored.disliked;
+ submittedState = restored.submittedState;
+ } else {
+ storedDisliked = await readSyntheticShortsDisliked(videoId);
+ submittedState =
+ initialVisibleState === LIKED_STATE ? LIKED_STATE : storedDisliked ? DISLIKED_STATE : initialVisibleState;
+ }
+
+ if (
+ generation !== initializationGeneration ||
+ getVideoId() !== videoId ||
+ getLikeButton() !== likeButton ||
+ getDislikeButton() !== dislikeButton
+ ) {
+ reconcileStaleShortsHydration(hydration, submittedState, storedDisliked);
+ return false;
+ }
+
+ applyHydratedShortsState(hydration, submittedState, dislikeButton, storedDisliked);
+ bindVoteButtonListeners(likeButton, dislikeButton);
+ } finally {
+ finishShortsHydration(hydration, likeButton, dislikeButton);
+ }
+ } else {
+ if (
+ generation !== initializationGeneration ||
+ getVideoId() !== videoId ||
+ getLikeButton() !== likeButton ||
+ getDislikeButton() !== dislikeButton
+ ) {
+ return false;
+ }
+ bindVoteButtonListeners(likeButton, dislikeButton);
+ }
+
+ if (!smartimationObserver) {
+ smartimationObserver = createObserver(
+ {
+ attributes: true,
+ subtree: true,
+ childList: true,
+ },
+ updateDOMDislikes,
+ );
+ smartimationObserver.container = null;
+ }
+
+ const smartimationContainer = buttons.querySelector("yt-smartimation");
+ if (smartimationContainer && smartimationObserver.container != smartimationContainer) {
+ cLog("Initializing smartimation mutation observer");
+ smartimationObserver.disconnect();
+ smartimationObserver.observe(smartimationContainer);
+ smartimationObserver.container = smartimationContainer;
+ }
+
+ return true;
+}
function setEventListeners(evt) {
- let jsInitChecktimer;
+ const generation = ++initializationGeneration;
+ let checkRunning = false;
+ if (initializationTimer) {
+ clearInterval(initializationTimer);
+ }
- function checkForJS_Finish() {
- //console.log();
- if (isShorts() || (getButtons()?.offsetParent && isVideoLoaded())) {
- const buttons = getButtons();
- const dislikeButton = getDislikeButton();
-
- if (preNavigateLikeButton !== getLikeButton() && dislikeButton) {
- cLog("Registering button listeners...");
- try {
- getLikeButton().addEventListener("click", likeClicked);
- dislikeButton?.addEventListener("click", dislikeClicked);
- getLikeButton().addEventListener("touchstart", likeClicked);
- dislikeButton?.addEventListener("touchstart", dislikeClicked);
- dislikeButton?.addEventListener("focusin", updateDOMDislikes);
- dislikeButton?.addEventListener("focusout", updateDOMDislikes);
- preNavigateLikeButton = getLikeButton();
-
- if (!smartimationObserver) {
- smartimationObserver = createObserver(
- {
- attributes: true,
- subtree: true,
- childList: true,
- },
- updateDOMDislikes,
- );
- smartimationObserver.container = null;
- }
-
- const smartimationContainer = buttons.querySelector("yt-smartimation");
- if (smartimationContainer && smartimationObserver.container != smartimationContainer) {
- cLog("Initializing smartimation mutation observer");
- smartimationObserver.disconnect();
- smartimationObserver.observe(smartimationContainer);
- smartimationObserver.container = smartimationContainer;
- }
- } catch {
- return;
- } //Don't spam errors into the console
- }
- if (dislikeButton) {
- setInitialState();
- clearInterval(jsInitChecktimer);
+ async function checkForJSFinish() {
+ if (generation !== initializationGeneration || checkRunning) {
+ return;
+ }
+ checkRunning = true;
+ try {
+ const initialized = await initializeCurrentButtons(generation);
+ if (initialized && generation === initializationGeneration) {
+ clearInterval(initializationTimer);
+ initializationTimer = null;
}
+ } catch (error) {
+ reportVoteFailure(error);
+ } finally {
+ checkRunning = false;
}
}
cLog("Setting up...");
- jsInitChecktimer = setInterval(checkForJS_Finish, 111);
+ initializationTimer = setInterval(() => void checkForJSFinish(), 111);
+ void checkForJSFinish();
+}
+
+function getLifecyclePageKey() {
+ const videoId = getVideoId();
+ if (!videoId) {
+ return null;
+ }
+ return `${isShorts() ? "shorts" : "watch"}:${videoId}`;
+}
+
+function watchControlsNeedReinitialization() {
+ if (isMobile || isShorts() || initializationTimer !== null) {
+ return false;
+ }
+
+ const videoId = getVideoId();
+ if (!videoId || initializedVideoId !== videoId) {
+ return false;
+ }
+
+ const buttons = getButtons();
+ const likeButton = getLikeButton();
+ const dislikeButton = getDislikeButton();
+ return (
+ !initializedButtons?.isConnected ||
+ buttons !== initializedButtons ||
+ likeButton !== initializedLikeButton ||
+ dislikeButton !== initializedDislikeButton ||
+ !initializedLikeButton?.isConnected ||
+ !initializedDislikeButton?.isConnected ||
+ !buttons?.contains(initializedLikeButton) ||
+ !buttons?.contains(initializedDislikeButton)
+ );
+}
+
+function checkPageLifecycle() {
+ const pageKey = getLifecyclePageKey();
+ if (pageKey !== lifecyclePageKey) {
+ lifecyclePageKey = pageKey;
+ if (!isShorts()) {
+ disconnectShortsLifecycleObserver();
+ if (!pageKey) {
+ clearPendingWatchControlObservers();
+ }
+ }
+ setEventListeners();
+ return;
+ }
+ if (watchControlsNeedReinitialization()) {
+ setEventListeners();
+ return;
+ }
+ repairWatchRateBar();
+}
+
+function handleNavigateStart() {
+ disconnectWatchRateBarObserver();
+ clearPendingWatchNavigationBoundary();
+ clearPendingWatchControlObservers();
+ if (isShorts()) {
+ return;
+ }
+
+ const buttons = getButtons();
+ const likeButton = getLikeButton();
+ const dislikeButton = getDislikeButton();
+ if (!buttons || !likeButton || !dislikeButton) {
+ return;
+ }
+
+ const likeActivationTarget = getActivationTarget(likeButton);
+ const dislikeActivationTarget = getActivationTarget(dislikeButton);
+ const likeVideoId = boundActivationVideoIds.get(likeActivationTarget);
+ const dislikeVideoId = boundActivationVideoIds.get(dislikeActivationTarget);
+ if (
+ !likeVideoId ||
+ likeVideoId !== dislikeVideoId ||
+ likeVideoId !== getVideoId() ||
+ !buttons.contains(likeActivationTarget) ||
+ !buttons.contains(dislikeActivationTarget)
+ ) {
+ return;
+ }
+
+ const boundary = {
+ buttons,
+ completedVideoId: null,
+ dislike: {
+ activationTarget: dislikeActivationTarget,
+ host: dislikeButton,
+ refreshed: false,
+ },
+ like: {
+ activationTarget: likeActivationTarget,
+ host: likeButton,
+ refreshed: false,
+ },
+ observer: null,
+ sourceVideoId: likeVideoId,
+ };
+ const observer = new MutationObserver((mutations) => {
+ captureWatchNavigationBoundaryRefreshes(boundary, mutations);
+ });
+ boundary.observer = observer;
+ pendingWatchNavigationBoundary = boundary;
+ observer.observe(buttons, {
+ attributeFilter: ["aria-disabled", "aria-label", "data-video-id", "disabled", "title", "video-id"],
+ attributes: true,
+ childList: true,
+ subtree: true,
+ });
+}
+
+function handleNavigateFinish(event) {
+ lifecyclePageKey = getLifecyclePageKey();
+ if (isShorts()) {
+ clearPendingWatchControlObservers();
+ clearPendingWatchNavigationBoundary();
+ } else {
+ disconnectShortsLifecycleObserver();
+ const videoId = getVideoId();
+ if (pendingWatchNavigationBoundary && videoId !== pendingWatchNavigationBoundary.sourceVideoId) {
+ pendingWatchNavigationBoundary.completedVideoId = videoId;
+ }
+ if (!getVideoId()) {
+ clearPendingWatchControlObservers();
+ clearPendingWatchNavigationBoundary();
+ }
+ }
+ setEventListeners(event);
}
(function () {
"use strict";
- window.addEventListener("yt-navigate-finish", setEventListeners, true);
+ void voteClient.ensureRegistered().catch(reportVoteFailure);
+ window.addEventListener("yt-navigate-start", handleNavigateStart, true);
+ window.addEventListener("yt-navigate-finish", handleNavigateFinish, true);
+ window.addEventListener("popstate", checkPageLifecycle, true);
+ lifecyclePageKey = getLifecyclePageKey();
+ setInterval(checkPageLifecycle, 500);
setEventListeners();
})();
if (isMobile) {
- let originalPush = history.pushState;
- history.pushState = function (...args) {
- window.returnDislikeButtonlistenersSet = false;
- setEventListeners(args[2]);
- return originalPush.apply(history, args);
- };
setInterval(() => {
const dislikeButton = getDislikeButton();
if (dislikeButton?.querySelector(".button-renderer-text") === null) {
@@ -702,3 +2891,6 @@ if (isMobile) {
}
}, 1000);
}
+
+/******/ })()
+;
\ No newline at end of file
diff --git a/Extensions/UserScript/e2e/fixtures/navigation-page.html b/Extensions/UserScript/e2e/fixtures/navigation-page.html
new file mode 100644
index 0000000..ee25fb4
--- /dev/null
+++ b/Extensions/UserScript/e2e/fixtures/navigation-page.html
@@ -0,0 +1,645 @@
+
+
+
+
+ Userscript navigation browser-test fixture
+
+
+
+
+
+
+
+
+
+
diff --git a/Extensions/UserScript/e2e/fixtures/shorts-page.html b/Extensions/UserScript/e2e/fixtures/shorts-page.html
new file mode 100644
index 0000000..9950865
--- /dev/null
+++ b/Extensions/UserScript/e2e/fixtures/shorts-page.html
@@ -0,0 +1,348 @@
+
+
+
+
+ Userscript Shorts browser-test fixture
+
+
+
+
+
+
+
+
+
diff --git a/Extensions/UserScript/e2e/fixtures/watch-page.html b/Extensions/UserScript/e2e/fixtures/watch-page.html
new file mode 100644
index 0000000..9d94aca
--- /dev/null
+++ b/Extensions/UserScript/e2e/fixtures/watch-page.html
@@ -0,0 +1,250 @@
+
+
+
+
+ Userscript browser-test fixture
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Extensions/UserScript/e2e/harness.js b/Extensions/UserScript/e2e/harness.js
new file mode 100644
index 0000000..e1ef59d
--- /dev/null
+++ b/Extensions/UserScript/e2e/harness.js
@@ -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,
+};
diff --git a/Extensions/UserScript/e2e/live/README.md b/Extensions/UserScript/e2e/live/README.md
new file mode 100644
index 0000000..345282f
--- /dev/null
+++ b/Extensions/UserScript/e2e/live/README.md
@@ -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=` 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.
diff --git a/Extensions/UserScript/e2e/live/live-diagnostics.js b/Extensions/UserScript/e2e/live/live-diagnostics.js
new file mode 100644
index 0000000..f09a19c
--- /dev/null
+++ b/Extensions/UserScript/e2e/live/live-diagnostics.js
@@ -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) ? "" : 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,
+};
diff --git a/Extensions/UserScript/e2e/live/live-interactive-runner.js b/Extensions/UserScript/e2e/live/live-interactive-runner.js
new file mode 100644
index 0000000..fea5fe0
--- /dev/null
+++ b/Extensions/UserScript/e2e/live/live-interactive-runner.js
@@ -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;
+});
diff --git a/Extensions/UserScript/e2e/live/live-scenarios.js b/Extensions/UserScript/e2e/live/live-scenarios.js
new file mode 100644
index 0000000..3f70872
--- /dev/null
+++ b/Extensions/UserScript/e2e/live/live-scenarios.js
@@ -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,
+};
diff --git a/Extensions/UserScript/e2e/live/live-youtube-driver.js b/Extensions/UserScript/e2e/live/live-youtube-driver.js
new file mode 100644
index 0000000..a927088
--- /dev/null
+++ b/Extensions/UserScript/e2e/live/live-youtube-driver.js
@@ -0,0 +1,2024 @@
+const assert = require("node:assert/strict");
+
+const API_ORIGIN = "https://returnyoutubedislikeapi.com";
+const VIDEO_ID_PATTERN = /^[A-Za-z0-9_-]{11}$/;
+const DISLIKE_BUTTON_SELECTORS = [
+ "[data-ryd-synthetic-shorts-dislike] button",
+ "dislike-button-view-model button",
+ "#segmented-dislike-button button",
+ "button#segmented-dislike-button",
+ "#dislike-button button",
+ "ytd-dislike-button-renderer button",
+].join(", ");
+const LIKE_BUTTON_SELECTORS = [
+ "like-button-view-model button",
+ "#segmented-like-button button",
+ "button#segmented-like-button",
+ "#like-button button",
+ "ytd-like-button-renderer button",
+].join(", ");
+const ACTION_BUTTON_SELECTORS = {
+ dislike: DISLIKE_BUTTON_SELECTORS,
+ like: LIKE_BUTTON_SELECTORS,
+};
+const RATE_BAR_SELECTORS = {
+ extension: { bar: "#ryd-bar", container: "#ryd-bar-container" },
+ userscript: { bar: "#return-youtube-dislike-bar", container: "#return-youtube-dislike-bar-container" },
+};
+const SYNTHETIC_SHORTS_SELECTOR = "[data-ryd-synthetic-shorts-dislike]";
+const SHORTS_NEXT_BUTTON_SELECTOR = [
+ "ytd-shorts #navigation-button-down button",
+ "#navigation-button-down button",
+ 'ytd-shorts button[aria-label="Next video"]',
+].join(", ");
+const SHORTS_GEOMETRY = {
+ buttonSize: 48,
+ controlHeight: 78,
+ controlWidth: 48,
+ fontSize: 12,
+ geometryTolerance: 1,
+ iconSize: 24,
+ labelHeight: 70,
+ lineHeight: 18,
+ textTolerance: 0.5,
+};
+const SHORTS_ACTION_HOST_INSETS = {
+ marginBottom: 0,
+ marginLeft: 0,
+ marginRight: 0,
+ marginTop: 0,
+ paddingBottom: 8,
+ paddingLeft: 0,
+ paddingRight: 0,
+ paddingTop: 0,
+};
+const SHORTS_NEXT_FIRST_CLICK_TIMEOUT = 5_000;
+const SHORTS_NEXT_RETRY_TIMEOUT = 25_000;
+const SHORTS_VISUAL_PAINT_TIMEOUT = 5_000;
+const VISUAL_TOOLTIP_TIMEOUT = 5_000;
+const WATCH_RATIO_SOAK_DURATION_MS = 4_000;
+const WATCH_RATIO_SOAK_INTERVAL_MS = 500;
+const NATIVE_YOUTUBE_TOOLTIP_SELECTOR = [
+ "tp-yt-paper-tooltip:not(#ryd-dislike-tooltip)",
+ "ytd-tooltip-renderer",
+ "ytm-tooltip-renderer",
+ ".ytp-tooltip",
+ '[role="tooltip"]:not(#ryd-dislike-tooltip):not(.ryd-tooltip)',
+].join(", ");
+
+const delay = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
+
+function isShortCandidateEligible(element, settings) {
+ const rect = element.getBoundingClientRect();
+ const intersectsViewport =
+ rect.width > 0 &&
+ rect.height > 0 &&
+ rect.bottom > 0 &&
+ rect.right > 0 &&
+ rect.top < innerHeight &&
+ rect.left < innerWidth;
+ if (!intersectsViewport) return false;
+
+ const reel = element.closest("ytd-reel-video-renderer, ytm-reel-video-renderer");
+ if (!settings.activeShortRequired) return true;
+ if (!reel || !settings.expectedShortVideoId) return false;
+
+ const reelRect = reel.getBoundingClientRect();
+ const reelIntersectsViewport =
+ reelRect.width > 0 &&
+ reelRect.height > 0 &&
+ reelRect.bottom > 0 &&
+ reelRect.right > 0 &&
+ reelRect.top < innerHeight &&
+ reelRect.left < innerWidth;
+ const expectedPath = `/shorts/${settings.expectedShortVideoId}`;
+ const rendererVideoId = reel.getAttribute("video-id");
+ const matchesVideo = rendererVideoId
+ ? rendererVideoId === settings.expectedShortVideoId
+ : [...reel.querySelectorAll('a[href*="/shorts/"]')].some((link) => {
+ try {
+ return new URL(link.getAttribute("href"), location.origin).pathname === expectedPath;
+ } catch {
+ return false;
+ }
+ });
+ return reelIntersectsViewport && matchesVideo;
+}
+
+function readDislikeControlText(button) {
+ const syntheticControl = button.closest("[data-ryd-synthetic-shorts-dislike]");
+ const textSource = syntheticControl?.querySelector("#text, [role='text']") ?? button;
+ return (textSource.innerText ?? textSource.textContent ?? "").replace(/\s+/g, " ").trim();
+}
+
+function readShortsIconVisualState(element) {
+ const svg = element.querySelector("svg");
+ const paintedGraphicCount = svg
+ ? [...svg.querySelectorAll("path, circle, ellipse, line, polygon, polyline, rect")].filter((graphic) => {
+ if (graphic.tagName.toLowerCase() !== "path") return true;
+ return (graphic.getAttribute("d") ?? "").trim().length > 0;
+ }).length
+ : 0;
+ let effectiveOpacity = 1;
+ let rendered = true;
+ for (let current = element; current; current = current.parentElement) {
+ const style = getComputedStyle(current);
+ if (style.display === "none" || style.visibility === "hidden" || style.visibility === "collapse") {
+ rendered = false;
+ }
+ const opacity = Number.parseFloat(style.opacity);
+ if (Number.isFinite(opacity)) effectiveOpacity *= opacity;
+ }
+ return {
+ effectiveOpacity,
+ paintedGraphicCount,
+ rendered,
+ svgPresent: svg !== null,
+ };
+}
+
+function isShortsIconVisualReady(state) {
+ return (
+ state?.svgPresent === true &&
+ state.paintedGraphicCount > 0 &&
+ state.rendered === true &&
+ state.effectiveOpacity > 0.01
+ );
+}
+
+async function waitForValue(readValue, predicate, message, timeout = 20_000) {
+ const deadline = Date.now() + timeout;
+ let lastValue;
+ while (Date.now() < deadline) {
+ lastValue = await readValue();
+ if (predicate(lastValue)) return lastValue;
+ await delay(200);
+ }
+ throw new Error(`${message}. Last value: ${JSON.stringify(lastValue)}`);
+}
+
+async function firstVisible(
+ locator,
+ label,
+ { expectedShortVideoId = null, requireActiveShort = false, requireViewport = false, timeout = 20_000 } = {},
+) {
+ return waitForValue(
+ async () => {
+ const count = await locator.count();
+ for (let index = 0; index < count; index += 1) {
+ const candidate = locator.nth(index);
+ if (!(await candidate.isVisible())) continue;
+ if (requireActiveShort || requireViewport) {
+ const isEligible = await candidate.evaluate(isShortCandidateEligible, {
+ activeShortRequired: requireActiveShort,
+ expectedShortVideoId,
+ });
+ if (!isEligible) continue;
+ }
+ return candidate;
+ }
+ return null;
+ },
+ Boolean,
+ `Timed out waiting for visible ${label}`,
+ timeout,
+ );
+}
+
+async function visibleLocatorIndexes(locator) {
+ const indexes = [];
+ const count = await locator.count();
+ for (let index = 0; index < count; index += 1) {
+ if (await locator.nth(index).isVisible()) indexes.push(index);
+ }
+ return indexes;
+}
+
+function relatedWatchVideoId(element, settings) {
+ try {
+ const url = new URL(element.getAttribute("href"), settings.origin);
+ const videoId = url.searchParams.get("v");
+ if (url.origin !== settings.origin || url.pathname !== "/watch") return null;
+ if (!/^[A-Za-z0-9_-]{11}$/.test(videoId || "")) return null;
+ if (videoId === settings.currentVideoId || settings.excludedVideoIds.includes(videoId)) return null;
+ return videoId;
+ } catch {
+ return null;
+ }
+}
+
+async function firstVisibleRelatedWatchLink(page, currentVideoId, excludedVideoIds, timeout = 30_000) {
+ const locator = page.locator('#related a[href*="/watch"]');
+ const origin = new URL(page.url()).origin;
+ return waitForValue(
+ async () => {
+ const count = await locator.count();
+ for (let index = 0; index < count; index += 1) {
+ const candidate = locator.nth(index);
+ if (!(await candidate.isVisible())) continue;
+ const videoId = await candidate.evaluate(relatedWatchVideoId, {
+ currentVideoId,
+ excludedVideoIds,
+ origin,
+ });
+ if (videoId) return { link: candidate, videoId };
+ }
+ return null;
+ },
+ Boolean,
+ `Timed out waiting for a visible unvisited #related watch link after ${currentVideoId}`,
+ timeout,
+ );
+}
+
+async function firstVisibleExactVideoLink(page, videoId, kind, timeout = 30_000) {
+ const locator = page.locator(kind === "short" ? 'a[href*="/shorts/"]' : 'a[href*="/watch"]');
+ return waitForValue(
+ async () => {
+ const count = await locator.count();
+ for (let index = 0; index < count; index += 1) {
+ const candidate = locator.nth(index);
+ if (!(await candidate.isVisible())) continue;
+ const isExactTarget = await candidate.evaluate(
+ (element, target) => {
+ try {
+ const url = new URL(element.getAttribute("href"), location.origin);
+ if (url.origin !== location.origin) return false;
+ if (target.kind === "short") return url.pathname === `/shorts/${target.videoId}`;
+ return url.pathname === "/watch" && url.searchParams.get("v") === target.videoId;
+ } catch {
+ return false;
+ }
+ },
+ { kind, videoId },
+ );
+ if (isExactTarget) return candidate;
+ }
+ return null;
+ },
+ Boolean,
+ `Timed out waiting for an exact visible ${kind} link for ${videoId} on the configured channel page`,
+ timeout,
+ );
+}
+
+async function clickWithSingleNavigationRetry({
+ click,
+ hasNavigated,
+ reportProgress,
+ retryDetails,
+ waitForNavigation,
+}) {
+ const runAttempt = async (attempt, timeout) => {
+ const [navigationResult, clickResult] = await Promise.allSettled([
+ waitForNavigation(timeout),
+ click(attempt, timeout),
+ ]);
+ if (navigationResult.status === "fulfilled" && clickResult.status === "fulfilled") return;
+ throw navigationResult.status === "rejected" ? navigationResult.reason : clickResult.reason;
+ };
+ let firstError;
+ try {
+ await runAttempt(1, SHORTS_NEXT_FIRST_CLICK_TIMEOUT);
+ return { retried: false };
+ } catch (error) {
+ firstError = error;
+ if (hasNavigated()) return { retried: false };
+ }
+
+ reportProgress("shorts-next-control.retrying", {
+ ...retryDetails,
+ firstFailure: String(firstError?.message ?? firstError),
+ firstTimeoutMs: SHORTS_NEXT_FIRST_CLICK_TIMEOUT,
+ retryTimeoutMs: SHORTS_NEXT_RETRY_TIMEOUT,
+ });
+ try {
+ await runAttempt(2, SHORTS_NEXT_RETRY_TIMEOUT);
+ return { retried: true };
+ } catch (retryError) {
+ if (hasNavigated()) return { retried: true };
+ throw new Error(
+ `YouTube did not navigate after the first Shorts Next click or its single retry. First failure: ${String(firstError?.message ?? firstError)}. Retry failure: ${String(retryError?.message ?? retryError)}`,
+ { cause: retryError },
+ );
+ }
+}
+
+function videoIdFromUrl(value) {
+ const url = new URL(value);
+ if (url.pathname.startsWith("/shorts/")) return url.pathname.split("/")[2] || null;
+ return url.searchParams.get("v");
+}
+
+function readJsonBody(request) {
+ try {
+ return request.postDataJSON();
+ } catch {
+ return null;
+ }
+}
+
+function assertLogicalVoteHandshake(records, videoId, value) {
+ assert.ok(
+ records.length >= 2 && records.length <= 4,
+ `Expected one to three vote puzzle requests followed by one confirmation; received ${records.length} interaction requests.`,
+ );
+ const voteCount = records.length - 1;
+ assert.deepEqual(
+ records.map((record) => record.pathname),
+ [...Array(voteCount).fill("/interact/vote"), "/interact/confirmVote"],
+ "A logical vote may make at most three matching puzzle requests, then must send exactly one confirmation and no other interaction traffic.",
+ );
+
+ const votes = records.slice(0, voteCount);
+ const confirmation = records[voteCount];
+ const userId = votes[0]?.body?.userId;
+ assert.equal(typeof userId, "string", "The vote request has no user ID.");
+ for (const vote of votes) {
+ assert.equal(vote.body?.userId, userId, "Vote puzzle retries used different user IDs.");
+ assert.equal(vote.body?.videoId, videoId, "Vote puzzle retry targeted a different video.");
+ assert.equal(vote.body?.value, value, "Vote puzzle retry changed the requested vote value.");
+ assert.ok(vote.status >= 200 && vote.status < 300, `Vote request failed with HTTP ${vote.status}.`);
+ assert.equal(vote.responseError, null, `Vote response could not be read: ${vote.responseError}`);
+ }
+
+ assert.equal(confirmation.body?.userId, userId, "Vote and confirmation used different user IDs.");
+ assert.equal(confirmation.body?.videoId, videoId, "Vote confirmation targeted a different video.");
+ assert.ok(
+ confirmation.status >= 200 && confirmation.status < 300,
+ `Vote confirmation failed with HTTP ${confirmation.status}.`,
+ );
+ assert.equal(
+ confirmation.responseError,
+ null,
+ `Confirmation response could not be read: ${confirmation.responseError}`,
+ );
+ assert.equal(confirmation.responseBody, true, "The production API did not confirm the vote.");
+ return userId;
+}
+
+function assertVisibleBox(box, label) {
+ assert.ok(box, `${label} has no rendered bounding box.`);
+ assert.ok(box.width > 0 && box.height > 0, `${label} has non-positive geometry: ${JSON.stringify(box)}`);
+}
+
+function assertBoxInsideViewport(box, viewport, label, tolerance = 1) {
+ assertVisibleBox(box, label);
+ assert.ok(box.x >= -tolerance, `${label} is clipped past the viewport's left edge.`);
+ assert.ok(box.y >= -tolerance, `${label} is clipped past the viewport's top edge.`);
+ assert.ok(box.x + box.width <= viewport.width + tolerance, `${label} is clipped past the viewport's right edge.`);
+ assert.ok(box.y + box.height <= viewport.height + tolerance, `${label} is clipped past the viewport's bottom edge.`);
+}
+
+function assertShortsActionStackGeometry(boxes, viewport, tolerance = 1) {
+ assert.ok(Array.isArray(boxes), "The Shorts action-stack geometry is missing.");
+ assert.ok(boxes.length >= 5, `Expected the full Shorts action stack; found only ${boxes.length} visible controls.`);
+ const center = boxCenterX(boxes[0]);
+ boxes.forEach((box, index) => {
+ const label = `Shorts action ${index + 1}`;
+ assertBoxInsideViewport(box, viewport, label, tolerance);
+ assertNear(boxCenterX(box), center, tolerance, `${label} horizontal center`);
+ if (index > 0) {
+ const previous = boxes[index - 1];
+ assert.ok(box.y >= previous.y + previous.height - tolerance, `${label} overlaps the preceding Shorts action.`);
+ }
+ });
+}
+
+function assertWatchRatioViewportAlignment(containerBox, likeBox, dislikeBox, viewport, tolerance = 1) {
+ assertVisibleBox(containerBox, "Watch ratio bar");
+ assertVisibleBox(likeBox, "Watch like control");
+ assertVisibleBox(dislikeBox, "Watch dislike control");
+
+ const controls = [likeBox, dislikeBox];
+ const nativeLeft = Math.min(...controls.map((box) => box.x));
+ const nativeRight = Math.max(...controls.map((box) => box.x + box.width));
+ const nativeControlsAreHorizontallyClipped = nativeLeft < -tolerance || nativeRight > viewport.width + tolerance;
+ if (!nativeControlsAreHorizontallyClipped) {
+ assertBoxInsideViewport(containerBox, viewport, "Watch ratio bar", tolerance);
+ return { nativeControlsAreHorizontallyClipped, nativeLeft, nativeRight };
+ }
+
+ for (const [box, label] of [
+ [likeBox, "Watch like control"],
+ [dislikeBox, "Watch dislike control"],
+ [containerBox, "Watch ratio bar"],
+ ]) {
+ assert.ok(box.y >= -tolerance, `${label} is clipped past the viewport's top edge.`);
+ assert.ok(
+ box.y + box.height <= viewport.height + tolerance,
+ `${label} is clipped past the viewport's bottom edge.`,
+ );
+ }
+
+ const containerRight = containerBox.x + containerBox.width;
+ assertNear(
+ containerBox.x,
+ nativeLeft,
+ tolerance,
+ "Clipped watch ratio bar left edge alignment with native reaction controls",
+ );
+ assertNear(
+ containerRight,
+ nativeRight,
+ tolerance,
+ "Clipped watch ratio bar right edge alignment with native reaction controls",
+ );
+ assert.ok(
+ containerBox.x >= nativeLeft - tolerance && containerRight <= nativeRight + tolerance,
+ "The watch ratio bar adds horizontal overflow beyond the native reaction controls.",
+ );
+ return { nativeControlsAreHorizontallyClipped, nativeLeft, nativeRight };
+}
+
+function croppedScreenshotClip(boxes, viewport, margin = 12) {
+ boxes.forEach((box, index) => assertVisibleBox(box, `Screenshot target ${index + 1}`));
+ const left = Math.max(0, Math.floor(Math.min(...boxes.map((box) => box.x)) - margin));
+ const top = Math.max(0, Math.floor(Math.min(...boxes.map((box) => box.y)) - margin));
+ const right = Math.min(viewport.width, Math.ceil(Math.max(...boxes.map((box) => box.x + box.width)) + margin));
+ const bottom = Math.min(viewport.height, Math.ceil(Math.max(...boxes.map((box) => box.y + box.height)) + margin));
+ assert.ok(right > left && bottom > top, "The cropped screenshot target is outside the viewport.");
+ return { x: left, y: top, width: right - left, height: bottom - top };
+}
+
+function boxIntersectionWithViewport(box, viewport) {
+ if (!box) return null;
+ const left = Math.max(0, box.x);
+ const top = Math.max(0, box.y);
+ const right = Math.min(viewport.width, box.x + box.width);
+ const bottom = Math.min(viewport.height, box.y + box.height);
+ if (right <= left || bottom <= top) return null;
+ return { height: bottom - top, width: right - left, x: left, y: top };
+}
+
+function squaredDistanceFromBox(point, box) {
+ const deltaX = Math.max(box.x - point.x, 0, point.x - (box.x + box.width));
+ const deltaY = Math.max(box.y - point.y, 0, point.y - (box.y + box.height));
+ return deltaX * deltaX + deltaY * deltaY;
+}
+
+function pointerPositionAwayFromBoxes(boxes, viewport, preferredBox = null) {
+ const visiblePreferredBox = boxIntersectionWithViewport(preferredBox, viewport);
+ if (visiblePreferredBox) {
+ const preferredCandidates = [
+ { x: 0.5, y: 0.5 },
+ { x: 0.25, y: 0.25 },
+ { x: 0.75, y: 0.25 },
+ { x: 0.25, y: 0.75 },
+ { x: 0.75, y: 0.75 },
+ { x: 0.5, y: 0.25 },
+ { x: 0.25, y: 0.5 },
+ { x: 0.75, y: 0.5 },
+ { x: 0.5, y: 0.75 },
+ ].map((position) => ({
+ x: visiblePreferredBox.x + visiblePreferredBox.width * position.x,
+ y: visiblePreferredBox.y + visiblePreferredBox.height * position.y,
+ }));
+ const safePreferredCandidates = preferredCandidates.filter((point) =>
+ boxes.every((box) => squaredDistanceFromBox(point, box) > 0),
+ );
+ if (safePreferredCandidates.length > 0) {
+ return safePreferredCandidates[0];
+ }
+ }
+
+ const fallbackCandidates = [
+ { x: viewport.width * 0.25, y: viewport.height * 0.25 },
+ { x: viewport.width * 0.5, y: viewport.height * 0.25 },
+ { x: viewport.width * 0.75, y: viewport.height * 0.25 },
+ { x: viewport.width * 0.25, y: viewport.height * 0.5 },
+ { x: viewport.width * 0.5, y: viewport.height * 0.5 },
+ { x: viewport.width * 0.75, y: viewport.height * 0.5 },
+ { x: viewport.width * 0.25, y: viewport.height * 0.75 },
+ { x: viewport.width * 0.5, y: viewport.height * 0.75 },
+ { x: viewport.width * 0.75, y: viewport.height * 0.75 },
+ ];
+ const ranked = fallbackCandidates
+ .map((point) => ({
+ distance: Math.min(...boxes.map((box) => squaredDistanceFromBox(point, box))),
+ point,
+ }))
+ .sort((left, right) => right.distance - left.distance);
+ assert.ok(ranked[0]?.distance > 0, "Could not place the screenshot pointer away from the reaction controls.");
+ return ranked[0].point;
+}
+
+function assertNear(actual, expected, tolerance, label) {
+ assert.ok(Number.isFinite(actual), `${label} is not a finite number: ${actual}`);
+ assert.ok(Number.isFinite(expected), `${label} has no finite reference value: ${expected}`);
+ assert.ok(
+ Math.abs(actual - expected) <= tolerance,
+ `${label} must be ${expected} +/- ${tolerance}px; received ${actual}px.`,
+ );
+}
+
+function boxCenterX(box) {
+ return box.x + box.width / 2;
+}
+
+function boxCenterY(box) {
+ return box.y + box.height / 2;
+}
+
+function assertBoxSize(box, width, height, label, tolerance = SHORTS_GEOMETRY.geometryTolerance) {
+ assertVisibleBox(box, label);
+ assertNear(box.width, width, tolerance, `${label} width`);
+ assertNear(box.height, height, tolerance, `${label} height`);
+}
+
+function assertHostInsets(style, label) {
+ assert.ok(style, `${label} computed style is missing.`);
+ for (const [property, expected] of Object.entries(SHORTS_ACTION_HOST_INSETS)) {
+ assertNear(style[property], expected, SHORTS_GEOMETRY.geometryTolerance, `${label} ${property}`);
+ }
+}
+
+function assertCountTypography(actual, native) {
+ assert.ok(actual, "Synthetic Shorts count typography is missing.");
+ assert.ok(native, "Native Like count typography is missing.");
+ for (const style of [
+ [native, "Native Like count"],
+ [actual, "Synthetic Shorts count"],
+ ]) {
+ assertNear(style[0].fontSize, SHORTS_GEOMETRY.fontSize, SHORTS_GEOMETRY.textTolerance, `${style[1]} font-size`);
+ assertNear(
+ style[0].lineHeight,
+ SHORTS_GEOMETRY.lineHeight,
+ SHORTS_GEOMETRY.textTolerance,
+ `${style[1]} line-height`,
+ );
+ }
+ for (const property of ["fontFamily", "fontStyle", "fontWeight"]) {
+ assert.equal(
+ actual[property],
+ native[property],
+ `Synthetic Shorts count ${property} does not match the native Like count.`,
+ );
+ }
+}
+
+const REACTION_PRESSED_STATES = {
+ disliked: { dislikeState: "true", likeState: "false" },
+ liked: { dislikeState: "false", likeState: "true" },
+ neutral: { dislikeState: "false", likeState: "false" },
+};
+
+function assertReactionPressedStates(actual, expectedState) {
+ const expected = REACTION_PRESSED_STATES[expectedState];
+ assert.ok(expected, `Unsupported expected YouTube reaction state: ${expectedState}`);
+ assert.ok(["true", "false"].includes(actual.likeState), `Unexpected YouTube like state: ${actual.likeState}`);
+ assert.ok(
+ ["true", "false"].includes(actual.dislikeState),
+ `Unexpected YouTube dislike state: ${actual.dislikeState}`,
+ );
+ assert.ok(
+ !(actual.likeState === "true" && actual.dislikeState === "true"),
+ "YouTube reported Like and Dislike as selected.",
+ );
+ assert.equal(actual.likeState, expected.likeState, `Expected YouTube reaction state ${expectedState}.`);
+ assert.equal(actual.dislikeState, expected.dislikeState, `Expected YouTube reaction state ${expectedState}.`);
+}
+
+function assertSyntheticShortsGeometry(measurement) {
+ assert.ok(measurement, "Synthetic Shorts geometry measurement is missing.");
+ const { like, next, synthetic } = measurement;
+ assert.ok(like, "Native Like geometry is missing.");
+ assert.ok(synthetic, "Synthetic Shorts geometry is missing.");
+ assert.ok(next, "The action following the synthetic Shorts control is missing.");
+
+ assertBoxSize(like.host, SHORTS_GEOMETRY.controlWidth, SHORTS_GEOMETRY.controlHeight, "Native Like action host");
+ assertBoxSize(
+ synthetic.host,
+ SHORTS_GEOMETRY.controlWidth,
+ SHORTS_GEOMETRY.controlHeight,
+ "Synthetic Shorts action host",
+ );
+ assertBoxSize(like.label, SHORTS_GEOMETRY.controlWidth, SHORTS_GEOMETRY.labelHeight, "Native Like label");
+ assertBoxSize(synthetic.label, SHORTS_GEOMETRY.controlWidth, SHORTS_GEOMETRY.labelHeight, "Synthetic Shorts label");
+ assertBoxSize(like.button, SHORTS_GEOMETRY.buttonSize, SHORTS_GEOMETRY.buttonSize, "Native Like button");
+ assertBoxSize(synthetic.button, SHORTS_GEOMETRY.buttonSize, SHORTS_GEOMETRY.buttonSize, "Synthetic Shorts button");
+ assertBoxSize(like.icon, SHORTS_GEOMETRY.iconSize, SHORTS_GEOMETRY.iconSize, "Native Like icon container");
+ assertBoxSize(synthetic.icon, SHORTS_GEOMETRY.iconSize, SHORTS_GEOMETRY.iconSize, "Synthetic Shorts icon container");
+ assertBoxSize(like.svg, SHORTS_GEOMETRY.iconSize, SHORTS_GEOMETRY.iconSize, "Native Like SVG");
+ assertBoxSize(synthetic.svg, SHORTS_GEOMETRY.iconSize, SHORTS_GEOMETRY.iconSize, "Synthetic Shorts SVG");
+ assertVisibleBox(like.count, "Native Like count");
+ assertVisibleBox(synthetic.count, "Synthetic Shorts count");
+ assertVisibleBox(next.host, "Action following the synthetic Shorts control");
+
+ assertHostInsets(like.hostStyle, "Native Like action host");
+ assertHostInsets(synthetic.hostStyle, "Synthetic Shorts action host");
+ assertCountTypography(synthetic.countStyle, like.countStyle);
+
+ const tolerance = SHORTS_GEOMETRY.geometryTolerance;
+ const actionCenter = boxCenterX(like.button);
+ for (const [box, label] of [
+ [like.host, "Native Like action host"],
+ [like.label, "Native Like label"],
+ [like.icon, "Native Like icon container"],
+ [like.svg, "Native Like SVG"],
+ [like.count, "Native Like count"],
+ [synthetic.host, "Synthetic Shorts action host"],
+ [synthetic.label, "Synthetic Shorts label"],
+ [synthetic.button, "Synthetic Shorts button"],
+ [synthetic.icon, "Synthetic Shorts icon container"],
+ [synthetic.svg, "Synthetic Shorts SVG"],
+ [synthetic.count, "Synthetic Shorts count"],
+ [next.host, "Action following the synthetic Shorts control"],
+ ]) {
+ assertNear(boxCenterX(box), actionCenter, tolerance, `${label} horizontal center`);
+ }
+
+ for (const [control, label] of [
+ [like, "Native Like"],
+ [synthetic, "Synthetic Shorts"],
+ ]) {
+ assertNear(boxCenterY(control.icon), boxCenterY(control.button), tolerance, `${label} icon vertical center`);
+ assertNear(boxCenterY(control.svg), boxCenterY(control.button), tolerance, `${label} SVG vertical center`);
+ }
+
+ assertNear(
+ synthetic.host.y - (like.host.y + like.host.height),
+ 0,
+ tolerance,
+ "Gap between native Like and synthetic Shorts action hosts",
+ );
+ assertNear(
+ next.host.y - (synthetic.host.y + synthetic.host.height),
+ 0,
+ tolerance,
+ "Gap between synthetic Shorts and following action hosts",
+ );
+}
+
+function assertNativeShortsPairGeometry(measurement) {
+ assert.ok(measurement, "Native Shorts pair geometry is missing.");
+ const { dislike, like } = measurement;
+ assert.ok(like, "Native Shorts Like geometry is missing.");
+ assert.ok(dislike, "Native Shorts Dislike geometry is missing.");
+ assert.equal(like.videoMatches, true, "The native Shorts Like control is outside the active reel.");
+ assert.equal(dislike.videoMatches, true, "The native Shorts Dislike control is outside the active reel.");
+ assert.ok(like.reelIndex >= 0, "The native Shorts Like control has no owning reel.");
+ assert.equal(dislike.reelIndex, like.reelIndex, "Native Shorts Like and Dislike belong to different reels.");
+ assert.ok(like.actionIndex >= 0, "The native Shorts Like control has no owning action stack.");
+ assert.equal(
+ dislike.actionIndex,
+ like.actionIndex + 1,
+ "Native Shorts Dislike is not immediately after Like in the active action stack.",
+ );
+
+ for (const [action, label] of [
+ [like, "Native Shorts Like"],
+ [dislike, "Native Shorts Dislike"],
+ ]) {
+ assertBoxSize(action.host, SHORTS_GEOMETRY.controlWidth, SHORTS_GEOMETRY.controlHeight, `${label} action host`);
+ assertBoxSize(action.label, SHORTS_GEOMETRY.controlWidth, SHORTS_GEOMETRY.labelHeight, `${label} label`);
+ assertBoxSize(action.button, SHORTS_GEOMETRY.buttonSize, SHORTS_GEOMETRY.buttonSize, `${label} button`);
+ assertBoxSize(action.icon, SHORTS_GEOMETRY.iconSize, SHORTS_GEOMETRY.iconSize, `${label} icon container`);
+ assertBoxSize(action.svg, SHORTS_GEOMETRY.iconSize, SHORTS_GEOMETRY.iconSize, `${label} SVG`);
+ assertVisibleBox(action.count, `${label} count`);
+ assertHostInsets(action.hostStyle, `${label} action host`);
+ }
+
+ assertCountTypography(dislike.countStyle, like.countStyle);
+ assertNear(
+ boxCenterX(dislike.host),
+ boxCenterX(like.host),
+ SHORTS_GEOMETRY.geometryTolerance,
+ "Native Shorts action-host horizontal center",
+ );
+ assertNear(
+ boxCenterX(dislike.button),
+ boxCenterX(like.button),
+ SHORTS_GEOMETRY.geometryTolerance,
+ "Native Shorts button horizontal center",
+ );
+ assertNear(
+ dislike.host.y,
+ like.host.y + like.host.height,
+ SHORTS_GEOMETRY.geometryTolerance,
+ "Gap between native Shorts Like and Dislike action hosts",
+ );
+}
+
+function readNativeShortsActionMeasurement(element, expectedVideoId) {
+ const reelSelector = "ytd-reel-video-renderer, ytm-reel-video-renderer";
+ const reel = element.closest(reelSelector);
+ const host =
+ element.closest(
+ "like-button-view-model, dislike-button-view-model, ytd-like-button-renderer, ytd-dislike-button-renderer",
+ ) ??
+ element.closest("label") ??
+ element;
+ const actionBar = host.parentElement;
+ const label = element.closest("label") ?? host.querySelector("label");
+ const icon =
+ element.querySelector(".ytSpecButtonShapeNextIcon, .yt-spec-button-shape-next__icon, yt-icon") ??
+ element.querySelector("svg")?.parentElement ??
+ null;
+ const svg = icon?.querySelector("svg") ?? element.querySelector("svg");
+ const count = [
+ ...host.querySelectorAll("#text, [role='text'], .yt-spec-button-shape-next__button-text-content"),
+ ].find((candidate) => /\d/.test(candidate.innerText ?? candidate.textContent ?? ""));
+ const expectedPath = `/shorts/${expectedVideoId}`;
+ const rendererVideoId = reel?.getAttribute("video-id");
+ const videoMatches = rendererVideoId
+ ? rendererVideoId === expectedVideoId
+ : [...(reel?.querySelectorAll('a[href*="/shorts/"]') ?? [])].some((link) => {
+ try {
+ return new URL(link.getAttribute("href"), location.origin).pathname === expectedPath;
+ } catch {
+ return false;
+ }
+ });
+ const readBox = (node) => {
+ if (!node) return null;
+ const box = node.getBoundingClientRect();
+ return { height: box.height, width: box.width, x: box.x, y: box.y };
+ };
+ const cssPixels = (value) => {
+ const number = Number.parseFloat(value);
+ return Number.isFinite(number) ? number : null;
+ };
+ const readHostStyle = (node) => {
+ if (!node) return null;
+ const style = getComputedStyle(node);
+ return {
+ marginBottom: cssPixels(style.marginBottom),
+ marginLeft: cssPixels(style.marginLeft),
+ marginRight: cssPixels(style.marginRight),
+ marginTop: cssPixels(style.marginTop),
+ paddingBottom: cssPixels(style.paddingBottom),
+ paddingLeft: cssPixels(style.paddingLeft),
+ paddingRight: cssPixels(style.paddingRight),
+ paddingTop: cssPixels(style.paddingTop),
+ };
+ };
+ const readCountStyle = (node) => {
+ if (!node) return null;
+ const style = getComputedStyle(node);
+ return {
+ fontFamily: style.fontFamily,
+ fontSize: cssPixels(style.fontSize),
+ fontStyle: style.fontStyle,
+ fontWeight: style.fontWeight,
+ lineHeight: cssPixels(style.lineHeight),
+ };
+ };
+ const actionHosts = [...(actionBar?.children ?? [])]
+ .filter((candidate) => {
+ const button = candidate.matches("button") ? candidate : candidate.querySelector("button");
+ if (!button) return false;
+ const box = candidate.getBoundingClientRect();
+ const style = getComputedStyle(candidate);
+ return (
+ box.width > 0 &&
+ box.height > 0 &&
+ box.bottom > 0 &&
+ box.right > 0 &&
+ box.top < innerHeight &&
+ box.left < innerWidth &&
+ style.display !== "none" &&
+ style.visibility !== "hidden"
+ );
+ })
+ .map(readBox);
+
+ return {
+ actionIndex: actionBar ? [...actionBar.children].indexOf(host) : -1,
+ actionHosts,
+ button: readBox(element),
+ count: readBox(count),
+ countStyle: readCountStyle(count),
+ host: readBox(host),
+ hostStyle: readHostStyle(host),
+ icon: readBox(icon),
+ label: readBox(label),
+ reelIndex: reel ? [...document.querySelectorAll(reelSelector)].indexOf(reel) : -1,
+ svg: readBox(svg),
+ videoMatches,
+ };
+}
+
+class VoteTrafficRecorder {
+ constructor(context, videoId, { handshakeTimeout = 120_000 } = {}) {
+ this.context = context;
+ this.handshakeTimeout = handshakeTimeout;
+ this.videoId = videoId;
+ this.records = [];
+ this.recordsByRequest = new WeakMap();
+ this.onRequest = this.onRequest.bind(this);
+ this.onResponse = this.onResponse.bind(this);
+ context.on("request", this.onRequest);
+ context.on("response", this.onResponse);
+ }
+
+ onRequest(request) {
+ const url = new URL(request.url());
+ if (url.origin !== API_ORIGIN || request.method() !== "POST" || !url.pathname.startsWith("/interact/")) return;
+
+ const body = readJsonBody(request);
+ const record = {
+ body,
+ pathname: url.pathname,
+ requestedAt: Date.now(),
+ responseBody: undefined,
+ responseError: null,
+ respondedAt: null,
+ status: null,
+ };
+ this.records.push(record);
+ this.recordsByRequest.set(request, record);
+ }
+
+ onResponse(response) {
+ const record = this.recordsByRequest.get(response.request());
+ if (!record) return;
+
+ record.status = response.status();
+ record.respondedAt = Date.now();
+ void response
+ .text()
+ .then((text) => {
+ try {
+ record.responseBody = JSON.parse(text);
+ } catch {
+ record.responseBody = text;
+ }
+ })
+ .catch((error) => {
+ record.responseError = error.message;
+ });
+ }
+
+ mark() {
+ return this.records.length;
+ }
+
+ hasVote(value, startIndex) {
+ return this.records
+ .slice(startIndex)
+ .some(
+ (record) =>
+ record.pathname === "/interact/vote" && record.body?.videoId === this.videoId && record.body?.value === value,
+ );
+ }
+
+ voteUserId(value, startIndex) {
+ return this.records
+ .slice(startIndex)
+ .find(
+ (record) =>
+ record.pathname === "/interact/vote" && record.body?.videoId === this.videoId && record.body?.value === value,
+ )?.body?.userId;
+ }
+
+ async waitForHandshake(value, startIndex) {
+ const records = await waitForValue(
+ () => {
+ const current = this.records.slice(startIndex);
+ const voteIndex = current.findIndex(
+ (record) =>
+ record.pathname === "/interact/vote" &&
+ record.body?.videoId === this.videoId &&
+ record.body?.value === value &&
+ record.status !== null,
+ );
+ const vote = current[voteIndex];
+ const confirmation = current.find(
+ (record, index) =>
+ index > voteIndex &&
+ record.pathname === "/interact/confirmVote" &&
+ record.body?.videoId === this.videoId &&
+ record.body?.userId === vote?.body?.userId &&
+ record.responseBody !== undefined,
+ );
+ return voteIndex >= 0 && confirmation ? current : null;
+ },
+ Boolean,
+ `Timed out waiting for the production vote handshake for value ${value}`,
+ this.handshakeTimeout,
+ );
+
+ return this.assertOneHandshake(value, startIndex);
+ }
+
+ async assertOneHandshake(value, startIndex) {
+ let previousLength = -1;
+ let stableSince = Date.now();
+ const quietPeriodDeadline = Date.now() + 5_000;
+ while (Date.now() - stableSince < 500) {
+ if (Date.now() >= quietPeriodDeadline) {
+ throw new Error("Interaction traffic did not become quiet. Ensure only one RYD runtime is enabled.");
+ }
+ const currentLength = this.records.length;
+ if (currentLength !== previousLength) {
+ previousLength = currentLength;
+ stableSince = Date.now();
+ }
+ await delay(100);
+ }
+
+ const records = this.records.slice(startIndex);
+ return assertLogicalVoteHandshake(records, this.videoId, value);
+ }
+
+ stop() {
+ this.context.off("request", this.onRequest);
+ this.context.off("response", this.onResponse);
+ }
+}
+
+class LiveYoutubeDriver {
+ constructor(page, context, { reportProgress = () => {}, visualTooltipTimeout = VISUAL_TOOLTIP_TIMEOUT } = {}) {
+ this.page = page;
+ this.context = context;
+ this.reportProgress = reportProgress;
+ this.visualTooltipTimeout = visualTooltipTimeout;
+ this.readOnlyInteractionGuard = null;
+ page.setDefaultTimeout(20_000);
+ page.setDefaultNavigationTimeout(30_000);
+ }
+
+ async readViewportSize() {
+ return this.page.evaluate(() => ({ height: innerHeight, width: innerWidth }));
+ }
+
+ async setViewportSize(viewport) {
+ await this.page.setViewportSize(viewport);
+ await waitForValue(
+ () => this.readViewportSize(),
+ (current) => current.width === viewport.width && current.height === viewport.height,
+ `Timed out resizing the live tab to ${viewport.width}x${viewport.height}`,
+ );
+ await this.page.evaluate(
+ () => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))),
+ );
+ }
+
+ async readLargestVisibleVideoBox(viewport) {
+ const videos = this.page.locator("video");
+ let largest = null;
+ let largestVisibleArea = 0;
+ for (let index = 0; index < (await videos.count()); index += 1) {
+ const video = videos.nth(index);
+ if (!(await video.isVisible())) continue;
+ const box = await video.boundingBox();
+ const visibleBox = boxIntersectionWithViewport(box, viewport);
+ const visibleArea = visibleBox ? visibleBox.width * visibleBox.height : 0;
+ if (visibleArea > largestVisibleArea) {
+ largest = box;
+ largestVisibleArea = visibleArea;
+ }
+ }
+ return largest;
+ }
+
+ async captureCroppedScreenshot(screenshotPath, boxes, { waitForVisualReadiness = null } = {}) {
+ const viewport = await this.readViewportSize();
+ const clip = croppedScreenshotClip(boxes, viewport);
+ const videoBox = await this.readLargestVisibleVideoBox(viewport);
+ const pointer = pointerPositionAwayFromBoxes(boxes, viewport, videoBox);
+ await this.page.mouse.move(pointer.x, pointer.y);
+ await waitForValue(
+ async () => {
+ const tooltips = this.page.locator(NATIVE_YOUTUBE_TOOLTIP_SELECTOR);
+ const visibleTooltips = [];
+ for (let index = 0; index < (await tooltips.count()); index += 1) {
+ const tooltip = tooltips.nth(index);
+ if (await tooltip.isVisible()) {
+ visibleTooltips.push((await tooltip.innerText()).replace(/\s+/g, " ").trim() || "");
+ }
+ }
+ return visibleTooltips;
+ },
+ (visibleTooltips) => visibleTooltips.length === 0,
+ "Timed out waiting for native YouTube tooltips to hide before screenshot capture",
+ this.visualTooltipTimeout,
+ );
+ if (waitForVisualReadiness) await waitForVisualReadiness();
+ await this.page.evaluate(
+ () => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))),
+ );
+ await this.page.screenshot({
+ animations: "disabled",
+ caret: "hide",
+ clip,
+ path: screenshotPath,
+ });
+ return clip;
+ }
+
+ async waitForShortsVisualPaint(buttons, timeout = SHORTS_VISUAL_PAINT_TIMEOUT) {
+ return waitForValue(
+ () => Promise.all(buttons.map((button) => button.evaluate(readShortsIconVisualState))),
+ (states) => states.every(isShortsIconVisualReady),
+ "Timed out waiting for the visible Shorts reaction icons to finish painting",
+ timeout,
+ );
+ }
+
+ async withNoProductionInteractions(action) {
+ if (this.readOnlyInteractionGuard) {
+ this.readOnlyInteractionGuard.depth += 1;
+ try {
+ return await action();
+ } finally {
+ this.readOnlyInteractionGuard.depth -= 1;
+ }
+ }
+
+ const guard = {
+ abortedRequests: [],
+ depth: 1,
+ observedRequests: [],
+ routeErrors: [],
+ };
+ this.readOnlyInteractionGuard = guard;
+ const routeMatcher = (url) => url.origin === API_ORIGIN && url.pathname.startsWith("/interact/");
+ const onRequest = (request) => {
+ const url = new URL(request.url());
+ if (url.origin !== API_ORIGIN || request.method() !== "POST" || !url.pathname.startsWith("/interact/")) return;
+ guard.observedRequests.push({ method: request.method(), pathname: url.pathname });
+ };
+ const onRoute = async (route) => {
+ const request = route.request();
+ if (request.method() !== "POST") {
+ await route.fallback();
+ return;
+ }
+
+ const url = new URL(request.url());
+ guard.abortedRequests.push({ method: request.method(), pathname: url.pathname });
+ try {
+ await route.abort("blockedbyclient");
+ } catch (error) {
+ guard.routeErrors.push(error.message);
+ }
+ };
+ let requestListenerInstalled = false;
+ let routeInstalled = false;
+ let result;
+ let actionError;
+ try {
+ await this.context.route(routeMatcher, onRoute);
+ routeInstalled = true;
+ this.context.on("request", onRequest);
+ requestListenerInstalled = true;
+ try {
+ result = await action();
+ } catch (error) {
+ actionError = error;
+ }
+ await delay(250);
+ } finally {
+ if (requestListenerInstalled) this.context.off("request", onRequest);
+ try {
+ if (routeInstalled) await this.context.unroute(routeMatcher, onRoute);
+ } finally {
+ this.readOnlyInteractionGuard = null;
+ }
+ }
+ assert.deepEqual(
+ {
+ abortedRequests: guard.abortedRequests,
+ observedRequests: guard.observedRequests,
+ routeErrors: guard.routeErrors,
+ },
+ { abortedRequests: [], observedRequests: [], routeErrors: [] },
+ "The read-only live scenario attempted a production interaction. The request was blocked before transmission.",
+ );
+ if (actionError) throw actionError;
+ return result;
+ }
+
+ async inspectWatchRatioVisual(
+ runtime,
+ { expectedCount = null, presenceTimeoutMs = 20_000, waitForPresence = false } = {},
+ ) {
+ const selectors = RATE_BAR_SELECTORS[runtime];
+ if (!selectors) throw new Error(`Unsupported live visual runtime: ${runtime}`);
+
+ const containers = this.page.locator(selectors.container);
+ const bars = this.page.locator(selectors.bar);
+ if (waitForPresence) {
+ await firstVisible(containers, `${runtime} watch ratio bar`, { timeout: presenceTimeoutMs });
+ await firstVisible(bars, `${runtime} watch ratio fill`, { timeout: presenceTimeoutMs });
+ }
+
+ const [visibleContainerIndexes, visibleBarIndexes] = await Promise.all([
+ visibleLocatorIndexes(containers),
+ visibleLocatorIndexes(bars),
+ ]);
+ assert.equal(
+ visibleContainerIndexes.length,
+ 1,
+ `Expected exactly one visible ${runtime} watch ratio bar; found ${visibleContainerIndexes.length}.`,
+ );
+ assert.equal(
+ visibleBarIndexes.length,
+ 1,
+ `Expected exactly one visible ${runtime} watch ratio fill; found ${visibleBarIndexes.length}.`,
+ );
+
+ const count = await this.waitForDislikeText();
+ if (expectedCount !== null) {
+ assert.equal(count, expectedCount, "The current watch dislike count changed during the ratio-bar soak.");
+ }
+ const container = containers.nth(visibleContainerIndexes[0]);
+ const bar = bars.nth(visibleBarIndexes[0]);
+ const likeButton = await this.visibleLikeButton();
+ const dislikeButton = await this.visibleDislikeButton();
+ await container.scrollIntoViewIfNeeded();
+
+ const wrapper = container.locator(
+ "xpath=ancestor::*[contains(concat(' ', normalize-space(@class), ' '), ' ryd-tooltip ')][1]",
+ );
+ const [barBox, containerBox, dislikeBox, likeBox, wrapperBox, viewport] = await Promise.all([
+ bar.boundingBox(),
+ container.boundingBox(),
+ dislikeButton.boundingBox(),
+ likeButton.boundingBox(),
+ wrapper.boundingBox(),
+ this.readViewportSize(),
+ ]);
+
+ assert.match(count, /\d/, "The watch dislike control has no rendered count.");
+ assertVisibleBox(barBox, "Watch ratio fill");
+ assertVisibleBox(wrapperBox, "Watch ratio wrapper");
+ const viewportAlignment = assertWatchRatioViewportAlignment(containerBox, likeBox, dislikeBox, viewport);
+ assert.ok(barBox.x >= containerBox.x - 1, "The watch ratio fill starts outside its container.");
+ assert.ok(
+ barBox.x + barBox.width <= containerBox.x + containerBox.width + 1,
+ "The watch ratio fill extends outside its container.",
+ );
+ assert.ok(
+ containerBox.y >= Math.max(likeBox.y + likeBox.height, dislikeBox.y + dislikeBox.height) - 4,
+ "The watch ratio bar overlaps the reaction controls.",
+ );
+ const expectedWrapperWidth = likeBox.width + dislikeBox.width;
+ assert.ok(
+ wrapperBox.width >= expectedWrapperWidth * 0.75 && wrapperBox.width <= expectedWrapperWidth * 1.25,
+ `The watch ratio wrapper width ${wrapperBox.width} does not track the reaction controls (${expectedWrapperWidth}).`,
+ );
+
+ return {
+ count,
+ geometry: { bar: barBox, container: containerBox, dislike: dislikeBox, like: likeBox, wrapper: wrapperBox },
+ viewport,
+ viewportAlignment,
+ };
+ }
+
+ async assertWatchRatioVisual(runtime, options = {}) {
+ return this.inspectWatchRatioVisual(runtime, options);
+ }
+
+ async captureWatchRatioVisual(runtime, screenshotPath, { expectedCount = null, presenceTimeoutMs = 20_000 } = {}) {
+ const measurement = await this.inspectWatchRatioVisual(runtime, {
+ expectedCount,
+ presenceTimeoutMs,
+ waitForPresence: true,
+ });
+ const { container, dislike, like, wrapper } = measurement.geometry;
+ const clip = await this.captureCroppedScreenshot(screenshotPath, [like, dislike, container, wrapper]);
+ return {
+ ...measurement,
+ screenshotPath,
+ screenshotClip: clip,
+ };
+ }
+
+ async soakWatchRatioVisual(
+ runtime,
+ { durationMs = WATCH_RATIO_SOAK_DURATION_MS, expectedCount, intervalMs = WATCH_RATIO_SOAK_INTERVAL_MS, videoId },
+ ) {
+ assert.match(expectedCount, /\d/, "A rendered dislike count is required before soaking the watch ratio bar.");
+ assert.match(videoId, VIDEO_ID_PATTERN, "A valid current video ID is required for the watch ratio-bar soak.");
+ assert.ok(
+ Number.isFinite(durationMs) && durationMs >= 0,
+ "The watch ratio-bar soak duration must be non-negative.",
+ );
+ assert.ok(Number.isFinite(intervalMs) && intervalMs > 0, "The watch ratio-bar soak interval must be positive.");
+
+ const deadline = Date.now() + durationMs;
+ let lastMeasurement;
+ let sampleCount = 0;
+ this.reportProgress("watch-ratio-soak.start", { durationMs, expectedCount, runtime, videoId });
+ do {
+ this.assertCurrentVideo(videoId);
+ lastMeasurement = await this.assertWatchRatioVisual(runtime, { expectedCount });
+ sampleCount += 1;
+ const remaining = deadline - Date.now();
+ if (remaining <= 0) break;
+ await delay(Math.min(intervalMs, remaining));
+ } while (true);
+ this.reportProgress("watch-ratio-soak.complete", { durationMs, expectedCount, runtime, sampleCount, videoId });
+
+ return { count: lastMeasurement.count, durationMs, sampleCount, videoId };
+ }
+
+ async captureSyntheticShortsVisual(videoId, screenshotPath) {
+ this.assertCurrentVideo(videoId);
+ const controls = this.page.locator(SYNTHETIC_SHORTS_SELECTOR);
+ const control = await firstVisible(controls, `synthetic Shorts dislike control for ${videoId}`, {
+ expectedShortVideoId: videoId,
+ requireActiveShort: true,
+ requireViewport: true,
+ });
+ const button = await firstVisible(control.locator("button"), "synthetic Shorts dislike button", {
+ expectedShortVideoId: videoId,
+ requireActiveShort: true,
+ requireViewport: true,
+ });
+ const likeButton = await this.visibleLikeButton();
+ const countLocator = control.locator("#text, [role='text']").first();
+ const count = await waitForValue(
+ () => countLocator.evaluate((element) => (element.innerText ?? element.textContent ?? "").trim()),
+ (text) => /\d/.test(text),
+ "The synthetic Shorts dislike control has no rendered count",
+ 30_000,
+ );
+ await control.scrollIntoViewIfNeeded();
+
+ const measurement = await control.evaluate((element, expectedVideoId) => {
+ const actionBar = element.closest("reel-action-bar-view-model");
+ const reel = element.closest("ytd-reel-video-renderer, ytm-reel-video-renderer");
+ const likeHost = element.previousElementSibling;
+ const nextHost = element.nextElementSibling;
+ const likeLabel = likeHost?.querySelector("label") ?? null;
+ const likeButton = likeHost?.querySelector("button") ?? null;
+ const likeIcon = likeButton?.querySelector(".ytSpecButtonShapeNextIcon") ?? null;
+ const likeSvg = likeIcon?.querySelector("svg") ?? likeButton?.querySelector("svg") ?? null;
+ const likeCount = likeHost?.querySelector("#text, [role='text']") ?? null;
+ const syntheticLabel = element.querySelector("label");
+ const syntheticButton = element.querySelector("button");
+ const syntheticIcon = syntheticButton?.querySelector(".ytSpecButtonShapeNextIcon") ?? null;
+ const syntheticSvg = syntheticIcon?.querySelector("svg") ?? syntheticButton?.querySelector("svg") ?? null;
+ const syntheticCount = element.querySelector("#text, [role='text']");
+ const expectedPath = `/shorts/${expectedVideoId}`;
+ const rendererVideoId = reel?.getAttribute("video-id");
+ const videoMatches = rendererVideoId
+ ? rendererVideoId === expectedVideoId
+ : [...(reel?.querySelectorAll('a[href*="/shorts/"]') ?? [])].some((link) => {
+ try {
+ return new URL(link.getAttribute("href"), location.origin).pathname === expectedPath;
+ } catch {
+ return false;
+ }
+ });
+
+ const readBox = (node) => {
+ if (!node) return null;
+ const box = node.getBoundingClientRect();
+ return { height: box.height, width: box.width, x: box.x, y: box.y };
+ };
+ const cssPixels = (value) => {
+ const number = Number.parseFloat(value);
+ return Number.isFinite(number) ? number : null;
+ };
+ const readHostStyle = (node) => {
+ if (!node) return null;
+ const style = getComputedStyle(node);
+ return {
+ marginBottom: cssPixels(style.marginBottom),
+ marginLeft: cssPixels(style.marginLeft),
+ marginRight: cssPixels(style.marginRight),
+ marginTop: cssPixels(style.marginTop),
+ paddingBottom: cssPixels(style.paddingBottom),
+ paddingLeft: cssPixels(style.paddingLeft),
+ paddingRight: cssPixels(style.paddingRight),
+ paddingTop: cssPixels(style.paddingTop),
+ };
+ };
+ const readCountStyle = (node) => {
+ if (!node) return null;
+ const style = getComputedStyle(node);
+ return {
+ fontFamily: style.fontFamily,
+ fontSize: cssPixels(style.fontSize),
+ fontStyle: style.fontStyle,
+ fontWeight: style.fontWeight,
+ lineHeight: cssPixels(style.lineHeight),
+ };
+ };
+ const actionHosts = [...(actionBar?.children ?? [])]
+ .filter((candidate) => {
+ const button = candidate.matches("button") ? candidate : candidate.querySelector("button");
+ if (!button) return false;
+ const box = candidate.getBoundingClientRect();
+ const style = getComputedStyle(candidate);
+ return (
+ box.width > 0 &&
+ box.height > 0 &&
+ box.bottom > 0 &&
+ box.right > 0 &&
+ box.top < innerHeight &&
+ box.left < innerWidth &&
+ style.display !== "none" &&
+ style.visibility !== "hidden"
+ );
+ })
+ .map(readBox);
+
+ return {
+ actionHosts,
+ activeVideoId: element.getAttribute("data-ryd-video-id"),
+ geometry: {
+ like: {
+ button: readBox(likeButton),
+ count: readBox(likeCount),
+ countStyle: readCountStyle(likeCount),
+ host: readBox(likeHost),
+ hostStyle: readHostStyle(likeHost),
+ icon: readBox(likeIcon),
+ label: readBox(likeLabel),
+ svg: readBox(likeSvg),
+ },
+ next: { host: readBox(nextHost) },
+ synthetic: {
+ button: readBox(syntheticButton),
+ count: readBox(syntheticCount),
+ countStyle: readCountStyle(syntheticCount),
+ host: readBox(element),
+ hostStyle: readHostStyle(element),
+ icon: readBox(syntheticIcon),
+ label: readBox(syntheticLabel),
+ svg: readBox(syntheticSvg),
+ },
+ },
+ likeIsImmediatePreviousAction:
+ likeHost?.matches("like-button-view-model") === true && likeHost?.parentElement === actionBar,
+ nativeDislikes: actionBar?.querySelectorAll("dislike-button-view-model, #dislike-button").length ?? -1,
+ nextIsImmediateAction: nextHost !== null && nextHost.parentElement === actionBar,
+ syntheticControls: actionBar?.querySelectorAll("[data-ryd-synthetic-shorts-dislike]").length ?? -1,
+ videoMatches,
+ };
+ }, videoId);
+ const viewport = await this.readViewportSize();
+
+ assert.equal(measurement.activeVideoId, videoId, "The synthetic Shorts control targets the wrong video.");
+ assert.equal(measurement.videoMatches, true, "The synthetic Shorts control is outside the active reel.");
+ assert.equal(measurement.nativeDislikes, 0, "The modern Shorts action bar unexpectedly has a native dislike.");
+ assert.equal(measurement.syntheticControls, 1, "The active Shorts action bar has duplicate synthetic controls.");
+ assert.equal(
+ measurement.likeIsImmediatePreviousAction,
+ true,
+ "The synthetic Shorts control is not immediately after the native Like action.",
+ );
+ assert.equal(
+ measurement.nextIsImmediateAction,
+ true,
+ "The synthetic Shorts control has no immediately following action.",
+ );
+ assert.equal(await button.getAttribute("aria-label"), "Dislike this video");
+ assert.equal(await button.getAttribute("aria-disabled"), "false");
+ assert.ok(["true", "false"].includes(await button.getAttribute("aria-pressed")), "Invalid Shorts pressed state.");
+ assert.match(count, /\d/, "The synthetic Shorts dislike count is not numeric.");
+ assertSyntheticShortsGeometry(measurement.geometry);
+ assertBoxInsideViewport(measurement.geometry.like.host, viewport, "Native Like action host");
+ assertBoxInsideViewport(measurement.geometry.synthetic.host, viewport, "Synthetic Shorts action host");
+ assertBoxInsideViewport(measurement.geometry.next.host, viewport, "Action following the synthetic Shorts control");
+ assertShortsActionStackGeometry(measurement.actionHosts, viewport);
+
+ const clip = await this.captureCroppedScreenshot(screenshotPath, measurement.actionHosts, {
+ waitForVisualReadiness: () => this.waitForShortsVisualPaint([likeButton, button]),
+ });
+ return {
+ count,
+ geometry: measurement.geometry,
+ screenshotPath,
+ screenshotClip: clip,
+ viewport,
+ };
+ }
+
+ async captureNativeShortsVisual(videoId, screenshotPath) {
+ this.assertCurrentVideo(videoId);
+ const count = await this.waitForDislikeText();
+ assert.match(count, /\d/, "The native Shorts dislike control has no rendered numeric count.");
+ const [likeButton, dislikeButton] = await Promise.all([this.visibleLikeButton(), this.visibleDislikeButton()]);
+ await Promise.all([likeButton.scrollIntoViewIfNeeded(), dislikeButton.scrollIntoViewIfNeeded()]);
+ const [dislike, like, viewport] = await Promise.all([
+ dislikeButton.evaluate(readNativeShortsActionMeasurement, videoId),
+ likeButton.evaluate(readNativeShortsActionMeasurement, videoId),
+ this.readViewportSize(),
+ ]);
+ const geometry = { dislike, like };
+ assertNativeShortsPairGeometry(geometry);
+ assertBoxInsideViewport(like.host, viewport, "Shorts Like action");
+ assertBoxInsideViewport(dislike.host, viewport, "Shorts Dislike action");
+ assertShortsActionStackGeometry(like.actionHosts, viewport);
+ const clip = await this.captureCroppedScreenshot(screenshotPath, like.actionHosts, {
+ waitForVisualReadiness: () => this.waitForShortsVisualPaint([likeButton, dislikeButton]),
+ });
+ return {
+ count,
+ geometry,
+ screenshotClip: clip,
+ screenshotPath,
+ viewport,
+ };
+ }
+
+ async captureReactionStateVisual({ expectedState, isShort, runtime, screenshotPath, videoId }) {
+ this.assertCurrentVideo(videoId);
+ const pressedStates = await this.readReactionPressedStates();
+ assertReactionPressedStates(pressedStates, expectedState);
+ const count = await this.waitForDislikeText();
+ assert.match(count, /\d/, "The dislike control has no rendered numeric count.");
+
+ let visual;
+ if (!isShort) {
+ visual = await this.captureWatchRatioVisual(runtime, screenshotPath);
+ } else if (runtime === "userscript") {
+ visual = await this.captureSyntheticShortsVisual(videoId, screenshotPath);
+ } else {
+ visual = await this.captureNativeShortsVisual(videoId, screenshotPath);
+ }
+
+ return {
+ ...visual,
+ count,
+ expectedState,
+ isShort,
+ pressedStates,
+ runtime,
+ screenshotPath,
+ videoId,
+ };
+ }
+
+ async pausePlayback() {
+ const pauseResult = await this.page.locator("video").evaluateAll((videos) => {
+ const result = { pauseFailures: [], pausedVideos: 0 };
+ videos.forEach((video) => {
+ try {
+ video.pause();
+ result.pausedVideos += 1;
+ } catch (error) {
+ result.pauseFailures.push(String(error?.message ?? error));
+ }
+ });
+ return result;
+ });
+ this.reportProgress("playback.paused", {
+ explanation: "The live smoke pauses media intentionally while it validates the current page",
+ ...pauseResult,
+ url: this.page.url(),
+ });
+ return pauseResult;
+ }
+
+ async assertSignedIn(expectedChannel) {
+ this.reportProgress("signed-in-account.waiting", { expectedChannel });
+ const avatar = await firstVisible(this.page.locator("#avatar-btn"), "signed-in YouTube account avatar");
+ await avatar.click();
+ try {
+ await waitForValue(
+ () =>
+ this.page.locator("ytd-multi-page-menu-renderer a[href], tp-yt-iron-dropdown a[href]").evaluateAll(
+ (links, channel) =>
+ links.filter((link) => {
+ const rect = link.getBoundingClientRect();
+ if (rect.width <= 0 || rect.height <= 0) return false;
+ try {
+ return (
+ new URL(link.getAttribute("href"), location.origin).pathname.replace(/\/$/, "") === `/${channel}`
+ );
+ } catch {
+ return false;
+ }
+ }).length,
+ expectedChannel,
+ ),
+ (matches) => matches === 1,
+ `Timed out waiting for the signed-in account menu entry for ${expectedChannel}`,
+ );
+ } finally {
+ await this.page.keyboard.press("Escape");
+ }
+ this.reportProgress("signed-in-account.confirmed", { expectedChannel });
+ }
+
+ async assertRuntime(runtime, expectedVersion, expectedBuildId) {
+ assert.match(
+ expectedBuildId ?? "",
+ /^[a-f0-9]{32}$/,
+ "An exact 32-character live build ID is required before testing an installed runtime.",
+ );
+ const markers = await this.page.locator("html").evaluate((element) => ({
+ extensionBuild: element.getAttribute("data-ryd-extension-build"),
+ extension: element.getAttribute("data-ryd-extension-version"),
+ userscriptBuild: element.getAttribute("data-ryd-userscript-build"),
+ userscript: element.getAttribute("data-ryd-userscript-version"),
+ }));
+ const otherRuntime = runtime === "userscript" ? "extension" : "userscript";
+ assert.equal(markers[runtime], expectedVersion, `Expected ${runtime} version ${expectedVersion} to be active.`);
+ assert.equal(
+ markers[`${runtime}Build`],
+ expectedBuildId,
+ `Expected the installed ${runtime} live build to match the freshly generated artifact.`,
+ );
+ assert.equal(markers[otherRuntime], null, `Disable the ${otherRuntime} before running the ${runtime} smoke.`);
+ assert.equal(
+ markers[`${otherRuntime}Build`],
+ null,
+ `Disable the ${otherRuntime} before running the ${runtime} smoke.`,
+ );
+ this.reportProgress("runtime.confirmed", { expectedBuildId, expectedVersion, runtime });
+ }
+
+ async assertCurrentShortsControl(videoId, runtime) {
+ this.reportProgress("shorts-control.waiting", { runtime, videoId });
+ this.assertCurrentVideo(videoId);
+ assert.ok(this.page.url().includes("/shorts/"), "The current live page is not a Shorts page.");
+ const button = await this.visibleDislikeButton();
+ const [count, measurement] = await Promise.all([
+ button.evaluate(readDislikeControlText),
+ button.evaluate(
+ (element, settings) => {
+ const syntheticHost = element.closest(settings.syntheticSelector);
+ const reel = element.closest("ytd-reel-video-renderer, ytm-reel-video-renderer");
+ const expectedPath = `/shorts/${settings.videoId}`;
+ const rendererVideoId = reel?.getAttribute("video-id");
+ const videoMatches = rendererVideoId
+ ? rendererVideoId === settings.videoId
+ : [...(reel?.querySelectorAll('a[href*="/shorts/"]') ?? [])].some((link) => {
+ try {
+ return new URL(link.getAttribute("href"), location.origin).pathname === expectedPath;
+ } catch {
+ return false;
+ }
+ });
+ const visibleSyntheticControls = [...(reel?.querySelectorAll(settings.syntheticSelector) ?? [])].filter(
+ (control) => {
+ const rect = control.getBoundingClientRect();
+ return (
+ rect.width > 0 &&
+ rect.height > 0 &&
+ rect.bottom > 0 &&
+ rect.right > 0 &&
+ rect.top < innerHeight &&
+ rect.left < innerWidth
+ );
+ },
+ );
+ const actionBar = element.closest("reel-action-bar-view-model, ytd-reel-player-overlay-renderer");
+ const visibleActionButtons = [...(actionBar?.querySelectorAll("button") ?? [])].filter((control) => {
+ const rect = control.getBoundingClientRect();
+ const style = getComputedStyle(control);
+ 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"
+ );
+ });
+ return {
+ actionLabels: visibleActionButtons.map(
+ (control) => control.getAttribute("aria-label") ?? control.textContent?.trim() ?? "",
+ ),
+ pressed: element.getAttribute("aria-pressed"),
+ synthetic: syntheticHost !== null,
+ syntheticVideoId: syntheticHost?.getAttribute("data-ryd-video-id") ?? null,
+ videoMatches,
+ visibleSyntheticControls: visibleSyntheticControls.length,
+ visibleActionButtons: visibleActionButtons.length,
+ };
+ },
+ { syntheticSelector: SYNTHETIC_SHORTS_SELECTOR, videoId },
+ ),
+ ]);
+
+ assert.equal(measurement.videoMatches, true, `The rendered Shorts control is not owned by video ${videoId}.`);
+ assert.ok(
+ ["true", "false"].includes(measurement.pressed),
+ "The current Shorts control has no valid pressed state.",
+ );
+ assert.match(count, /\d/, "The current Shorts dislike control has no rendered count.");
+ assert.ok(
+ measurement.visibleActionButtons >= 5,
+ `The current Shorts reel rendered only ${measurement.visibleActionButtons} visible action controls: ${measurement.actionLabels.join(
+ ", ",
+ )}`,
+ );
+ if (runtime === "userscript") {
+ assert.equal(measurement.synthetic, true, "The userscript did not render its synthetic current Shorts control.");
+ assert.equal(measurement.syntheticVideoId, videoId, "The synthetic Shorts control targets a stale video ID.");
+ assert.equal(
+ measurement.visibleSyntheticControls,
+ 1,
+ "The current Shorts reel must contain exactly one visible userscript synthetic dislike control.",
+ );
+ }
+
+ const result = {
+ actionLabels: measurement.actionLabels,
+ count,
+ synthetic: measurement.synthetic,
+ videoId,
+ visibleActionButtons: measurement.visibleActionButtons,
+ };
+ this.reportProgress("shorts-control.confirmed", result);
+ return result;
+ }
+
+ async withVotesResponse(videoId, action) {
+ this.reportProgress("ryd-votes-response.waiting", { videoId });
+ const responsePromise = this.context.waitForEvent("response", {
+ predicate: (response) => {
+ const url = new URL(response.url());
+ if (url.origin !== API_ORIGIN || url.pathname !== "/votes" || url.searchParams.get("videoId") !== videoId) {
+ return false;
+ }
+ try {
+ return response.request().frame().page() === this.page;
+ } catch {
+ return false;
+ }
+ },
+ timeout: 30_000,
+ });
+ const [response, result] = await Promise.all([responsePromise, action()]);
+ assert.ok(response.ok(), `The production /votes request for ${videoId} failed with HTTP ${response.status()}.`);
+ const body = await response.json();
+ assert.equal(typeof body.dislikes, "number", `The production /votes response for ${videoId} has no dislike count.`);
+ this.reportProgress("ryd-votes-response.received", { status: response.status(), videoId });
+ return { body, result };
+ }
+
+ async navigateFromColdChannel(channelUrl, videoId, kind) {
+ const expectedChannelUrl = new URL(channelUrl);
+ this.reportProgress("cold-channel.load.start", { channelUrl, kind, videoId });
+ await this.page.goto(channelUrl, { waitUntil: "domcontentloaded" });
+ await this.page.reload({ waitUntil: "domcontentloaded" });
+ const actualChannelUrl = new URL(this.page.url());
+ assert.equal(actualChannelUrl.origin, expectedChannelUrl.origin, "The cold channel load left youtube.com.");
+ assert.equal(
+ actualChannelUrl.pathname.replace(/\/$/, ""),
+ expectedChannelUrl.pathname.replace(/\/$/, ""),
+ "The cold channel load did not remain on the configured channel path.",
+ );
+ this.reportProgress("cold-channel.load.complete", { channelUrl: this.page.url(), kind, videoId });
+
+ this.reportProgress("cold-channel.target-link.waiting", { kind, videoId });
+ const target = await firstVisibleExactVideoLink(this.page, videoId, kind);
+ this.reportProgress("cold-channel.target-link.found", { kind, videoId });
+ const documentMarker = await this.page.evaluate(() => {
+ const value = `${Date.now()}-${Math.random()}`;
+ globalThis.__rydLiveSpaDocumentMarker = value;
+ return value;
+ });
+ await this.withVotesResponse(videoId, async () => {
+ this.reportProgress("cold-channel.target-link.clicking", { kind, videoId });
+ await target.scrollIntoViewIfNeeded();
+ await Promise.all([this.page.waitForURL((url) => videoIdFromUrl(url.toString()) === videoId), target.click()]);
+ await this.waitForVideo(videoId);
+ });
+ assert.equal(
+ await this.page.evaluate(() => globalThis.__rydLiveSpaDocumentMarker),
+ documentMarker,
+ `The channel-to-${kind} transition replaced the document instead of using YouTube SPA navigation.`,
+ );
+ this.reportProgress("cold-channel.navigation.confirmed", { kind, url: this.page.url(), videoId });
+ }
+
+ async navigateFromColdChannelToShort(channelUrl, videoId) {
+ await this.navigateFromColdChannel(channelUrl, videoId, "short");
+ }
+
+ async navigateFromColdChannelToWatch(channelUrl, videoId) {
+ await this.navigateFromColdChannel(channelUrl, videoId, "watch");
+ }
+
+ async navigateToNextShort(previousVideoId) {
+ this.assertCurrentVideo(previousVideoId);
+ this.reportProgress("shorts-next-control.waiting", { previousVideoId });
+ const nextButton = await firstVisible(this.page.locator(SHORTS_NEXT_BUTTON_SELECTOR), "Shorts Next video button", {
+ expectedShortVideoId: previousVideoId,
+ requireViewport: true,
+ });
+ this.reportProgress("shorts-next-control.found", { previousVideoId });
+ const documentMarker = await this.page.evaluate(() => {
+ const value = `${Date.now()}-${Math.random()}`;
+ globalThis.__rydLiveSpaDocumentMarker = value;
+ return value;
+ });
+ const votesResponses = [];
+ const onResponse = (response) => {
+ const url = new URL(response.url());
+ if (url.origin !== API_ORIGIN || url.pathname !== "/votes") return;
+ try {
+ if (response.request().frame().page() !== this.page) return;
+ } catch {
+ return;
+ }
+ votesResponses.push(response);
+ };
+
+ this.context.on("response", onResponse);
+ let nextVideoId;
+ try {
+ const isNextShortUrl = (value) => {
+ const url = value instanceof URL ? value : new URL(value);
+ const candidate = videoIdFromUrl(url.toString());
+ return (
+ url.pathname.startsWith("/shorts/") && VIDEO_ID_PATTERN.test(candidate || "") && candidate !== previousVideoId
+ );
+ };
+ await clickWithSingleNavigationRetry({
+ click: async (attempt, timeout) => {
+ this.reportProgress("shorts-next-control.clicking", { attempt, previousVideoId });
+ await nextButton.scrollIntoViewIfNeeded({ timeout });
+ await nextButton.click({ timeout });
+ },
+ hasNavigated: () => isNextShortUrl(this.page.url()),
+ reportProgress: this.reportProgress,
+ retryDetails: { previousVideoId },
+ waitForNavigation: (timeout) => this.page.waitForURL(isNextShortUrl, { timeout }),
+ });
+ nextVideoId = videoIdFromUrl(this.page.url());
+ assert.match(nextVideoId, VIDEO_ID_PATTERN, "The Shorts Next video navigation produced an invalid video ID.");
+ assert.notEqual(nextVideoId, previousVideoId, "The Shorts Next video control did not advance to a new video.");
+ await this.waitForVideoUrl(nextVideoId);
+
+ this.reportProgress("ryd-votes-response.waiting", { videoId: nextVideoId });
+ const response = await waitForValue(
+ () =>
+ Promise.resolve(
+ votesResponses.find((candidate) => new URL(candidate.url()).searchParams.get("videoId") === nextVideoId),
+ ),
+ Boolean,
+ `Timed out waiting for the production /votes response for next Short ${nextVideoId}`,
+ 30_000,
+ );
+ assert.ok(
+ response.ok(),
+ `The production /votes request for ${nextVideoId} failed with HTTP ${response.status()}.`,
+ );
+ const body = await response.json();
+ assert.equal(typeof body.dislikes, "number", `The production /votes response for ${nextVideoId} has no count.`);
+ this.reportProgress("ryd-votes-response.received", { status: response.status(), videoId: nextVideoId });
+ } finally {
+ this.context.off("response", onResponse);
+ }
+
+ assert.equal(
+ await this.page.evaluate(() => globalThis.__rydLiveSpaDocumentMarker),
+ documentMarker,
+ "The Shorts Next video transition replaced the document instead of using YouTube SPA navigation.",
+ );
+ this.reportProgress("shorts-next-navigation.confirmed", {
+ previousVideoId,
+ url: this.page.url(),
+ videoId: nextVideoId,
+ });
+ return nextVideoId;
+ }
+
+ async waitForVideoUrl(videoId) {
+ this.reportProgress("video-url.waiting", { videoId });
+ await waitForValue(
+ () => Promise.resolve(videoIdFromUrl(this.page.url())),
+ (currentVideoId) => currentVideoId === videoId,
+ `Timed out waiting for YouTube video ${videoId}`,
+ );
+ this.reportProgress("video-url.confirmed", { url: this.page.url(), videoId });
+ }
+
+ async waitForVideo(videoId) {
+ await this.waitForVideoUrl(videoId);
+ await this.pausePlayback();
+ }
+
+ assertCurrentVideo(videoId) {
+ assert.equal(
+ videoIdFromUrl(this.page.url()),
+ videoId,
+ `The live tab is no longer on the allowlisted video ${videoId}.`,
+ );
+ }
+
+ async openPlaylist(url, videoId) {
+ return this.withVotesResponse(videoId, async () => {
+ await this.page.goto(url, { waitUntil: "domcontentloaded" });
+ await this.waitForVideo(videoId);
+ });
+ }
+
+ async openWatch(videoId) {
+ return this.withVotesResponse(videoId, async () => {
+ await this.page.goto(`https://www.youtube.com/watch?v=${videoId}`, { waitUntil: "domcontentloaded" });
+ await this.waitForVideo(videoId);
+ });
+ }
+
+ async reload(videoId) {
+ return this.withVotesResponse(videoId, async () => {
+ await this.page.reload({ waitUntil: "domcontentloaded" });
+ await this.waitForVideo(videoId);
+ });
+ }
+
+ async navigateWithinPlaylist(videoId) {
+ const links = this.page.locator(`a[href*="watch?v=${videoId}"]`);
+ const link = await firstVisible(links, `playlist link for ${videoId}`);
+ const documentMarker = await this.page.evaluate(() => {
+ const value = `${Date.now()}-${Math.random()}`;
+ globalThis.__rydLiveSpaDocumentMarker = value;
+ return value;
+ });
+ await this.withVotesResponse(videoId, async () => {
+ await link.scrollIntoViewIfNeeded();
+ await Promise.all([this.page.waitForURL((url) => videoIdFromUrl(url.toString()) === videoId), link.click()]);
+ await this.waitForVideo(videoId);
+ });
+ assert.equal(
+ await this.page.evaluate(() => globalThis.__rydLiveSpaDocumentMarker),
+ documentMarker,
+ "The playlist transition replaced the document instead of using YouTube SPA navigation.",
+ );
+ }
+
+ async navigateToRelatedWatch(excludedVideoIds = []) {
+ const currentVideoId = videoIdFromUrl(this.page.url());
+ assert.match(currentVideoId, VIDEO_ID_PATTERN, "The sidebar stress scenario must start on a watch video.");
+ const exclusions = [...new Set(excludedVideoIds)];
+ for (const excludedVideoId of exclusions) {
+ assert.match(excludedVideoId, VIDEO_ID_PATTERN, "Sidebar navigation exclusions must be valid video IDs.");
+ }
+
+ this.reportProgress("related-watch-link.waiting", { currentVideoId, excludedVideoIds: exclusions });
+ const { link, videoId } = await firstVisibleRelatedWatchLink(this.page, currentVideoId, [
+ ...new Set([...exclusions, currentVideoId]),
+ ]);
+ this.reportProgress("related-watch-link.found", { currentVideoId, videoId });
+ const documentMarker = await this.page.evaluate(() => {
+ const value = `${Date.now()}-${Math.random()}`;
+ globalThis.__rydLiveSpaDocumentMarker = value;
+ return value;
+ });
+
+ const { body } = await this.withVotesResponse(videoId, async () => {
+ await link.scrollIntoViewIfNeeded();
+ const targetVideoId = await link.evaluate(relatedWatchVideoId, {
+ currentVideoId,
+ excludedVideoIds: [...new Set([...exclusions, currentVideoId])],
+ origin: new URL(this.page.url()).origin,
+ });
+ assert.equal(targetVideoId, videoId, "The selected #related link changed before it could be clicked.");
+ this.reportProgress("related-watch-link.clicking", { currentVideoId, videoId });
+ await Promise.all([this.page.waitForURL((url) => videoIdFromUrl(url.toString()) === videoId), link.click()]);
+ await this.waitForVideo(videoId);
+ });
+
+ assert.equal(
+ await this.page.evaluate(() => globalThis.__rydLiveSpaDocumentMarker),
+ documentMarker,
+ "The #related watch transition replaced the document instead of using YouTube SPA navigation.",
+ );
+ this.reportProgress("related-watch-navigation.confirmed", {
+ apiDislikes: body.dislikes,
+ currentVideoId,
+ url: this.page.url(),
+ videoId,
+ });
+ return { body, videoId };
+ }
+
+ async openShort(videoId) {
+ return this.withVotesResponse(videoId, async () => {
+ await this.page.goto(`https://www.youtube.com/shorts/${videoId}`, { waitUntil: "domcontentloaded" });
+ await this.waitForVideo(videoId);
+ });
+ }
+
+ async visibleDislikeButton() {
+ return this.visibleActionButton("dislike");
+ }
+
+ async visibleLikeButton() {
+ return this.visibleActionButton("like");
+ }
+
+ async visibleActionButton(action) {
+ const selectors = ACTION_BUTTON_SELECTORS[action];
+ if (!selectors) throw new Error(`Unsupported YouTube reaction action: ${action}`);
+
+ const isShort = this.page.url().includes("/shorts/");
+ const videoId = videoIdFromUrl(this.page.url());
+ const locator = isShort
+ ? this.page.locator(selectors)
+ : this.page
+ .locator(`ytd-watch-flexy[video-id="${videoId}"], ytd-watch-grid[video-id="${videoId}"]`)
+ .locator(selectors);
+ return firstVisible(locator, `YouTube ${action} button`, {
+ expectedShortVideoId: isShort ? videoIdFromUrl(this.page.url()) : null,
+ requireActiveShort: isShort,
+ requireViewport: isShort,
+ });
+ }
+
+ async waitForDislikeText({ differentFrom = null } = {}) {
+ return waitForValue(
+ async () => (await this.visibleDislikeButton()).evaluate(readDislikeControlText),
+ (text) => /\d/.test(text) && (differentFrom === null || text !== differentFrom),
+ "The enabled RYD runtime did not render a dislike count",
+ 30_000,
+ );
+ }
+
+ async readVoteState() {
+ return (await this.visibleDislikeButton()).getAttribute("aria-pressed");
+ }
+
+ async readLikeState() {
+ return (await this.visibleLikeButton()).getAttribute("aria-pressed");
+ }
+
+ async readReactionPressedStates() {
+ const [likeState, dislikeState] = await Promise.all([this.readLikeState(), this.readVoteState()]);
+ assert.ok(["true", "false"].includes(likeState), `Unexpected YouTube like state: ${likeState}`);
+ assert.ok(["true", "false"].includes(dislikeState), `Unexpected YouTube dislike state: ${dislikeState}`);
+ assert.ok(!(likeState === "true" && dislikeState === "true"), "YouTube reported Like and Dislike as selected.");
+ return { dislikeState, likeState };
+ }
+
+ async readReactionState() {
+ const { dislikeState, likeState } = await this.readReactionPressedStates();
+ if (likeState === "true") return "liked";
+ if (dislikeState === "true") return "disliked";
+ return "neutral";
+ }
+
+ async waitForVoteState(expected) {
+ return waitForValue(
+ () => this.readVoteState(),
+ (state) => state === String(expected),
+ `Timed out waiting for dislike aria-pressed=${expected}`,
+ );
+ }
+
+ async waitForReactionState(expected) {
+ return waitForValue(
+ () => this.readReactionState(),
+ (state) => state === expected,
+ `Timed out waiting for YouTube reaction state ${expected}`,
+ );
+ }
+
+ async clickDislike(videoId) {
+ return this.clickAction(videoId, "dislike");
+ }
+
+ async clickLike(videoId) {
+ return this.clickAction(videoId, "like");
+ }
+
+ async clickAction(videoId, action) {
+ this.assertCurrentVideo(videoId);
+ const selectors = ACTION_BUTTON_SELECTORS[action];
+ if (!selectors) throw new Error(`Unsupported YouTube reaction action: ${action}`);
+
+ const isShort = this.page.url().includes("/shorts/");
+ const locator = isShort
+ ? this.page.locator(selectors)
+ : this.page
+ .locator(`ytd-watch-flexy[video-id="${videoId}"], ytd-watch-grid[video-id="${videoId}"]`)
+ .locator(selectors);
+ const button = await firstVisible(locator, `${action} button for allowlisted video ${videoId}`, {
+ expectedShortVideoId: isShort ? videoId : null,
+ requireActiveShort: isShort,
+ requireViewport: isShort,
+ });
+ this.assertCurrentVideo(videoId);
+ await button.scrollIntoViewIfNeeded();
+ this.assertCurrentVideo(videoId);
+ await button.click();
+ }
+}
+
+module.exports = {
+ LiveYoutubeDriver,
+ VoteTrafficRecorder,
+ WATCH_RATIO_SOAK_DURATION_MS,
+ assertReactionPressedStates,
+ assertLogicalVoteHandshake,
+ assertNativeShortsPairGeometry,
+ assertShortsActionStackGeometry,
+ assertSyntheticShortsGeometry,
+ assertWatchRatioViewportAlignment,
+ clickWithSingleNavigationRetry,
+ croppedScreenshotClip,
+ firstVisibleRelatedWatchLink,
+ isShortCandidateEligible,
+ isShortsIconVisualReady,
+ pointerPositionAwayFromBoxes,
+ readDislikeControlText,
+ relatedWatchVideoId,
+ videoIdFromUrl,
+};
diff --git a/Extensions/UserScript/e2e/live/live-youtube.live.e2e.js b/Extensions/UserScript/e2e/live/live-youtube.live.e2e.js
new file mode 100644
index 0000000..cafaa62
--- /dev/null
+++ b/Extensions/UserScript/e2e/live/live-youtube.live.e2e.js
@@ -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.");
+ }
+ },
+ });
+ });
+});
diff --git a/Extensions/UserScript/e2e/navigation-matrix.js b/Extensions/UserScript/e2e/navigation-matrix.js
new file mode 100644
index 0000000..37a7f47
--- /dev/null
+++ b/Extensions/UserScript/e2e/navigation-matrix.js
@@ -0,0 +1,1417 @@
+const { expect } = require("@playwright/test");
+const { assertInvariantContinuously, waitForStableInvariant } = require("../../e2e/continuous-invariants");
+const { VIDEO_A, VIDEO_B } = require("./harness");
+
+const WATCH_SIDEBAR_MATRIX = [
+ {
+ coverage: {
+ destination: "shorts",
+ dom: ["preloaded-sibling", "active-reel-switch"],
+ origin: "shorts",
+ timing: ["settled"],
+ trigger: "next-control",
+ width: "wide-desktop",
+ },
+ destination: { counts: { dislikes: 300, likes: 100 }, kind: "shorts", videoId: VIDEO_B },
+ id: "short-next-short-active-reel",
+ origin: { counts: { dislikes: 100, likes: 300 }, kind: "shorts", state: "neutral", videoId: VIDEO_A },
+ viewport: { height: 720, width: 1280 },
+ },
+ {
+ coverage: {
+ destination: "watch",
+ dom: ["retain-hidden-outgoing", "replace-controls", "prune-current-bar"],
+ origin: "watch",
+ timing: ["finish-before-hydration", "destination-count-gated"],
+ trigger: "sidebar-link",
+ width: "wide-desktop",
+ },
+ id: "watch-sidebar-watch-retain-prune",
+ destination: {
+ counts: { dislikes: 300, likes: 100 },
+ kind: "watch",
+ videoId: VIDEO_B,
+ },
+ origin: {
+ counts: { dislikes: 100, likes: 300 },
+ kind: "watch",
+ state: "neutral",
+ videoId: VIDEO_A,
+ },
+ timing: {
+ destinationCount: "gated",
+ navigateFinish: "before-destination-controls",
+ },
+ transition: {
+ destinationControls: "replace",
+ outgoing: "retain-hidden-top-row",
+ postInit: "prune-current-rate-bar",
+ trigger: "persistent-sidebar-link",
+ },
+ viewport: { height: 720, width: 1280 },
+ },
+ {
+ coverage: {
+ destination: "watch",
+ dom: ["same-current-root", "reuse-exact-control-nodes", "no-useful-control-mutation"],
+ origin: "watch",
+ timing: ["navigate-finish", "destination-count-gated"],
+ trigger: "sidebar-link",
+ width: "wide-desktop",
+ },
+ destination: { counts: { dislikes: 300, likes: 100 }, kind: "watch", videoId: VIDEO_B },
+ id: "watch-sidebar-watch-same-node-route-complete",
+ origin: { counts: { dislikes: 100, likes: 300 }, kind: "watch", state: "neutral", videoId: VIDEO_A },
+ transition: { controls: "reuse-exact-nodes", nativeControlMutations: "none" },
+ viewport: { height: 720, width: 1280 },
+ },
+ {
+ coverage: {
+ destination: "watch",
+ dom: [
+ "connected-hidden-rate-bar",
+ "connected-collapsed-rate-bar",
+ "malformed-rate-bar",
+ "stripped-rate-bar-wrapper-class",
+ ],
+ origin: "watch",
+ timing: ["same-video", "no-navigation-event"],
+ trigger: "dom-corruption",
+ width: "wide-desktop",
+ },
+ destination: { counts: { dislikes: 100, likes: 300 }, kind: "watch", videoId: VIDEO_A },
+ id: "watch-current-rate-bar-connected-corruption",
+ origin: { counts: { dislikes: 100, likes: 300 }, kind: "watch", state: "neutral", videoId: VIDEO_A },
+ transition: {
+ corruptions: ["hidden-wrapper", "collapsed-wrapper", "missing-fill", "stripped-wrapper-class"],
+ },
+ viewport: { height: 720, width: 1280 },
+ },
+];
+
+const NAVIGATION_MATRIX = [
+ ...WATCH_SIDEBAR_MATRIX,
+ {
+ coverage: {
+ destination: "watch",
+ dom: ["same-current-root", "hidden-outgoing-first", "rendered-destination-second"],
+ origin: "watch",
+ timing: ["settled"],
+ trigger: "sidebar-link",
+ width: "wide-desktop",
+ },
+ destination: { counts: { dislikes: 300, likes: 100 }, kind: "watch", videoId: VIDEO_B },
+ id: "watch-sidebar-watch-same-root-hidden-first",
+ origin: { counts: { dislikes: 100, likes: 300 }, kind: "watch", state: "neutral", videoId: VIDEO_A },
+ transition: { outgoingPresentation: "hidden" },
+ viewport: { height: 720, width: 1280 },
+ },
+ {
+ coverage: {
+ destination: "watch",
+ dom: ["same-current-root", "positive-size-offscreen-outgoing-first", "rendered-destination-second"],
+ origin: "watch",
+ timing: ["settled"],
+ trigger: "sidebar-link",
+ width: "wide-desktop",
+ },
+ destination: { counts: { dislikes: 300, likes: 100 }, kind: "watch", videoId: VIDEO_B },
+ id: "watch-sidebar-watch-same-root-offscreen-first",
+ origin: { counts: { dislikes: 100, likes: 300 }, kind: "watch", state: "neutral", videoId: VIDEO_A },
+ transition: { outgoingPresentation: "offscreen" },
+ viewport: { height: 720, width: 1280 },
+ },
+ {
+ coverage: {
+ destination: "watch",
+ dom: [
+ "same-current-root",
+ "hidden-outgoing-first",
+ "rendered-destination-second",
+ "legacy-segmented-duplicate-ids",
+ ],
+ origin: "watch",
+ timing: ["settled"],
+ trigger: "sidebar-link",
+ width: "wide-desktop",
+ },
+ destination: { counts: { dislikes: 300, likes: 100 }, kind: "watch", videoId: VIDEO_B },
+ id: "watch-sidebar-watch-legacy-segmented-duplicate-ids",
+ origin: { counts: { dislikes: 100, likes: 300 }, kind: "watch", state: "neutral", videoId: VIDEO_A },
+ transition: { controlMarkup: "legacy-segmented", outgoingPresentation: "hidden" },
+ viewport: { height: 720, width: 1280 },
+ },
+ {
+ coverage: {
+ destination: "watch",
+ dom: ["replace-controls"],
+ origin: "watch",
+ timing: ["settled"],
+ trigger: "history-back-forward",
+ width: "wide-desktop",
+ },
+ destination: { counts: { dislikes: 300, likes: 100 }, kind: "watch", videoId: VIDEO_B },
+ id: "watch-history-back-forward-replace",
+ origin: { counts: { dislikes: 100, likes: 300 }, kind: "watch", state: "neutral", videoId: VIDEO_A },
+ viewport: { height: 720, width: 1280 },
+ },
+ {
+ coverage: {
+ destination: "watch",
+ dom: ["replace-page-and-controls"],
+ origin: "watch",
+ timing: ["no-navigate-finish"],
+ trigger: "autoplay-ended",
+ width: "wide-desktop",
+ },
+ destination: { counts: { dislikes: 300, likes: 100 }, kind: "watch", videoId: VIDEO_B },
+ id: "watch-autoplay-watch-replace-no-finish",
+ origin: { counts: { dislikes: 100, likes: 300 }, kind: "watch", state: "neutral", videoId: VIDEO_A },
+ viewport: { height: 720, width: 1280 },
+ },
+ {
+ coverage: {
+ destination: "shorts",
+ dom: ["replace-page-and-controls", "delayed-hydration"],
+ origin: "watch",
+ timing: ["finish-before-hydration"],
+ trigger: "direct-link",
+ width: "wide-desktop",
+ },
+ destination: { counts: { dislikes: 300, likes: 100 }, kind: "shorts", videoId: VIDEO_B },
+ id: "watch-direct-short-delayed",
+ origin: { counts: { dislikes: 100, likes: 300 }, kind: "watch", state: "neutral", videoId: VIDEO_A },
+ viewport: { height: 720, width: 1280 },
+ },
+ {
+ coverage: {
+ destination: "watch",
+ dom: ["replace-page-and-controls", "delayed-hydration"],
+ origin: "shorts",
+ timing: ["finish-before-hydration"],
+ trigger: "direct-link",
+ width: "wide-desktop",
+ },
+ destination: { counts: { dislikes: 300, likes: 100 }, kind: "watch", videoId: VIDEO_B },
+ id: "short-direct-watch-delayed",
+ origin: { counts: { dislikes: 100, likes: 300 }, kind: "shorts", state: "neutral", videoId: VIDEO_A },
+ viewport: { height: 720, width: 1280 },
+ },
+ {
+ coverage: {
+ destination: "watch",
+ dom: ["replace-current-action-container"],
+ origin: "watch",
+ timing: ["same-video", "no-navigation-event"],
+ trigger: "dom-replacement",
+ width: "wide-desktop",
+ },
+ destination: { counts: { dislikes: 100, likes: 300 }, kind: "watch", videoId: VIDEO_A },
+ id: "watch-current-action-container-replace",
+ origin: { counts: { dislikes: 100, likes: 300 }, kind: "watch", state: "neutral", videoId: VIDEO_A },
+ viewport: { height: 720, width: 1280 },
+ },
+];
+
+const USERSCRIPT_MATRIX_RUNTIME = {
+ name: "userscript",
+ selectors: {
+ bar: "#return-youtube-dislike-bar",
+ container: "#return-youtube-dislike-bar-container",
+ tooltip: "#ryd-dislike-tooltip",
+ wrapper: ".ryd-tooltip",
+ shortsDislike: "[data-ryd-synthetic-shorts-dislike]",
+ shortsVideoAttribute: "data-ryd-video-id",
+ },
+ tooltipText({ dislikes, likes }) {
+ return `${likes} / ${dislikes}`;
+ },
+};
+
+async function installSidebarRetainPruneFixture(context, scenario) {
+ await context.addInitScript((matrixScenario) => {
+ if (!location.hostname.endsWith("youtube.com")) return;
+
+ addEventListener(
+ "DOMContentLoaded",
+ () => {
+ if (new URL(location.href).searchParams.get("rydNavigationFixture") !== "1") return;
+
+ const fixturePage = document.getElementById("fixture-page");
+ if (!fixturePage || !globalThis.__navigationFixture) {
+ throw new Error("The navigation matrix requires the navigation-page fixture.");
+ }
+
+ const retainedTrees = document.createElement("div");
+ retainedTrees.hidden = true;
+ retainedTrees.id = "fixture-matrix-retained-trees";
+ // Keep the retained outgoing tree before the live page in document
+ // order. This reproduces YouTube variants where a first-match query
+ // resolves stale connected controls before the current rendered tree.
+ fixturePage.before(retainedTrees);
+
+ const sidebar = document.createElement("aside");
+ sidebar.id = "fixture-matrix-sidebar";
+ sidebar.setAttribute("aria-label", "Fixture sidebar");
+ const destinationLink = document.createElement("a");
+ destinationLink.id = "fixture-matrix-sidebar-watch";
+ destinationLink.href = `/watch?v=${matrixScenario.destination.videoId}&rydNavigationFixture=1`;
+ destinationLink.textContent = "Open sidebar video";
+ sidebar.appendChild(destinationLink);
+ fixturePage.before(sidebar);
+
+ const transition = {
+ destinationTopRow: null,
+ documentIdentity: `matrix-${Date.now()}-${Math.random()}`,
+ phase: "origin",
+ timeline: [],
+ };
+ const record = (phase) => {
+ transition.phase = phase;
+ transition.timeline.push(phase);
+ };
+
+ destinationLink.addEventListener("click", (event) => {
+ event.preventDefault();
+ if (transition.phase !== "origin") throw new Error(`Cannot navigate from matrix phase ${transition.phase}.`);
+
+ const watchPage = fixturePage.querySelector('[data-fixture-page-kind="watch"]');
+ const outgoingTopRow = watchPage?.querySelector("#top-row");
+ const watchFlexy = watchPage?.querySelector("ytd-watch-flexy");
+ if (!watchPage || !outgoingTopRow || !watchFlexy) {
+ throw new Error("The origin watch fixture is incomplete.");
+ }
+
+ const destinationTopRow = outgoingTopRow.cloneNode(true);
+ destinationTopRow.removeAttribute("style");
+ destinationTopRow.querySelectorAll(".ryd-tooltip").forEach((element) => element.remove());
+ const destinationControls = destinationTopRow.querySelector("[data-fixture-control-video-id]");
+ destinationControls.setAttribute("data-fixture-control-video-id", matrixScenario.destination.videoId);
+ for (const role of ["like", "dislike"]) {
+ const control = destinationControls.querySelector(`[data-ryd-role="${role}"]`);
+ control.classList.remove("style-default-active");
+ control.classList.add("style-text");
+ control.querySelector("button")?.setAttribute("aria-pressed", "false");
+ }
+ const destinationDislikeText = destinationControls.querySelector('[data-ryd-role="dislike"] #text');
+ destinationDislikeText.textContent = "";
+ transition.destinationTopRow = destinationTopRow;
+
+ document.dispatchEvent(new Event("yt-navigate-start", { bubbles: true }));
+ record("navigate-start");
+
+ retainedTrees.appendChild(outgoingTopRow);
+ history.pushState({}, "", `/watch?v=${matrixScenario.destination.videoId}&rydNavigationFixture=1`);
+ watchPage.setAttribute("data-fixture-video-id", matrixScenario.destination.videoId);
+ watchFlexy.setAttribute("video-id", matrixScenario.destination.videoId);
+ record("route-and-shell");
+
+ document.dispatchEvent(new Event("yt-navigate-finish", { bubbles: true }));
+ record("navigate-finish");
+ });
+
+ globalThis.__navigationMatrixFixture = {
+ detachOutgoing() {
+ retainedTrees.replaceChildren();
+ record("detach-outgoing");
+ },
+ hydrateDestination() {
+ if (transition.phase !== "navigate-finish" || !transition.destinationTopRow) {
+ throw new Error(`Cannot hydrate the destination from matrix phase ${transition.phase}.`);
+ }
+ const watchPage = fixturePage.querySelector('[data-fixture-page-kind="watch"]');
+ const firstNavigationLink = watchPage?.querySelector("a[data-fixture-page-kind]");
+ watchPage.insertBefore(transition.destinationTopRow, firstNavigationLink ?? null);
+ transition.destinationTopRow = null;
+ record("hydrate-destination-controls");
+ },
+ snapshot() {
+ return {
+ currentControls: fixturePage.querySelectorAll("[data-fixture-control-video-id]").length,
+ documentIdentity: transition.documentIdentity,
+ phase: transition.phase,
+ retainedControls: retainedTrees.querySelectorAll("[data-fixture-control-video-id]").length,
+ retainedTreesConnected: retainedTrees.isConnected,
+ retainedTreesHidden: retainedTrees.hidden,
+ sidebarConnected: sidebar.isConnected,
+ timeline: [...transition.timeline],
+ };
+ },
+ };
+ },
+ { once: true },
+ );
+ }, scenario);
+}
+
+async function installSameRootHiddenFirstFixture(context, scenario) {
+ await context.addInitScript((matrixScenario) => {
+ if (!location.hostname.endsWith("youtube.com")) return;
+
+ addEventListener(
+ "DOMContentLoaded",
+ () => {
+ if (new URL(location.href).searchParams.get("rydNavigationFixture") !== "1") return;
+
+ const fixturePage = document.getElementById("fixture-page");
+ const watchPage = fixturePage?.querySelector('[data-fixture-page-kind="watch"]');
+ const watchFlexy = watchPage?.querySelector("ytd-watch-flexy");
+ const originTopRow = watchPage?.querySelector("#top-row");
+ if (!fixturePage || !watchPage || !watchFlexy || !originTopRow) {
+ throw new Error("The same-root matrix requires a complete watch fixture.");
+ }
+ if (matrixScenario.transition?.controlMarkup === "legacy-segmented") {
+ const modernSegmented = originTopRow.querySelector("segmented-like-dislike-button-view-model");
+ if (!modernSegmented) {
+ throw new Error("The legacy segmented matrix could not find its source controls.");
+ }
+ const legacySegmented = document.createElement("ytd-segmented-like-dislike-button-renderer");
+ for (const attribute of modernSegmented.attributes) {
+ legacySegmented.setAttribute(attribute.name, attribute.value);
+ }
+ legacySegmented.style.cssText = "display:flex;gap:8px;min-height:48px;width:320px";
+ while (modernSegmented.firstChild) {
+ legacySegmented.appendChild(modernSegmented.firstChild);
+ }
+ const legacyLike = legacySegmented.querySelector('[data-ryd-role="like"]');
+ const legacyDislike = legacySegmented.querySelector('[data-ryd-role="dislike"]');
+ legacyLike.id = "segmented-like-button";
+ legacyDislike.id = "segmented-dislike-button";
+ for (const button of legacySegmented.querySelectorAll("button")) {
+ button.style.cssText = "min-height:36px;min-width:96px";
+ }
+ modernSegmented.replaceWith(legacySegmented);
+ }
+ watchFlexy.appendChild(originTopRow);
+
+ const sidebar = document.createElement("aside");
+ sidebar.id = "fixture-matrix-same-root-sidebar";
+ sidebar.setAttribute("aria-label", "Fixture same-root sidebar");
+ const destinationLink = document.createElement("a");
+ destinationLink.id = "fixture-matrix-same-root-watch";
+ destinationLink.href = `/watch?v=${matrixScenario.destination.videoId}&rydNavigationFixture=1`;
+ destinationLink.textContent = "Open same-root sidebar video";
+ sidebar.appendChild(destinationLink);
+ fixturePage.before(sidebar);
+
+ const documentIdentity = `matrix-${Date.now()}-${Math.random()}`;
+ destinationLink.addEventListener("click", (event) => {
+ event.preventDefault();
+ const outgoingTopRow = watchFlexy.querySelector("#top-row");
+ if (!outgoingTopRow || outgoingTopRow.hasAttribute("data-fixture-matrix-hidden-outgoing")) {
+ throw new Error("The same-root matrix origin is unavailable.");
+ }
+
+ const destinationTopRow = outgoingTopRow.cloneNode(true);
+ destinationTopRow.removeAttribute("style");
+ destinationTopRow.querySelectorAll(".ryd-tooltip").forEach((element) => element.remove());
+ const destinationControls = destinationTopRow.querySelector("[data-fixture-control-video-id]");
+ destinationControls.setAttribute("data-fixture-control-video-id", matrixScenario.destination.videoId);
+ for (const role of ["like", "dislike"]) {
+ const control = destinationControls.querySelector(`[data-ryd-role="${role}"]`);
+ control.classList.remove("style-default-active");
+ control.classList.add("style-text");
+ control.querySelector("button")?.setAttribute("aria-pressed", "false");
+ }
+ destinationControls.querySelector('[data-ryd-role="dislike"] #text').textContent = "";
+ destinationTopRow.setAttribute("data-fixture-matrix-live-destination", "true");
+
+ document.dispatchEvent(new Event("yt-navigate-start", { bubbles: true }));
+ if (matrixScenario.transition?.outgoingPresentation === "offscreen") {
+ outgoingTopRow.style.setProperty("left", "-10000px", "important");
+ outgoingTopRow.style.setProperty("position", "fixed", "important");
+ outgoingTopRow.style.setProperty("top", "0", "important");
+ } else {
+ outgoingTopRow.hidden = true;
+ }
+ outgoingTopRow.setAttribute("data-fixture-matrix-hidden-outgoing", "true");
+ history.pushState({}, "", `/watch?v=${matrixScenario.destination.videoId}&rydNavigationFixture=1`);
+ watchPage.setAttribute("data-fixture-video-id", matrixScenario.destination.videoId);
+ watchFlexy.setAttribute("video-id", matrixScenario.destination.videoId);
+ watchFlexy.appendChild(destinationTopRow);
+ document.dispatchEvent(new Event("yt-navigate-finish", { bubbles: true }));
+ });
+
+ globalThis.__navigationMatrixSameRootFixture = {
+ detachOutgoing() {
+ watchFlexy.querySelector('[data-fixture-matrix-hidden-outgoing="true"]')?.remove();
+ },
+ snapshot() {
+ const hiddenOutgoing = watchFlexy.querySelector('[data-fixture-matrix-hidden-outgoing="true"]');
+ const liveDestination = watchFlexy.querySelector('[data-fixture-matrix-live-destination="true"]');
+ const hiddenBox = hiddenOutgoing?.getBoundingClientRect();
+ return {
+ documentIdentity,
+ hiddenFirst: Boolean(
+ hiddenOutgoing &&
+ liveDestination &&
+ hiddenOutgoing.compareDocumentPosition(liveDestination) & Node.DOCUMENT_POSITION_FOLLOWING,
+ ),
+ hiddenOutgoingConnected: Boolean(hiddenOutgoing?.isConnected),
+ hiddenOutgoingHeight: hiddenBox?.height ?? null,
+ hiddenOutgoingWidth: hiddenBox?.width ?? null,
+ outgoingIntersectsViewport: Boolean(
+ hiddenBox &&
+ hiddenBox.width > 0 &&
+ hiddenBox.height > 0 &&
+ hiddenBox.bottom > 0 &&
+ hiddenBox.right > 0 &&
+ hiddenBox.top < innerHeight &&
+ hiddenBox.left < innerWidth,
+ ),
+ outgoingPresentation: matrixScenario.transition?.outgoingPresentation ?? "hidden",
+ liveDestinationConnected: Boolean(liveDestination?.isConnected),
+ rootVideoId: watchFlexy.getAttribute("video-id"),
+ sameRoot: Boolean(
+ hiddenOutgoing &&
+ liveDestination &&
+ hiddenOutgoing.closest("ytd-watch-flexy") === liveDestination.closest("ytd-watch-flexy"),
+ ),
+ sidebarConnected: sidebar.isConnected,
+ };
+ },
+ };
+ },
+ { once: true },
+ );
+ }, scenario);
+}
+
+async function installSameNodeRouteCompletionFixture(context, scenario) {
+ await context.addInitScript((matrixScenario) => {
+ if (!location.hostname.endsWith("youtube.com")) return;
+
+ addEventListener(
+ "DOMContentLoaded",
+ () => {
+ if (new URL(location.href).searchParams.get("rydNavigationFixture") !== "1") return;
+
+ const fixturePage = document.getElementById("fixture-page");
+ const watchPage = fixturePage?.querySelector('[data-fixture-page-kind="watch"]');
+ const watchFlexy = watchPage?.querySelector("ytd-watch-flexy");
+ const topRow = watchPage?.querySelector("#top-row");
+ const buttons = topRow?.querySelector("#top-level-buttons-computed");
+ const controls = buttons?.querySelector("[data-fixture-control-video-id]");
+ const like = controls?.querySelector('[data-ryd-role="like"]');
+ const dislike = controls?.querySelector('[data-ryd-role="dislike"]');
+ if (!fixturePage || !watchPage || !watchFlexy || !topRow || !buttons || !controls || !like || !dislike) {
+ throw new Error("The same-node matrix requires a complete watch fixture.");
+ }
+ watchFlexy.appendChild(topRow);
+
+ const sidebar = document.createElement("aside");
+ sidebar.id = "fixture-matrix-same-node-sidebar";
+ sidebar.setAttribute("aria-label", "Fixture same-node sidebar");
+ const destinationLink = document.createElement("a");
+ destinationLink.id = "fixture-matrix-same-node-watch";
+ destinationLink.href = `/watch?v=${matrixScenario.destination.videoId}&rydNavigationFixture=1`;
+ destinationLink.textContent = "Open same-node sidebar video";
+ sidebar.appendChild(destinationLink);
+ fixturePage.before(sidebar);
+
+ const documentIdentity = `matrix-${Date.now()}-${Math.random()}`;
+ const timeline = [];
+ destinationLink.addEventListener("click", (event) => {
+ event.preventDefault();
+ document.dispatchEvent(new Event("yt-navigate-start", { bubbles: true }));
+ timeline.push("navigate-start");
+
+ history.pushState({}, "", `/watch?v=${matrixScenario.destination.videoId}&rydNavigationFixture=1`);
+ watchPage.setAttribute("data-fixture-video-id", matrixScenario.destination.videoId);
+ watchFlexy.setAttribute("video-id", matrixScenario.destination.videoId);
+ // This fixture-only marker lets the test address B. It is deliberately
+ // outside the userscript observer's attribute filter and provides no
+ // runtime ownership evidence.
+ controls.setAttribute("data-fixture-control-video-id", matrixScenario.destination.videoId);
+ timeline.push("route-and-root-only");
+
+ document.dispatchEvent(new Event("yt-navigate-finish", { bubbles: true }));
+ timeline.push("navigate-finish");
+ });
+
+ globalThis.__navigationMatrixSameNodeFixture = {
+ snapshot() {
+ return {
+ buttonsReused: buttons === watchFlexy.querySelector("#top-level-buttons-computed"),
+ controlsReused: controls === watchFlexy.querySelector("[data-fixture-control-video-id]"),
+ dislikeReused: dislike === watchFlexy.querySelector('[data-ryd-role="dislike"]'),
+ documentIdentity,
+ likeReused: like === watchFlexy.querySelector('[data-ryd-role="like"]'),
+ rootVideoId: watchFlexy.getAttribute("video-id"),
+ sidebarConnected: sidebar.isConnected,
+ timeline: [...timeline],
+ };
+ },
+ };
+ },
+ { once: true },
+ );
+ }, scenario);
+}
+
+async function installStandardNavigationMatrixFixture(context, scenario) {
+ await context.addInitScript((matrixScenario) => {
+ if (!location.hostname.endsWith("youtube.com")) return;
+
+ addEventListener(
+ "DOMContentLoaded",
+ () => {
+ if (new URL(location.href).searchParams.get("rydNavigationFixture") !== "1") return;
+
+ const fixturePage = document.getElementById("fixture-page");
+ if (!fixturePage || !globalThis.__navigationFixture) {
+ throw new Error("The navigation matrix requires the navigation-page fixture.");
+ }
+
+ const probe = {
+ documentIdentity: `matrix-${Date.now()}-${Math.random()}`,
+ historyRenders: [],
+ navigateFinishes: 0,
+ navigateStarts: 0,
+ };
+ document.addEventListener("yt-navigate-start", () => {
+ probe.navigateStarts += 1;
+ });
+ document.addEventListener("yt-navigate-finish", () => {
+ probe.navigateFinishes += 1;
+ });
+
+ const delayedLinkId = {
+ "short-direct-watch-delayed": "short-to-watch",
+ "watch-direct-short-delayed": "watch-to-short",
+ }[matrixScenario.id];
+ if (delayedLinkId) {
+ document.getElementById(delayedLinkId)?.setAttribute("data-fixture-control-delay", "600");
+ }
+
+ if (matrixScenario.id === "watch-history-back-forward-replace") {
+ addEventListener("popstate", () => {
+ const videoId = new URL(location.href).searchParams.get("v");
+ const watchPage = fixturePage.querySelector('[data-fixture-page-kind="watch"]');
+ const currentTopRow = watchPage?.querySelector("#top-row");
+ const watchFlexy = watchPage?.querySelector("ytd-watch-flexy");
+ if (!videoId || !watchPage || !currentTopRow || !watchFlexy) {
+ throw new Error("The history matrix could not render the current watch entry.");
+ }
+
+ const replacementTopRow = currentTopRow.cloneNode(true);
+ replacementTopRow.removeAttribute("style");
+ replacementTopRow.querySelectorAll(".ryd-tooltip").forEach((element) => element.remove());
+ const controls = replacementTopRow.querySelector("[data-fixture-control-video-id]");
+ 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");
+ control.querySelector("button")?.setAttribute("aria-pressed", "false");
+ }
+ controls.querySelector('[data-ryd-role="dislike"] #text').textContent = "";
+ currentTopRow.replaceWith(replacementTopRow);
+ watchPage.setAttribute("data-fixture-video-id", videoId);
+ watchFlexy.setAttribute("video-id", videoId);
+ probe.historyRenders.push(videoId);
+ document.dispatchEvent(new Event("yt-navigate-finish", { bubbles: true }));
+ });
+ }
+
+ globalThis.__navigationMatrixProbe = {
+ snapshot() {
+ return {
+ currentKind:
+ fixturePage.querySelector("[data-fixture-page-kind]")?.getAttribute("data-fixture-page-kind") ?? null,
+ currentVideoId:
+ fixturePage.querySelector("[data-fixture-video-id]")?.getAttribute("data-fixture-video-id") ?? null,
+ documentIdentity: probe.documentIdentity,
+ historyRenders: [...probe.historyRenders],
+ navigateFinishes: probe.navigateFinishes,
+ navigateStarts: probe.navigateStarts,
+ transitionPending: fixturePage.dataset.fixtureTransitionPending ?? null,
+ };
+ },
+ };
+ },
+ { once: true },
+ );
+ }, scenario);
+}
+
+async function installNavigationMatrixFixture(context, scenario) {
+ if (!NAVIGATION_MATRIX.some((candidate) => candidate.id === scenario.id)) {
+ throw new Error(`No navigation matrix fixture is registered for ${scenario.id}.`);
+ }
+ if (scenario.id === "watch-sidebar-watch-retain-prune") {
+ await installSidebarRetainPruneFixture(context, scenario);
+ return;
+ }
+ if (
+ scenario.id === "watch-sidebar-watch-same-root-hidden-first" ||
+ scenario.id === "watch-sidebar-watch-same-root-offscreen-first" ||
+ scenario.id === "watch-sidebar-watch-legacy-segmented-duplicate-ids"
+ ) {
+ await installSameRootHiddenFirstFixture(context, scenario);
+ return;
+ }
+ if (scenario.id === "watch-sidebar-watch-same-node-route-complete") {
+ await installSameNodeRouteCompletionFixture(context, scenario);
+ return;
+ }
+ await installStandardNavigationMatrixFixture(context, scenario);
+}
+
+function currentWatchLocators(page, runtime, videoId) {
+ const controls = page.locator(`[data-fixture-control-video-id="${videoId}"]`);
+ const reactionRegion = controls.locator("xpath=..");
+ const wrapper = reactionRegion.locator(`:scope > ${runtime.selectors.wrapper}`);
+ const container = wrapper.locator(runtime.selectors.container);
+ return {
+ bar: container.locator(runtime.selectors.bar),
+ container,
+ controls,
+ reactionRegion,
+ tooltip: wrapper.locator(runtime.selectors.tooltip),
+ wrapper,
+ };
+}
+
+async function expectOwnedWatchBar(page, runtime, videoId, counts) {
+ const locators = currentWatchLocators(page, runtime, videoId);
+ await expect(locators.controls).toHaveCount(1);
+ await expect(locators.controls.locator('[data-ryd-role="dislike"] #text')).toHaveText(String(counts.dislikes));
+ await expect(locators.wrapper).toHaveCount(1);
+ await expect(locators.wrapper).toBeVisible();
+ await expect(locators.container).toBeVisible();
+ await expect(locators.bar).toBeVisible();
+ await expect(locators.tooltip).toContainText(runtime.tooltipText(counts));
+ await expect(page.locator(runtime.selectors.wrapper)).toHaveCount(1);
+ await expect(page.locator(runtime.selectors.container)).toHaveCount(1);
+ await expect(page.locator(runtime.selectors.bar)).toHaveCount(1);
+ await expect(page.locator(runtime.selectors.tooltip)).toHaveCount(1);
+
+ const geometry = await locators.reactionRegion.evaluate((reactionRegion, selectors) => {
+ const box = (element) => {
+ const bounds = element.getBoundingClientRect();
+ return {
+ bottom: bounds.bottom,
+ left: bounds.left,
+ right: bounds.right,
+ top: bounds.top,
+ width: bounds.width,
+ };
+ };
+ const like = reactionRegion.querySelector('[data-ryd-role="like"] button');
+ const dislike = reactionRegion.querySelector('[data-ryd-role="dislike"] button');
+ const wrapper = reactionRegion.querySelector(`:scope > ${selectors.wrapper}`);
+ const container = wrapper.querySelector(selectors.container);
+ const bar = container.querySelector(selectors.bar);
+ return {
+ bar: box(bar),
+ container: box(container),
+ dislike: box(dislike),
+ like: box(like),
+ wrapper: box(wrapper),
+ };
+ }, runtime.selectors);
+ expect(geometry.wrapper.width).toBeCloseTo(geometry.like.width + geometry.dislike.width, 0);
+ expect(geometry.container.top).toBeGreaterThanOrEqual(Math.max(geometry.like.bottom, geometry.dislike.bottom) - 1);
+ expect(geometry.bar.left).toBeGreaterThanOrEqual(geometry.container.left - 1);
+ expect(geometry.bar.right).toBeLessThanOrEqual(geometry.container.right + 1);
+ expect(geometry.bar.width / geometry.container.width).toBeCloseTo(counts.likes / (counts.likes + counts.dislikes), 2);
+ return locators;
+}
+
+async function expectOwnedShortControl(page, runtime, videoId, counts) {
+ await expect(page).toHaveURL((url) => url.pathname === `/shorts/${videoId}`);
+ const activeRenderer = page.locator(`ytd-reel-video-renderer[video-id="${videoId}"][is-active]`);
+ const dislike = activeRenderer.locator(runtime.selectors.shortsDislike);
+ await expect(activeRenderer).toHaveCount(1);
+ await expect(activeRenderer).toBeVisible();
+ await expect(dislike).toHaveCount(1);
+ await expect(dislike).toBeVisible();
+ await expect(dislike.locator("#text")).toHaveText(String(counts.dislikes));
+ if (runtime.selectors.shortsVideoAttribute) {
+ await expect(dislike).toHaveAttribute(runtime.selectors.shortsVideoAttribute, videoId);
+ }
+ await expect(page.locator(`${runtime.selectors.shortsDislike}:visible`)).toHaveCount(1);
+ await expect(page.locator(runtime.selectors.wrapper)).toHaveCount(0);
+ await expect(page.locator(runtime.selectors.container)).toHaveCount(0);
+ await expect(page.locator(runtime.selectors.bar)).toHaveCount(0);
+ return { activeRenderer, dislike };
+}
+
+async function readOwnedSurfaceInvariant(page, runtime, videoId, counts, kind) {
+ return page.evaluate(
+ ({ counts: expectedCounts, expectedTooltip, kind: expectedKind, selectors, videoId: expectedVideoId }) => {
+ const visible = (element) => {
+ if (!element?.isConnected || element.closest("[hidden], [aria-hidden='true']")) return false;
+ const style = getComputedStyle(element);
+ const bounds = element.getBoundingClientRect();
+ return style.display !== "none" && style.visibility !== "hidden" && bounds.width > 0 && bounds.height > 0;
+ };
+ const currentUrl = new URL(location.href);
+ const common = {
+ bars: document.querySelectorAll(selectors.bar).length,
+ containers: document.querySelectorAll(selectors.container).length,
+ pathname: currentUrl.pathname,
+ tooltips: document.querySelectorAll(selectors.tooltip).length,
+ wrappers: document.querySelectorAll(selectors.wrapper).length,
+ };
+
+ if (expectedKind === "shorts") {
+ const activeRenderers = Array.from(
+ document.querySelectorAll(`ytd-reel-video-renderer[video-id="${expectedVideoId}"][is-active]`),
+ );
+ const currentControls = activeRenderers.flatMap((renderer) =>
+ Array.from(renderer.querySelectorAll(selectors.shortsDislike)),
+ );
+ const currentActionButtons = activeRenderers.flatMap((renderer) =>
+ Array.from(renderer.querySelectorAll("reel-action-bar-view-model button")).filter(visible),
+ );
+ const visibleControls = Array.from(document.querySelectorAll(selectors.shortsDislike)).filter(visible);
+ return {
+ ...common,
+ activeRenderers: activeRenderers.length,
+ count: currentControls[0]?.querySelector("#text")?.textContent?.trim() ?? null,
+ currentActionButtons: currentActionButtons.length,
+ currentControls: currentControls.length,
+ currentVisible: currentControls.length === 1 && visible(currentControls[0]),
+ expectedCount: String(expectedCounts.dislikes),
+ expectedPathname: `/shorts/${expectedVideoId}`,
+ kind: expectedKind,
+ visibleControls: visibleControls.length,
+ };
+ }
+
+ const controls = Array.from(document.querySelectorAll(`[data-fixture-control-video-id="${expectedVideoId}"]`));
+ const currentControls = controls.filter(visible);
+ const reactionRegion = currentControls[0]?.parentElement ?? null;
+ const wrapper = reactionRegion?.querySelector(`:scope > ${selectors.wrapper}`) ?? null;
+ const container = wrapper?.querySelector(selectors.container) ?? null;
+ const bar = container?.querySelector(selectors.bar) ?? null;
+ const containerBounds = container?.getBoundingClientRect();
+ const barBounds = bar?.getBoundingClientRect();
+ return {
+ ...common,
+ count: currentControls[0]?.querySelector('[data-ryd-role="dislike"] #text')?.textContent?.trim() ?? null,
+ currentControls: currentControls.length,
+ expectedCount: String(expectedCounts.dislikes),
+ expectedPathname: "/watch",
+ expectedRatio: expectedCounts.likes / (expectedCounts.likes + expectedCounts.dislikes),
+ expectedTooltip,
+ expectedVideoId,
+ kind: expectedKind,
+ ownerBarVisible: visible(bar),
+ ownerContainerVisible: visible(container),
+ ownerTooltip: wrapper?.querySelector(selectors.tooltip)?.textContent?.replace(/\s+/g, " ").trim() ?? null,
+ ownerWrapperVisible: visible(wrapper),
+ ratio: containerBounds?.width > 0 && barBounds ? barBounds.width / containerBounds.width : null,
+ videoId: currentUrl.searchParams.get("v"),
+ };
+ },
+ { counts, expectedTooltip: runtime.tooltipText(counts), kind, selectors: runtime.selectors, videoId },
+ );
+}
+
+function ownedSurfaceInvariantIsValid(sample) {
+ if (sample.pathname !== sample.expectedPathname || sample.count !== sample.expectedCount) return false;
+ if (sample.kind === "shorts") {
+ return (
+ sample.activeRenderers === 1 &&
+ sample.currentControls === 1 &&
+ sample.currentActionButtons >= 4 &&
+ sample.currentVisible &&
+ sample.visibleControls === 1 &&
+ sample.wrappers === 0 &&
+ sample.containers === 0 &&
+ sample.bars === 0
+ );
+ }
+ return (
+ sample.videoId === sample.expectedVideoId &&
+ sample.currentControls === 1 &&
+ sample.wrappers === 1 &&
+ sample.containers === 1 &&
+ sample.bars === 1 &&
+ sample.tooltips === 1 &&
+ sample.ownerWrapperVisible &&
+ sample.ownerContainerVisible &&
+ sample.ownerBarVisible &&
+ sample.ownerTooltip?.includes(sample.expectedTooltip) &&
+ Math.abs(sample.ratio - sample.expectedRatio) <= 0.015
+ );
+}
+
+async function waitForOwnedSurfaceStability(page, runtime, surface, timing = {}) {
+ return waitForStableInvariant({
+ intervalMs: 25,
+ isValid: ownedSurfaceInvariantIsValid,
+ label: `${runtime.name} ${surface.kind} ${surface.videoId} ownership`,
+ read: () => readOwnedSurfaceInvariant(page, runtime, surface.videoId, surface.counts, surface.kind),
+ stableForMs: timing.stableForMs ?? 250,
+ timeoutMs: timing.timeoutMs ?? 2_000,
+ });
+}
+
+async function waitForOwnedSurfaceWithinBudget(page, runtime, surface, maxFirstValidMs = 1_000) {
+ const readiness = await waitForOwnedSurfaceStability(page, runtime, surface, {
+ stableForMs: 250,
+ timeoutMs: maxFirstValidMs + 500,
+ });
+ expect(readiness.firstValidMs).toBeLessThanOrEqual(maxFirstValidMs);
+ return readiness;
+}
+
+async function assertOwnedSurfaceContinuously(page, runtime, surface, durationMs = 600) {
+ return assertInvariantContinuously({
+ durationMs,
+ intervalMs: 25,
+ isValid: ownedSurfaceInvariantIsValid,
+ label: `${runtime.name} settled ${surface.kind} ${surface.videoId}`,
+ read: () => readOwnedSurfaceInvariant(page, runtime, surface.videoId, surface.counts, surface.kind),
+ });
+}
+
+function expectCountRequestVideoIds(backend, expectedVideoIds) {
+ expect(backend.requestsFor("GET", "/votes").map((request) => request.query.videoId)).toEqual(expectedVideoIds);
+}
+
+async function readStandardProbe(page) {
+ return page.evaluate(() => globalThis.__navigationMatrixProbe.snapshot());
+}
+
+async function pruneCurrentWatchBar(page, runtime, locators, surface) {
+ await locators.reactionRegion.evaluate((reactionRegion, wrapperSelector) => {
+ const stats = { addedWrappers: 0, removedWrappers: 0 };
+ const observer = new MutationObserver((mutations) => {
+ for (const mutation of mutations) {
+ for (const node of mutation.addedNodes) {
+ if (node instanceof Element && node.matches(wrapperSelector)) stats.addedWrappers += 1;
+ }
+ for (const node of mutation.removedNodes) {
+ if (node instanceof Element && node.matches(wrapperSelector)) stats.removedWrappers += 1;
+ }
+ }
+ });
+ observer.observe(reactionRegion, { childList: true });
+ globalThis.__navigationMatrixBarObserver = observer;
+ globalThis.__navigationMatrixBarStats = stats;
+
+ const wrapper = reactionRegion.querySelector(`:scope > ${wrapperSelector}`);
+ if (!wrapper) throw new Error("The current matrix reaction tree has no owned rate bar to prune.");
+ wrapper.remove();
+ }, runtime.selectors.wrapper);
+
+ await waitForOwnedSurfaceWithinBudget(page, runtime, surface);
+ await assertOwnedSurfaceContinuously(page, runtime, surface);
+ return page.evaluate(() => {
+ globalThis.__navigationMatrixBarObserver.disconnect();
+ return globalThis.__navigationMatrixBarStats;
+ });
+}
+
+async function runWatchSidebarRetainPruneScenario({ backend, page, runtime, scenario }) {
+ const { destination, origin } = scenario;
+ const initialDocumentIdentity = await page.evaluate(
+ () => globalThis.__navigationMatrixFixture.snapshot().documentIdentity,
+ );
+ await expect(page).toHaveURL((url) => url.pathname === "/watch" && url.searchParams.get("v") === origin.videoId);
+ await expectOwnedWatchBar(page, runtime, origin.videoId, origin.counts);
+ expect(backend.requestsFor("GET", "/votes").map((request) => request.query.videoId)).toEqual([origin.videoId]);
+
+ const destinationCountGate = backend.defer("GET", "/votes");
+ await page.locator("#fixture-matrix-sidebar-watch").click();
+
+ await expect(page).toHaveURL((url) => url.pathname === "/watch" && url.searchParams.get("v") === destination.videoId);
+ expect(await page.evaluate(() => globalThis.__navigationMatrixFixture.snapshot())).toEqual({
+ currentControls: 0,
+ documentIdentity: initialDocumentIdentity,
+ phase: "navigate-finish",
+ retainedControls: 1,
+ retainedTreesConnected: true,
+ retainedTreesHidden: true,
+ sidebarConnected: true,
+ timeline: ["navigate-start", "route-and-shell", "navigate-finish"],
+ });
+ await expect(page.locator("#fixture-matrix-retained-trees").locator(runtime.selectors.wrapper)).toHaveCount(1);
+ expect(backend.requestsFor("GET", "/votes").map((request) => request.query.videoId)).toEqual([origin.videoId]);
+
+ await page.evaluate(() => globalThis.__navigationMatrixFixture.hydrateDestination());
+ const destinationRequest = await destinationCountGate.seen;
+ try {
+ expect(destinationRequest.query.videoId).toBe(destination.videoId);
+ expect(await page.evaluate(() => globalThis.__navigationMatrixFixture.snapshot())).toMatchObject({
+ currentControls: 1,
+ documentIdentity: initialDocumentIdentity,
+ phase: "hydrate-destination-controls",
+ retainedControls: 1,
+ retainedTreesConnected: true,
+ retainedTreesHidden: true,
+ sidebarConnected: true,
+ timeline: ["navigate-start", "route-and-shell", "navigate-finish", "hydrate-destination-controls"],
+ });
+ const gatedDestination = currentWatchLocators(page, runtime, destination.videoId);
+ await expect(gatedDestination.controls).toHaveCount(1);
+ await expect(gatedDestination.controls.locator('[data-ryd-role="dislike"] #text')).toHaveText("");
+ await expect(gatedDestination.wrapper).toHaveCount(0);
+ await expect(page.locator("#fixture-matrix-retained-trees").locator(runtime.selectors.wrapper)).toHaveCount(1);
+ } finally {
+ if (!destinationCountGate.released) {
+ destinationCountGate.release({ body: { ...destination.counts, rating: 4.5 } });
+ }
+ }
+
+ await waitForOwnedSurfaceWithinBudget(page, runtime, destination);
+ const destinationLocators = await expectOwnedWatchBar(page, runtime, destination.videoId, destination.counts);
+ await expect(page.locator("#fixture-matrix-retained-trees").locator(runtime.selectors.wrapper)).toHaveCount(0);
+ expect(backend.requestsFor("GET", "/votes").map((request) => request.query.videoId)).toEqual([
+ origin.videoId,
+ destination.videoId,
+ ]);
+
+ const pruneStats = await pruneCurrentWatchBar(page, runtime, destinationLocators, destination);
+ expect(pruneStats).toEqual({ addedWrappers: 1, removedWrappers: 1 });
+ await expectOwnedWatchBar(page, runtime, destination.videoId, destination.counts);
+ expect(backend.requestsFor("GET", "/votes").map((request) => request.query.videoId)).toEqual([
+ origin.videoId,
+ destination.videoId,
+ ]);
+
+ await page.evaluate(() => globalThis.__navigationMatrixFixture.detachOutgoing());
+ await assertOwnedSurfaceContinuously(page, runtime, destination, 550);
+ await expect(page.locator("#fixture-matrix-retained-trees").locator("[data-fixture-control-video-id]")).toHaveCount(
+ 0,
+ );
+ await expectOwnedWatchBar(page, runtime, destination.videoId, destination.counts);
+ expect(await page.evaluate(() => globalThis.__navigationMatrixFixture.snapshot())).toMatchObject({
+ currentControls: 1,
+ documentIdentity: initialDocumentIdentity,
+ phase: "detach-outgoing",
+ retainedControls: 0,
+ retainedTreesConnected: true,
+ retainedTreesHidden: true,
+ sidebarConnected: true,
+ timeline: [
+ "navigate-start",
+ "route-and-shell",
+ "navigate-finish",
+ "hydrate-destination-controls",
+ "detach-outgoing",
+ ],
+ });
+
+ expect(backend.requestsFor("POST", "/interact/vote")).toHaveLength(0);
+ expect(backend.requestsFor("POST", "/interact/confirmVote")).toHaveLength(0);
+}
+
+async function runWatchSameRootHiddenFirstScenario({ backend, page, runtime, scenario }) {
+ const { destination, origin } = scenario;
+ const initialDocumentIdentity = await page.evaluate(
+ () => globalThis.__navigationMatrixSameRootFixture.snapshot().documentIdentity,
+ );
+ await expectOwnedWatchBar(page, runtime, origin.videoId, origin.counts);
+ expectCountRequestVideoIds(backend, [origin.videoId]);
+
+ await page.locator("#fixture-matrix-same-root-watch").click();
+
+ await expect(page).toHaveURL((url) => url.pathname === "/watch" && url.searchParams.get("v") === destination.videoId);
+ await waitForOwnedSurfaceWithinBudget(page, runtime, destination);
+ await expectOwnedWatchBar(page, runtime, destination.videoId, destination.counts);
+ const outgoingIsOffscreen = scenario.transition.outgoingPresentation === "offscreen";
+ const settledTopology = await page.evaluate(() => globalThis.__navigationMatrixSameRootFixture.snapshot());
+ expect(settledTopology).toEqual({
+ documentIdentity: initialDocumentIdentity,
+ hiddenFirst: true,
+ hiddenOutgoingConnected: true,
+ hiddenOutgoingHeight: outgoingIsOffscreen ? expect.any(Number) : 0,
+ hiddenOutgoingWidth: outgoingIsOffscreen ? expect.any(Number) : 0,
+ liveDestinationConnected: true,
+ outgoingIntersectsViewport: false,
+ outgoingPresentation: scenario.transition.outgoingPresentation,
+ rootVideoId: destination.videoId,
+ sameRoot: true,
+ sidebarConnected: true,
+ });
+ if (outgoingIsOffscreen) {
+ expect(settledTopology.hiddenOutgoingHeight).toBeGreaterThan(0);
+ expect(settledTopology.hiddenOutgoingWidth).toBeGreaterThan(0);
+ }
+ const currentRoot = page.locator(`ytd-watch-flexy[video-id="${destination.videoId}"]`);
+ await expect(currentRoot.locator("ytd-menu-renderer.ytd-watch-metadata > div")).toHaveCount(2);
+ await expect(
+ currentRoot.locator('[data-fixture-matrix-hidden-outgoing="true"]').locator(runtime.selectors.wrapper),
+ ).toHaveCount(0);
+ await expect(
+ currentRoot.locator('[data-fixture-matrix-live-destination="true"]').locator(runtime.selectors.wrapper),
+ ).toHaveCount(1);
+ if (scenario.transition.controlMarkup === "legacy-segmented") {
+ await expect(currentRoot.locator("#segmented-like-button")).toHaveCount(2);
+ await expect(currentRoot.locator("#segmented-dislike-button")).toHaveCount(2);
+ await expect(
+ currentRoot.locator('[data-fixture-matrix-live-destination="true"]').locator("#segmented-dislike-button #text"),
+ ).toHaveText(String(destination.counts.dislikes));
+ }
+ expectCountRequestVideoIds(backend, [origin.videoId, destination.videoId]);
+
+ await assertOwnedSurfaceContinuously(page, runtime, destination);
+ expectCountRequestVideoIds(backend, [origin.videoId, destination.videoId]);
+ expect(await page.evaluate(() => globalThis.__navigationMatrixSameRootFixture.snapshot())).toMatchObject({
+ documentIdentity: initialDocumentIdentity,
+ hiddenFirst: true,
+ hiddenOutgoingConnected: true,
+ hiddenOutgoingHeight: outgoingIsOffscreen ? expect.any(Number) : 0,
+ hiddenOutgoingWidth: outgoingIsOffscreen ? expect.any(Number) : 0,
+ liveDestinationConnected: true,
+ outgoingIntersectsViewport: false,
+ outgoingPresentation: scenario.transition.outgoingPresentation,
+ rootVideoId: destination.videoId,
+ sameRoot: true,
+ sidebarConnected: true,
+ });
+
+ await page.evaluate(() => globalThis.__navigationMatrixSameRootFixture.detachOutgoing());
+ await assertOwnedSurfaceContinuously(page, runtime, destination, 550);
+ await expect(currentRoot.locator("ytd-menu-renderer.ytd-watch-metadata > div")).toHaveCount(1);
+ await expectOwnedWatchBar(page, runtime, destination.videoId, destination.counts);
+ expectCountRequestVideoIds(backend, [origin.videoId, destination.videoId]);
+}
+
+async function runWatchHistoryScenario({ backend, page, runtime, scenario }) {
+ const { destination, origin } = scenario;
+ const initialProbe = await readStandardProbe(page);
+ await expectOwnedWatchBar(page, runtime, origin.videoId, origin.counts);
+ expectCountRequestVideoIds(backend, [origin.videoId]);
+
+ await page.locator("#watch-next").click();
+ await waitForOwnedSurfaceWithinBudget(page, runtime, destination);
+ await expectOwnedWatchBar(page, runtime, destination.videoId, destination.counts);
+ expect(await readStandardProbe(page)).toMatchObject({
+ currentKind: "watch",
+ currentVideoId: destination.videoId,
+ documentIdentity: initialProbe.documentIdentity,
+ historyRenders: [],
+ navigateFinishes: 1,
+ navigateStarts: 0,
+ transitionPending: null,
+ });
+ expectCountRequestVideoIds(backend, [origin.videoId, destination.videoId]);
+
+ await page.goBack();
+ await waitForOwnedSurfaceWithinBudget(page, runtime, origin);
+ await expectOwnedWatchBar(page, runtime, origin.videoId, origin.counts);
+ expect(await readStandardProbe(page)).toMatchObject({
+ currentKind: "watch",
+ currentVideoId: origin.videoId,
+ documentIdentity: initialProbe.documentIdentity,
+ historyRenders: [origin.videoId],
+ navigateFinishes: 2,
+ navigateStarts: 0,
+ transitionPending: null,
+ });
+ expectCountRequestVideoIds(backend, [origin.videoId, destination.videoId, origin.videoId]);
+
+ await page.goForward();
+ await waitForOwnedSurfaceWithinBudget(page, runtime, destination);
+ await expectOwnedWatchBar(page, runtime, destination.videoId, destination.counts);
+ expect(await readStandardProbe(page)).toMatchObject({
+ currentKind: "watch",
+ currentVideoId: destination.videoId,
+ documentIdentity: initialProbe.documentIdentity,
+ historyRenders: [origin.videoId, destination.videoId],
+ navigateFinishes: 3,
+ navigateStarts: 0,
+ transitionPending: null,
+ });
+ expectCountRequestVideoIds(backend, [origin.videoId, destination.videoId, origin.videoId, destination.videoId]);
+
+ await assertOwnedSurfaceContinuously(page, runtime, destination);
+ expectCountRequestVideoIds(backend, [origin.videoId, destination.videoId, origin.videoId, destination.videoId]);
+}
+
+async function runWatchAutoplayScenario({ backend, page, runtime, scenario }) {
+ const { destination, origin } = scenario;
+ const initialProbe = await readStandardProbe(page);
+ await expectOwnedWatchBar(page, runtime, origin.videoId, origin.counts);
+ expectCountRequestVideoIds(backend, [origin.videoId]);
+
+ await page.evaluate(() => globalThis.__navigationFixture.dispatchEnded());
+
+ await waitForOwnedSurfaceWithinBudget(page, runtime, destination);
+ await expectOwnedWatchBar(page, runtime, destination.videoId, destination.counts);
+ expect(await readStandardProbe(page)).toMatchObject({
+ currentKind: "watch",
+ currentVideoId: destination.videoId,
+ documentIdentity: initialProbe.documentIdentity,
+ historyRenders: [],
+ navigateFinishes: 0,
+ navigateStarts: 0,
+ transitionPending: null,
+ });
+ expectCountRequestVideoIds(backend, [origin.videoId, destination.videoId]);
+
+ await assertOwnedSurfaceContinuously(page, runtime, destination);
+ expectCountRequestVideoIds(backend, [origin.videoId, destination.videoId]);
+}
+
+async function runCrossSurfaceScenario({ backend, page, runtime, scenario }) {
+ const { destination, origin } = scenario;
+ const initialProbe = await readStandardProbe(page);
+ if (origin.kind === "watch") {
+ await expectOwnedWatchBar(page, runtime, origin.videoId, origin.counts);
+ } else {
+ await expectOwnedShortControl(page, runtime, origin.videoId, origin.counts);
+ }
+ expectCountRequestVideoIds(backend, [origin.videoId]);
+
+ const linkId = origin.kind === "watch" ? "watch-to-short" : "short-to-watch";
+ await page.locator(`#${linkId}`).click();
+
+ if (destination.kind === "watch") {
+ await expect(page).toHaveURL(
+ (url) => url.pathname === "/watch" && url.searchParams.get("v") === destination.videoId,
+ );
+ } else {
+ await expect(page).toHaveURL((url) => url.pathname === `/shorts/${destination.videoId}`);
+ }
+ expect(await readStandardProbe(page)).toMatchObject({
+ currentKind: origin.kind,
+ currentVideoId: origin.videoId,
+ documentIdentity: initialProbe.documentIdentity,
+ historyRenders: [],
+ navigateFinishes: 1,
+ navigateStarts: 0,
+ transitionPending: destination.kind,
+ });
+ expectCountRequestVideoIds(backend, [origin.videoId]);
+
+ await waitForOwnedSurfaceWithinBudget(page, runtime, destination);
+ if (destination.kind === "watch") {
+ await expectOwnedWatchBar(page, runtime, destination.videoId, destination.counts);
+ } else {
+ await expectOwnedShortControl(page, runtime, destination.videoId, destination.counts);
+ }
+ expect(await readStandardProbe(page)).toMatchObject({
+ currentKind: destination.kind,
+ currentVideoId: destination.videoId,
+ documentIdentity: initialProbe.documentIdentity,
+ historyRenders: [],
+ navigateFinishes: 1,
+ navigateStarts: 0,
+ transitionPending: null,
+ });
+ expectCountRequestVideoIds(backend, [origin.videoId, destination.videoId]);
+
+ await assertOwnedSurfaceContinuously(page, runtime, destination);
+ expectCountRequestVideoIds(backend, [origin.videoId, destination.videoId]);
+}
+
+async function runWatchActionContainerReplacementScenario({ backend, page, runtime, scenario }) {
+ const { origin } = scenario;
+ const initialProbe = await readStandardProbe(page);
+ await expectOwnedWatchBar(page, runtime, origin.videoId, origin.counts);
+ expectCountRequestVideoIds(backend, [origin.videoId]);
+
+ await page.evaluate((selectors) => {
+ const currentActions = document.querySelector(
+ '[data-fixture-page-kind="watch"] ytd-menu-renderer.ytd-watch-metadata > div',
+ );
+ if (!currentActions) throw new Error("The current watch fixture has no action container to replace.");
+ const replacement = currentActions.cloneNode(true);
+ replacement.setAttribute("data-fixture-matrix-action-replacement", "true");
+ replacement.querySelectorAll(selectors.wrapper).forEach((element) => element.remove());
+ const controls = replacement.querySelector("[data-fixture-control-video-id]");
+ 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");
+ control.querySelector("button")?.setAttribute("aria-pressed", "false");
+ }
+ controls.querySelector('[data-ryd-role="dislike"] #text').textContent = "";
+
+ const stats = { addedWrappers: 0 };
+ const observer = new MutationObserver((mutations) => {
+ for (const mutation of mutations) {
+ for (const node of mutation.addedNodes) {
+ if (node instanceof Element && node.matches(selectors.wrapper)) stats.addedWrappers += 1;
+ }
+ }
+ });
+ observer.observe(replacement, { childList: true });
+ globalThis.__navigationMatrixReplacementObserver = observer;
+ globalThis.__navigationMatrixReplacementStats = stats;
+ globalThis.__navigationMatrixReplacedActions = currentActions;
+ currentActions.replaceWith(replacement);
+ }, runtime.selectors);
+
+ await waitForOwnedSurfaceWithinBudget(page, runtime, origin);
+ await expectOwnedWatchBar(page, runtime, origin.videoId, origin.counts);
+ await expect(page.locator('[data-fixture-matrix-action-replacement="true"]')).toHaveCount(1);
+ expect(
+ await page.evaluate(() => ({
+ oldContainerConnected: globalThis.__navigationMatrixReplacedActions.isConnected,
+ probe: globalThis.__navigationMatrixProbe.snapshot(),
+ })),
+ ).toMatchObject({
+ oldContainerConnected: false,
+ probe: {
+ currentKind: "watch",
+ currentVideoId: origin.videoId,
+ documentIdentity: initialProbe.documentIdentity,
+ historyRenders: [],
+ navigateFinishes: 0,
+ navigateStarts: 0,
+ transitionPending: null,
+ },
+ });
+ expectCountRequestVideoIds(backend, [origin.videoId]);
+
+ await assertOwnedSurfaceContinuously(page, runtime, origin);
+ const replacementStats = await page.evaluate(() => {
+ globalThis.__navigationMatrixReplacementObserver.disconnect();
+ return globalThis.__navigationMatrixReplacementStats;
+ });
+ expect(replacementStats).toEqual({ addedWrappers: 1 });
+ await expectOwnedWatchBar(page, runtime, origin.videoId, origin.counts);
+ expectCountRequestVideoIds(backend, [origin.videoId]);
+}
+
+async function runWatchSameNodeRouteCompletionScenario({ backend, page, runtime, scenario }) {
+ const { destination, origin } = scenario;
+ const initialTopology = await page.evaluate(() => globalThis.__navigationMatrixSameNodeFixture.snapshot());
+ await expectOwnedWatchBar(page, runtime, origin.videoId, origin.counts);
+ expectCountRequestVideoIds(backend, [origin.videoId]);
+
+ const destinationCountGate = backend.defer("GET", "/votes");
+ await page.locator("#fixture-matrix-same-node-watch").click();
+ await expect(page).toHaveURL((url) => url.pathname === "/watch" && url.searchParams.get("v") === destination.videoId);
+
+ const destinationRequest = await destinationCountGate.seen;
+ expect(destinationRequest.query.videoId).toBe(destination.videoId);
+ const pendingDestination = currentWatchLocators(page, runtime, destination.videoId);
+ await expect(pendingDestination.controls).toHaveCount(1);
+ await expect(pendingDestination.controls.locator('[data-ryd-role="dislike"] #text')).toHaveText("");
+ await expect(pendingDestination.wrapper).toHaveCount(0);
+ await expect(page.locator(`${runtime.selectors.wrapper}[data-ryd-video-id="${origin.videoId}"]`)).toHaveCount(0);
+
+ destinationCountGate.release({ body: { ...destination.counts, rating: 4.5 } });
+ await waitForOwnedSurfaceWithinBudget(page, runtime, destination);
+ const destinationLocators = await expectOwnedWatchBar(page, runtime, destination.videoId, destination.counts);
+ await expect(destinationLocators.wrapper).toHaveAttribute("data-ryd-video-id", destination.videoId);
+ expectCountRequestVideoIds(backend, [origin.videoId, destination.videoId]);
+ expect(await page.evaluate(() => globalThis.__navigationMatrixSameNodeFixture.snapshot())).toEqual({
+ buttonsReused: true,
+ controlsReused: true,
+ dislikeReused: true,
+ documentIdentity: initialTopology.documentIdentity,
+ likeReused: true,
+ rootVideoId: destination.videoId,
+ sidebarConnected: true,
+ timeline: ["navigate-start", "route-and-root-only", "navigate-finish"],
+ });
+
+ await assertOwnedSurfaceContinuously(page, runtime, destination);
+ expectCountRequestVideoIds(backend, [origin.videoId, destination.videoId]);
+}
+
+async function runWatchRateBarCorruptionScenario({ backend, page, runtime, scenario }) {
+ const { origin } = scenario;
+ await expectOwnedWatchBar(page, runtime, origin.videoId, origin.counts);
+ expectCountRequestVideoIds(backend, [origin.videoId]);
+
+ for (const corruption of scenario.transition.corruptions) {
+ const corrupted = await page.evaluate(
+ ({ corruption: corruptionKind, selectors }) => {
+ const wrapper = document.querySelector(selectors.wrapper);
+ const container = wrapper?.querySelector(selectors.container);
+ const fill = container?.querySelector(selectors.bar);
+ if (!wrapper || !container || !fill) {
+ throw new Error(`Cannot apply ${corruptionKind}; the current rate bar is incomplete.`);
+ }
+ globalThis.__navigationMatrixCorruptedRateBar = wrapper;
+ if (corruptionKind === "hidden-wrapper") {
+ wrapper.hidden = true;
+ } else if (corruptionKind === "collapsed-wrapper") {
+ wrapper.style.width = "0px";
+ wrapper.style.overflow = "hidden";
+ } else if (corruptionKind === "missing-fill") {
+ fill.remove();
+ } else if (corruptionKind === "stripped-wrapper-class") {
+ wrapper.classList.remove("ryd-tooltip");
+ } else {
+ throw new Error(`Unknown rate-bar corruption ${corruptionKind}.`);
+ }
+ const bounds = wrapper.getBoundingClientRect();
+ return {
+ connected: wrapper.isConnected,
+ height: bounds.height,
+ hidden: wrapper.hidden,
+ width: bounds.width,
+ };
+ },
+ { corruption, selectors: runtime.selectors },
+ );
+ expect(corrupted.connected).toBe(true);
+ if (corruption === "hidden-wrapper") {
+ expect(corrupted.hidden).toBe(true);
+ }
+ if (corruption === "collapsed-wrapper") {
+ expect(corrupted.width).toBe(0);
+ }
+
+ await waitForOwnedSurfaceWithinBudget(page, runtime, origin);
+ await expectOwnedWatchBar(page, runtime, origin.videoId, origin.counts);
+ expect(await page.evaluate(() => globalThis.__navigationMatrixCorruptedRateBar.isConnected)).toBe(false);
+ expectCountRequestVideoIds(backend, [origin.videoId]);
+ await assertOwnedSurfaceContinuously(page, runtime, origin, 300);
+ }
+}
+
+async function runShortNextScenario({ backend, page, runtime, scenario }) {
+ const { destination, origin } = scenario;
+ const initialProbe = await readStandardProbe(page);
+ await expectOwnedShortControl(page, runtime, origin.videoId, origin.counts);
+ expectCountRequestVideoIds(backend, [origin.videoId]);
+
+ await page.locator("#short-next").click();
+ await waitForOwnedSurfaceWithinBudget(page, runtime, destination);
+ await expectOwnedShortControl(page, runtime, destination.videoId, destination.counts);
+ expect(await readStandardProbe(page)).toMatchObject({
+ currentKind: "shorts",
+ currentVideoId: destination.videoId,
+ documentIdentity: initialProbe.documentIdentity,
+ historyRenders: [],
+ navigateFinishes: 1,
+ navigateStarts: 0,
+ transitionPending: null,
+ });
+ expectCountRequestVideoIds(backend, [origin.videoId, destination.videoId]);
+ await assertOwnedSurfaceContinuously(page, runtime, destination);
+ expectCountRequestVideoIds(backend, [origin.videoId, destination.videoId]);
+}
+
+const SCENARIO_RUNNERS = {
+ "short-next-short-active-reel": runShortNextScenario,
+ "short-direct-watch-delayed": runCrossSurfaceScenario,
+ "watch-autoplay-watch-replace-no-finish": runWatchAutoplayScenario,
+ "watch-current-action-container-replace": runWatchActionContainerReplacementScenario,
+ "watch-current-rate-bar-connected-corruption": runWatchRateBarCorruptionScenario,
+ "watch-direct-short-delayed": runCrossSurfaceScenario,
+ "watch-history-back-forward-replace": runWatchHistoryScenario,
+ "watch-sidebar-watch-retain-prune": runWatchSidebarRetainPruneScenario,
+ "watch-sidebar-watch-legacy-segmented-duplicate-ids": runWatchSameRootHiddenFirstScenario,
+ "watch-sidebar-watch-same-root-hidden-first": runWatchSameRootHiddenFirstScenario,
+ "watch-sidebar-watch-same-root-offscreen-first": runWatchSameRootHiddenFirstScenario,
+ "watch-sidebar-watch-same-node-route-complete": runWatchSameNodeRouteCompletionScenario,
+};
+
+async function runNavigationMatrixScenario(options) {
+ const runner = SCENARIO_RUNNERS[options.scenario.id];
+ if (!runner) throw new Error(`No navigation matrix runner is registered for ${options.scenario.id}.`);
+ await runner(options);
+}
+
+module.exports = {
+ NAVIGATION_MATRIX,
+ USERSCRIPT_MATRIX_RUNTIME,
+ WATCH_SIDEBAR_MATRIX,
+ installNavigationMatrixFixture,
+ runNavigationMatrixScenario,
+};
diff --git a/Extensions/UserScript/e2e/userscript-initialization-race.e2e.js b/Extensions/UserScript/e2e/userscript-initialization-race.e2e.js
new file mode 100644
index 0000000..73c47a4
--- /dev/null
+++ b/Extensions/UserScript/e2e/userscript-initialization-race.e2e.js
@@ -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);
+});
diff --git a/Extensions/UserScript/e2e/userscript-navigation-lifecycle.e2e.js b/Extensions/UserScript/e2e/userscript-navigation-lifecycle.e2e.js
new file mode 100644
index 0000000..f923d7a
--- /dev/null
+++ b/Extensions/UserScript/e2e/userscript-navigation-lifecycle.e2e.js
@@ -0,0 +1,1093 @@
+const { test, expect } = require("@playwright/test");
+const {
+ CREDENTIAL_KEY,
+ VIDEO_A,
+ VIDEO_B,
+ createFakeBackend,
+ injectGeneratedUserscript,
+ installGmEnvironment,
+ installHermeticRoutes,
+ openNavigationFixture,
+ openShortsFixture,
+} = require("./harness");
+
+const EXISTING_CREDENTIALS = {
+ userId: "ExistingUserscriptCredential000000000001",
+ registrationConfirmed: true,
+};
+const COUNTS = {
+ [VIDEO_A]: { dislikes: 11, likes: 100 },
+ [VIDEO_B]: { dislikes: 22, likes: 200 },
+};
+
+async function launchNavigationHarness({ context, page }, initialPage = {}, { beforeInject } = {}) {
+ 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: COUNTS });
+ await installGmEnvironment(context, { [CREDENTIAL_KEY]: EXISTING_CREDENTIALS });
+ await installHermeticRoutes(context, backend);
+ await openNavigationFixture(page, initialPage);
+ if (beforeInject) await beforeInject(page);
+ await injectGeneratedUserscript(page);
+ return { backend, consoleErrors, pageErrors };
+}
+
+async function expectWatchInitialized(page, videoId, expectedDislikes = COUNTS[videoId].dislikes) {
+ const expectedCount = String(expectedDislikes);
+ await expect(page).toHaveURL((url) => url.pathname === "/watch" && url.searchParams.get("v") === videoId);
+ await expect(page.locator(`ytd-watch-flexy[video-id="${videoId}"]`)).toHaveCount(1);
+ await expect(page.locator('[data-fixture-page-kind="watch"] [data-ryd-role="buttons"]')).toHaveCount(1);
+ await expect(page.locator('[data-fixture-page-kind="watch"] [data-ryd-role="dislike"] #text')).toHaveText(
+ expectedCount,
+ );
+ await expect(page.locator("#return-youtube-dislike-bar-container")).toHaveCount(1);
+ await expect(page.locator("#return-youtube-dislike-bar")).toHaveCount(1);
+ await expect(page.locator(".ryd-tooltip")).toHaveCount(1);
+ await expect(page.locator("[data-ryd-synthetic-shorts-dislike]")).toHaveCount(0);
+}
+
+async function expectShortInitialized(page, videoId, expectedDislikes = COUNTS[videoId].dislikes) {
+ const expectedCount = String(expectedDislikes);
+ await expect(page).toHaveURL((url) => url.pathname === `/shorts/${videoId}`);
+ const activeRenderer = page.locator(`ytd-reel-video-renderer[video-id="${videoId}"][is-active]`);
+ await expect(page.locator("ytd-shorts")).toHaveCount(1);
+ await expect(activeRenderer).toHaveCount(1);
+ await expect(
+ page.locator('ytd-reel-video-renderer[is-active] reel-action-bar-view-model[data-ryd-role="buttons"]'),
+ ).toHaveCount(1);
+ const syntheticDislike = activeRenderer.locator("[data-ryd-synthetic-shorts-dislike]");
+ await expect(syntheticDislike).toHaveCount(1);
+ await expect(syntheticDislike).toBeVisible();
+ await expect(page.locator("[data-ryd-synthetic-shorts-dislike]:visible")).toHaveCount(1);
+ await expect(syntheticDislike).toHaveAttribute("data-ryd-video-id", videoId);
+ await expect(syntheticDislike.locator("button")).toBeEnabled();
+ await expect(syntheticDislike.locator("#text")).toHaveText(expectedCount);
+ await expect(page.locator("#return-youtube-dislike-bar-container")).toHaveCount(0);
+ await expect(page.locator(".ryd-tooltip")).toHaveCount(0);
+}
+
+async function expectMobileShortInitialized(page, videoId, expectedDislikes = COUNTS[videoId].dislikes) {
+ await expect(page).toHaveURL((url) => url.pathname === `/shorts/${videoId}`);
+ const activeShort = page.locator(`[data-fixture-mobile-short="${videoId}"][is-active]`);
+ await expect(activeShort).toHaveCount(1);
+ await expect(activeShort).toBeVisible();
+ await expect(page.locator("[data-fixture-mobile-short][is-active]:visible")).toHaveCount(1);
+ await expect(activeShort.locator('ytm-like-button-renderer[data-ryd-role="buttons"]')).toHaveCount(1);
+ await expect(activeShort.locator('[data-ryd-role="dislike"] #text')).toHaveText(String(expectedDislikes));
+ await expect(activeShort.locator('[data-ryd-role="dislike"] button')).toBeEnabled();
+ await expect(page.locator("[data-ryd-synthetic-shorts-dislike]")).toHaveCount(0);
+ await expect(page.locator("#return-youtube-dislike-bar-container")).toHaveCount(0);
+}
+
+async function expectOneActivation(page, backend, videoId) {
+ const voteCount = backend.requestsFor("POST", "/interact/vote").length;
+ const confirmationCount = backend.requestsFor("POST", "/interact/confirmVote").length;
+ await page.locator('[data-ryd-role="dislike"]:visible button').click();
+ await expect.poll(() => backend.requestsFor("POST", "/interact/confirmVote").length).toBe(confirmationCount + 1);
+ await page.waitForTimeout(600);
+
+ const votes = backend.requestsFor("POST", "/interact/vote");
+ const confirmations = backend.requestsFor("POST", "/interact/confirmVote");
+ expect(votes).toHaveLength(voteCount + 1);
+ expect(confirmations).toHaveLength(confirmationCount + 1);
+ expect(votes.at(-1).body).toMatchObject({ videoId, value: -1 });
+ expect(confirmations.at(-1).body).toMatchObject({ videoId });
+}
+
+async function expectHealthyRuntime(page, harness) {
+ expect(harness.backend.blockedRequests).toEqual([]);
+ expect(harness.consoleErrors).toEqual([]);
+ expect(harness.pageErrors).toEqual([]);
+ expect(await page.evaluate(() => globalThis.__unhandledRejections)).toEqual([]);
+}
+
+test("cold channel reload then immediate Short link initializes delayed Shorts controls once", async ({
+ context,
+ page,
+}) => {
+ const harness = await launchNavigationHarness({ context, page });
+
+ await page.reload({ waitUntil: "domcontentloaded" });
+ await injectGeneratedUserscript(page);
+ await page.locator("#channel-short").click();
+
+ await expect(page).toHaveURL((url) => url.pathname === `/shorts/${VIDEO_A}`);
+ const decoyDislikeCount = page.locator('[data-fixture-decoy-controls] [data-ryd-role="dislike"] #text');
+ await page.waitForTimeout(250);
+ await expect(decoyDislikeCount).toHaveText("");
+ await expect(page.locator("[data-ryd-synthetic-shorts-dislike]")).toHaveCount(0);
+ expect(harness.backend.requestsFor("GET", "/votes")).toHaveLength(0);
+
+ await expectShortInitialized(page, VIDEO_A);
+ await expectOneActivation(page, harness.backend, VIDEO_A);
+ expect(
+ harness.backend.requestsFor("GET", "/votes").filter((request) => request.query.videoId === VIDEO_A),
+ ).toHaveLength(1);
+ await expectHealthyRuntime(page, harness);
+});
+
+test("the live read-only prelude leaves one native Shorts Like activation", async ({ context, page }) => {
+ const harness = await launchNavigationHarness({ context, page });
+
+ await page.reload({ waitUntil: "domcontentloaded" });
+ await injectGeneratedUserscript(page);
+ await page.locator("#channel-short").click();
+ await expectShortInitialized(page, VIDEO_A);
+ await page.locator("#short-next").click();
+ await expectShortInitialized(page, VIDEO_B);
+ expect(harness.backend.requestsFor("POST", "/interact/vote")).toHaveLength(0);
+ expect(harness.backend.requestsFor("POST", "/interact/confirmVote")).toHaveLength(0);
+ await expectHealthyRuntime(page, harness);
+
+ const responsiveViewports = [
+ { height: 720, width: 1280 },
+ { height: 720, width: 768 },
+ { height: 844, width: 390 },
+ ];
+ for (const [index, viewport] of responsiveViewports.entries()) {
+ await page.setViewportSize(viewport);
+ await openNavigationFixture(page, { pageKind: "watch", videoId: VIDEO_A });
+ await injectGeneratedUserscript(page);
+ await expectWatchInitialized(page, VIDEO_A);
+ if (index === 0) {
+ await page.reload({ waitUntil: "domcontentloaded" });
+ await injectGeneratedUserscript(page);
+ await expectWatchInitialized(page, VIDEO_A);
+ }
+
+ await openNavigationFixture(page, { pageKind: "shorts", videoId: VIDEO_A });
+ await injectGeneratedUserscript(page);
+ await expectShortInitialized(page, VIDEO_A);
+ expect(harness.backend.requestsFor("POST", "/interact/vote")).toHaveLength(0);
+ expect(harness.backend.requestsFor("POST", "/interact/confirmVote")).toHaveLength(0);
+ await expectHealthyRuntime(page, harness);
+ }
+
+ await page.setViewportSize({ height: 720, width: 1280 });
+ await openNavigationFixture(page, { pageKind: "watch", videoId: VIDEO_A });
+ await injectGeneratedUserscript(page);
+ await expectWatchInitialized(page, VIDEO_A);
+ await page.locator("#watch-next").click();
+ await expectWatchInitialized(page, VIDEO_B);
+ expect(harness.backend.requestsFor("POST", "/interact/vote")).toHaveLength(0);
+ expect(harness.backend.requestsFor("POST", "/interact/confirmVote")).toHaveLength(0);
+ await expectHealthyRuntime(page, harness);
+
+ await openShortsFixture(page, VIDEO_A);
+ await injectGeneratedUserscript(page);
+ const finalShort = page.locator(`[data-short-video="${VIDEO_A}"]:not([hidden])`);
+ const nativeLike = finalShort.locator('[data-ryd-role="like"] button');
+ await expect(finalShort.locator("[data-ryd-synthetic-shorts-dislike] #text")).toHaveText(
+ String(COUNTS[VIDEO_A].dislikes),
+ );
+ await expect(nativeLike).toHaveAttribute("aria-pressed", "false");
+ expect(harness.backend.requestsFor("POST", "/interact/vote")).toHaveLength(0);
+ expect(harness.backend.requestsFor("POST", "/interact/confirmVote")).toHaveLength(0);
+
+ await nativeLike.click();
+ await expect.poll(() => harness.backend.requestsFor("POST", "/interact/confirmVote").length).toBe(1);
+ await page.waitForTimeout(600);
+
+ const votes = harness.backend.requestsFor("POST", "/interact/vote");
+ const confirmations = harness.backend.requestsFor("POST", "/interact/confirmVote");
+ expect(votes).toHaveLength(1);
+ expect(confirmations).toHaveLength(1);
+ expect(votes[0].body).toEqual({ userId: EXISTING_CREDENTIALS.userId, videoId: VIDEO_A, value: 1 });
+ expect(confirmations[0].body).toMatchObject({ userId: EXISTING_CREDENTIALS.userId, videoId: VIDEO_A });
+ await expect(nativeLike).toHaveAttribute("aria-pressed", "true");
+ await expect(finalShort.locator("[data-ryd-synthetic-shorts-dislike] button")).toHaveAttribute(
+ "aria-pressed",
+ "false",
+ );
+ await expectHealthyRuntime(page, harness);
+});
+
+test("inactive sibling identity churn cannot restart cold channel-to-Short hydration", async ({ context, page }) => {
+ const harness = await launchNavigationHarness(
+ { context, page },
+ {},
+ {
+ beforeInject: (fixturePage) =>
+ fixturePage.evaluate((syntheticStateKey) => {
+ const originalGetValue = globalThis.GM.getValue;
+ let releaseRead;
+ const readGate = new Promise((resolve) => {
+ releaseRead = resolve;
+ });
+ const state = {
+ count: 0,
+ release() {
+ releaseRead();
+ },
+ };
+ const delayedGetValue = async (key, fallbackValue) => {
+ if (key === syntheticStateKey) {
+ state.count += 1;
+ await readGate;
+ }
+ return originalGetValue(key, fallbackValue);
+ };
+ globalThis.__fixtureSyntheticReadGate = state;
+ globalThis.GM.getValue = delayedGetValue;
+ globalThis.GM_getValue = delayedGetValue;
+ }, `rydSyntheticDislikedShort:${VIDEO_A}`),
+ },
+ );
+
+ await page.locator("#channel-short").click();
+ await expect(page).toHaveURL((url) => url.pathname === `/shorts/${VIDEO_A}`);
+ await expect.poll(() => page.evaluate(() => globalThis.__fixtureSyntheticReadGate.count)).toBe(1);
+
+ for (let sequence = 0; sequence < 20; sequence += 1) {
+ await page.evaluate((value) => window.__navigationFixture.churnInactiveDesktopShortIdentity(value), sequence);
+ await page.waitForTimeout(10);
+ }
+ await expect(page.locator(`ytd-reel-video-renderer[video-id="${VIDEO_A}"][is-active]`)).toBeVisible();
+ await expect(page.locator("ytd-reel-video-renderer:not([is-active])")).toBeHidden();
+ expect(
+ harness.backend.requestsFor("GET", "/votes").filter((request) => request.query.videoId === VIDEO_A),
+ ).toHaveLength(1);
+
+ await page.evaluate(() => globalThis.__fixtureSyntheticReadGate.release());
+ await expectShortInitialized(page, VIDEO_A);
+ await page.waitForTimeout(600);
+
+ expect(await page.evaluate(() => globalThis.__fixtureSyntheticReadGate.count)).toBe(1);
+ expect(
+ harness.backend.requestsFor("GET", "/votes").filter((request) => request.query.videoId === VIDEO_A),
+ ).toHaveLength(1);
+ await expect(page.locator("[data-ryd-synthetic-shorts-dislike]:visible")).toHaveCount(1);
+ await expectHealthyRuntime(page, harness);
+});
+
+test("channel video link initializes delayed watch controls and one ratio bar", async ({ context, page }) => {
+ const harness = await launchNavigationHarness({ context, page });
+
+ await page.locator("#channel-watch").click();
+
+ await expectWatchInitialized(page, VIDEO_A);
+ await expectOneActivation(page, harness.backend, VIDEO_A);
+ await expectWatchInitialized(page, VIDEO_A, COUNTS[VIDEO_A].dislikes + 1);
+ await expectHealthyRuntime(page, harness);
+});
+
+test("rendered fixed watch controls initialize without an offset parent", async ({ context, page }) => {
+ const harness = await launchNavigationHarness(
+ { context, page },
+ { pageKind: "watch", videoId: VIDEO_A },
+ {
+ beforeInject: async (fixturePage) => {
+ await fixturePage.evaluate(() => {
+ const buttons = document.querySelector('[data-fixture-page-kind="watch"] #top-level-buttons-computed');
+ if (!buttons) throw new Error("The fixed-controls fixture has no watch buttons.");
+ buttons.style.left = "20px";
+ buttons.style.setProperty("position", "fixed", "important");
+ buttons.style.top = "120px";
+ const rect = buttons.getBoundingClientRect();
+ if (buttons.offsetParent !== null || rect.width <= 0 || rect.height <= 0) {
+ throw new Error("The fixed-controls fixture did not reproduce positive geometry without offsetParent.");
+ }
+ });
+ },
+ },
+ );
+
+ await expectWatchInitialized(page, VIDEO_A);
+ await expect(page.locator('[data-fixture-page-kind="watch"] #top-level-buttons-computed')).toHaveCSS(
+ "position",
+ "fixed",
+ );
+ await expectHealthyRuntime(page, harness);
+});
+
+test("watch to Short link switches page kind, identity, and controls", async ({ context, page }) => {
+ const harness = await launchNavigationHarness({ context, page }, { pageKind: "watch", videoId: VIDEO_A });
+ await expectWatchInitialized(page, VIDEO_A);
+
+ await page.locator("#watch-to-short").click();
+
+ await expect(page).toHaveURL((url) => url.pathname === `/shorts/${VIDEO_B}`);
+ await expect(page.locator('[data-fixture-page-kind="watch"] [data-ryd-role="dislike"] #text')).toHaveText("11");
+ await page.waitForTimeout(50);
+ await expect(page.locator('[data-fixture-page-kind="watch"] [data-ryd-role="dislike"] #text')).toHaveText("11");
+ await expect(page.locator("[data-ryd-synthetic-shorts-dislike]")).toHaveCount(0);
+
+ await expectShortInitialized(page, VIDEO_B);
+ await expectOneActivation(page, harness.backend, VIDEO_B);
+ await expectShortInitialized(page, VIDEO_B, COUNTS[VIDEO_B].dislikes + 1);
+ await expectHealthyRuntime(page, harness);
+});
+
+test("Short to watch link removes Shorts control and initializes the target bar", async ({ context, page }) => {
+ const harness = await launchNavigationHarness({ context, page }, { pageKind: "shorts", videoId: VIDEO_A });
+ await expectShortInitialized(page, VIDEO_A);
+
+ await page.locator("#short-to-watch").click();
+
+ await expectWatchInitialized(page, VIDEO_B);
+ await expectOneActivation(page, harness.backend, VIDEO_B);
+ await expectWatchInitialized(page, VIDEO_B, COUNTS[VIDEO_B].dislikes + 1);
+ await expectHealthyRuntime(page, harness);
+});
+
+test("Short next navigation activates one preloaded sibling control", async ({ context, page }) => {
+ const harness = await launchNavigationHarness({ context, page }, { pageKind: "shorts", videoId: VIDEO_A });
+ await expectShortInitialized(page, VIDEO_A);
+ const preloadedNextRenderer = page.locator(`ytd-reel-video-renderer[video-id="${VIDEO_B}"]`);
+ await expect(preloadedNextRenderer).toBeHidden();
+ await preloadedNextRenderer.evaluate((renderer) => renderer.setAttribute("data-fixture-preloaded-marker", "true"));
+
+ await page.locator("#short-next").click();
+
+ await expectShortInitialized(page, VIDEO_B);
+ await expect(page.locator(`ytd-reel-video-renderer[video-id="${VIDEO_B}"][is-active]`)).toHaveAttribute(
+ "data-fixture-preloaded-marker",
+ "true",
+ );
+ await expectOneActivation(page, harness.backend, VIDEO_B);
+ await expectShortInitialized(page, VIDEO_B, COUNTS[VIDEO_B].dislikes + 1);
+ await expectHealthyRuntime(page, harness);
+});
+
+test("delayed Short navigation never retags or submits the outgoing reel as the target video", async ({
+ context,
+ page,
+}) => {
+ const harness = await launchNavigationHarness({ context, page }, { pageKind: "shorts", videoId: VIDEO_A });
+ await expectShortInitialized(page, VIDEO_A);
+ const outgoingRenderer = page.locator(`ytd-reel-video-renderer[video-id="${VIDEO_A}"][is-active]`);
+
+ await page.evaluate((videoId) => window.__navigationFixture.navigateDelayedShort(videoId), VIDEO_B);
+
+ await expect(page).toHaveURL((url) => url.pathname === `/shorts/${VIDEO_B}`);
+ await expect(outgoingRenderer).toBeVisible();
+ await expect(outgoingRenderer).toHaveAttribute("video-id", VIDEO_A);
+ await expect(outgoingRenderer.locator(`a[href="/shorts/${VIDEO_B}"]`)).toHaveCount(1);
+ await expect(page.locator(`ytd-reel-video-renderer[video-id="${VIDEO_B}"]`)).toHaveCount(0);
+ await page.waitForTimeout(600);
+ await expect(outgoingRenderer).toBeVisible();
+ await expect(outgoingRenderer.locator(`[data-ryd-video-id="${VIDEO_B}"]`)).toHaveCount(0);
+ await expect(outgoingRenderer.locator("[data-ryd-synthetic-shorts-dislike] #text")).toHaveText("11");
+ await outgoingRenderer.locator("[data-ryd-synthetic-shorts-dislike] button").click();
+ await page.waitForTimeout(100);
+ expect(harness.backend.requestsFor("POST", "/interact/vote")).toHaveLength(0);
+ expect(harness.backend.requestsFor("POST", "/interact/confirmVote")).toHaveLength(0);
+
+ await page.evaluate(() => window.__navigationFixture.finishDelayedNavigation());
+ await expectShortInitialized(page, VIDEO_B);
+ await expectOneActivation(page, harness.backend, VIDEO_B);
+ expect(
+ harness.backend.requestsFor("GET", "/votes").filter((request) => request.query.videoId === VIDEO_B),
+ ).toHaveLength(1);
+ await expectHealthyRuntime(page, harness);
+});
+
+test("delayed mobile Short navigation never binds the outgoing renderer to the target video", async ({
+ context,
+ page,
+}) => {
+ const harness = await launchNavigationHarness(
+ { context, page },
+ { hostname: "m.youtube.com", pageKind: "shorts", videoId: VIDEO_A },
+ );
+ await expectMobileShortInitialized(page, VIDEO_A);
+ const outgoingRenderer = page.locator(`ytm-reel-video-renderer[video-id="${VIDEO_A}"][is-active]`);
+
+ await page.evaluate((videoId) => window.__navigationFixture.navigateDelayedShort(videoId), VIDEO_B);
+
+ await expect(page).toHaveURL((url) => url.pathname === `/shorts/${VIDEO_B}`);
+ await expect(outgoingRenderer).toBeVisible();
+ await expect(outgoingRenderer).toHaveAttribute("video-id", VIDEO_A);
+ await expect(outgoingRenderer.locator("ytm-reel-player-overlay-renderer:not([video-id])")).toHaveCount(1);
+ await expect(outgoingRenderer.locator(`a[href="/shorts/${VIDEO_B}"]`)).toHaveCount(1);
+ await expect(page.locator(`ytm-reel-video-renderer[video-id="${VIDEO_B}"]`)).toHaveCount(0);
+ await page.waitForTimeout(600);
+ await expect(outgoingRenderer.locator('[data-ryd-role="dislike"] #text')).toHaveText("11");
+ await outgoingRenderer.locator('[data-ryd-role="dislike"] button').click();
+ await page.waitForTimeout(100);
+ expect(
+ harness.backend.requestsFor("GET", "/votes").filter((request) => request.query.videoId === VIDEO_B),
+ ).toHaveLength(0);
+ expect(harness.backend.requestsFor("POST", "/interact/vote")).toHaveLength(0);
+ expect(harness.backend.requestsFor("POST", "/interact/confirmVote")).toHaveLength(0);
+
+ await page.evaluate(() => window.__navigationFixture.finishDelayedNavigation());
+ await expectMobileShortInitialized(page, VIDEO_B);
+ await expectOneActivation(page, harness.backend, VIDEO_B);
+ await expectMobileShortInitialized(page, VIDEO_B, COUNTS[VIDEO_B].dislikes + 1);
+ expect(
+ harness.backend.requestsFor("GET", "/votes").filter((request) => request.query.videoId === VIDEO_B),
+ ).toHaveLength(1);
+ await expectHealthyRuntime(page, harness);
+});
+
+test("a metadata-free Short action bar keeps its original fallback ownership across a delayed route", async ({
+ context,
+ page,
+}) => {
+ const harness = await launchNavigationHarness(
+ { context, page },
+ { pageKind: "shorts", videoId: VIDEO_A },
+ {
+ beforeInject: (fixturePage) =>
+ fixturePage.evaluate(() => window.__navigationFixture.anonymizeActiveDesktopShort()),
+ },
+ );
+ const outgoingRenderer = page.locator("ytd-reel-video-renderer[is-active]:not([video-id])");
+ const outgoingDislike = outgoingRenderer.locator("[data-ryd-synthetic-shorts-dislike]");
+ await expect(outgoingRenderer).toBeVisible();
+ await expect(outgoingRenderer.locator('a[href*="/shorts/"]')).toHaveCount(0);
+ await expect(outgoingDislike).toHaveAttribute("data-ryd-video-id", VIDEO_A);
+ await expect(outgoingDislike.locator("#text")).toHaveText("11");
+ await expect(outgoingDislike.locator("button")).toBeEnabled();
+
+ await page.evaluate((videoId) => window.__navigationFixture.navigateDelayedAnonymousShort(videoId), VIDEO_B);
+
+ await expect(page).toHaveURL((url) => url.pathname === `/shorts/${VIDEO_B}`);
+ await page.waitForTimeout(600);
+ await expect(outgoingRenderer).toBeVisible();
+ await expect(outgoingRenderer.locator(`[data-ryd-video-id="${VIDEO_B}"]`)).toHaveCount(0);
+ await expect(outgoingDislike).toHaveAttribute("data-ryd-video-id", VIDEO_A);
+ await outgoingDislike.locator("button").click();
+ await page.waitForTimeout(100);
+ expect(
+ harness.backend.requestsFor("GET", "/votes").filter((request) => request.query.videoId === VIDEO_B),
+ ).toHaveLength(0);
+ expect(harness.backend.requestsFor("POST", "/interact/vote")).toHaveLength(0);
+
+ await page.evaluate(() => window.__navigationFixture.finishDelayedNavigation());
+ await expectShortInitialized(page, VIDEO_B);
+ await expectOneActivation(page, harness.backend, VIDEO_B);
+ expect(
+ harness.backend.requestsFor("GET", "/votes").filter((request) => request.query.videoId === VIDEO_B),
+ ).toHaveLength(1);
+ await expectHealthyRuntime(page, harness);
+});
+
+test("a descendant Shorts path is not accepted as the current video identity", async ({ context, page }) => {
+ const harness = await launchNavigationHarness({ context, page }, { pageKind: "shorts", videoId: VIDEO_A });
+ await expectShortInitialized(page, VIDEO_A);
+ const activeRenderer = page.locator(`ytd-reel-video-renderer[video-id="${VIDEO_A}"][is-active]`);
+
+ await page.evaluate((videoId) => window.__navigationFixture.navigateToShortsDescendant(videoId), VIDEO_A);
+
+ await expect(page).toHaveURL((url) => url.pathname === `/shorts/${VIDEO_A}/extra`);
+ await page.waitForTimeout(600);
+ await expect(activeRenderer.locator(`[data-ryd-video-id="${VIDEO_A}/extra"]`)).toHaveCount(0);
+ expect(
+ harness.backend.requestsFor("GET", "/votes").filter((request) => request.query.videoId === `${VIDEO_A}/extra`),
+ ).toHaveLength(0);
+ await expectHealthyRuntime(page, harness);
+});
+
+test("Short autoplay activates a preloaded sibling reel without a navigation event", async ({ context, page }) => {
+ const harness = await launchNavigationHarness({ context, page }, { pageKind: "shorts", videoId: VIDEO_A });
+ await expectShortInitialized(page, VIDEO_A);
+
+ await page.evaluate(() => window.__navigationFixture.dispatchEnded());
+
+ await expectShortInitialized(page, VIDEO_B);
+ await expectOneActivation(page, harness.backend, VIDEO_B);
+ await expectShortInitialized(page, VIDEO_B, COUNTS[VIDEO_B].dislikes + 1);
+ expect(
+ harness.backend.requestsFor("GET", "/votes").filter((request) => request.query.videoId === VIDEO_B),
+ ).toHaveLength(1);
+ await expectHealthyRuntime(page, harness);
+});
+
+test("watch next navigation replaces controls and leaves one target bar", async ({ context, page }) => {
+ const harness = await launchNavigationHarness({ context, page }, { pageKind: "watch", videoId: VIDEO_A });
+ await expectWatchInitialized(page, VIDEO_A);
+
+ await page.locator("#watch-next").click();
+
+ await expectWatchInitialized(page, VIDEO_B);
+ await expectOneActivation(page, harness.backend, VIDEO_B);
+ await expectWatchInitialized(page, VIDEO_B, COUNTS[VIDEO_B].dislikes + 1);
+ await expectHealthyRuntime(page, harness);
+});
+
+test("watch SPA navigation refreshes one aligned ratio bar with the target video's data", async ({ context, page }) => {
+ const harness = await launchNavigationHarness({ context, page }, { pageKind: "watch", videoId: VIDEO_A });
+ await expectWatchInitialized(page, VIDEO_A);
+ await expect(page.locator("#ryd-dislike-tooltip")).toContainText("100 / 11");
+
+ await page.evaluate(
+ ({ fromVideoId, toVideoId }) => {
+ const outgoingTopRow = document.querySelector('[data-fixture-page-kind="watch"] #top-row');
+ if (!outgoingTopRow?.querySelector("#return-youtube-dislike-bar-container")) {
+ throw new Error("The outgoing watch reaction tree has no initialized ratio bar to retain.");
+ }
+ const retainedOutgoingTree = document.createElement("div");
+ retainedOutgoingTree.hidden = true;
+ retainedOutgoingTree.setAttribute("data-fixture-retained-watch-video-id", fromVideoId);
+ retainedOutgoingTree.appendChild(outgoingTopRow);
+ document.body.appendChild(retainedOutgoingTree);
+ window.__navigationFixture.navigate("watch", toVideoId);
+ },
+ { fromVideoId: VIDEO_A, toVideoId: VIDEO_B },
+ );
+
+ await expectWatchInitialized(page, VIDEO_B);
+ const retainedOutgoingTree = page.locator(`[data-fixture-retained-watch-video-id="${VIDEO_A}"]`);
+ const currentControls = page.locator(`[data-fixture-control-video-id="${VIDEO_B}"]`);
+ const currentReactionRegion = currentControls.locator("xpath=..");
+ const wrapper = currentReactionRegion.locator(":scope > .ryd-tooltip");
+ const container = wrapper.locator("#return-youtube-dislike-bar-container");
+ const bar = container.locator("#return-youtube-dislike-bar");
+ const tooltip = wrapper.locator("#ryd-dislike-tooltip");
+
+ await expect(page.locator(".ryd-tooltip")).toHaveCount(1);
+ await expect(page.locator("#return-youtube-dislike-bar-container")).toHaveCount(1);
+ await expect(page.locator("#return-youtube-dislike-bar")).toHaveCount(1);
+ await expect(page.locator("#ryd-dislike-tooltip")).toHaveCount(1);
+ await expect(retainedOutgoingTree).toHaveCount(1);
+ await expect(retainedOutgoingTree).toBeHidden();
+ await expect(retainedOutgoingTree.locator(".ryd-tooltip")).toHaveCount(0);
+ await expect(retainedOutgoingTree.locator("#return-youtube-dislike-bar-container")).toHaveCount(0);
+ await expect(wrapper).toBeVisible();
+ await expect(container).toBeVisible();
+ await expect(bar).toBeVisible();
+ await expect(currentControls.locator('[data-ryd-role="dislike"] #text')).toHaveText("22");
+ await expect(tooltip).toContainText("200 / 22");
+ await expect(tooltip).toBeHidden();
+
+ const geometry = await currentReactionRegion.evaluate((reactionRegion) => {
+ const readBox = (element) => {
+ const box = element.getBoundingClientRect();
+ return {
+ bottom: box.bottom,
+ height: box.height,
+ left: box.left,
+ right: box.right,
+ top: box.top,
+ width: box.width,
+ };
+ };
+ const controls = reactionRegion.querySelector("[data-fixture-control-video-id]");
+ const like = controls.querySelector('[data-ryd-role="like"] button');
+ const dislike = controls.querySelector('[data-ryd-role="dislike"] button');
+ const ratioWrapper = reactionRegion.querySelector(":scope > .ryd-tooltip");
+ const ratioContainer = ratioWrapper.querySelector("#return-youtube-dislike-bar-container");
+ const ratioFill = ratioContainer.querySelector("#return-youtube-dislike-bar");
+ return {
+ bar: readBox(ratioFill),
+ container: readBox(ratioContainer),
+ dislike: readBox(dislike),
+ like: readBox(like),
+ wrapper: readBox(ratioWrapper),
+ };
+ });
+
+ expect(geometry.wrapper.width).toBeCloseTo(geometry.like.width + geometry.dislike.width, 0);
+ expect(geometry.container.width).toBeCloseTo(geometry.wrapper.width, 0);
+ expect(geometry.container.top).toBeGreaterThanOrEqual(Math.max(geometry.like.bottom, geometry.dislike.bottom) - 1);
+ expect(geometry.bar.left).toBeGreaterThanOrEqual(geometry.container.left - 1);
+ expect(geometry.bar.right).toBeLessThanOrEqual(geometry.container.right + 1);
+ expect(geometry.bar.width / geometry.container.width).toBeCloseTo(200 / (200 + 22), 2);
+
+ await wrapper.hover({ position: { x: 1, y: 1 } });
+ await expect(tooltip).toBeVisible();
+ await expect(tooltip).toContainText("200 / 22");
+ expect(
+ harness.backend.requestsFor("GET", "/votes").filter((request) => request.query.videoId === VIDEO_B),
+ ).toHaveLength(1);
+ await expectHealthyRuntime(page, harness);
+});
+
+test("a pruned current watch ratio bar is restored once with the current video's data", async ({ context, page }) => {
+ const harness = await launchNavigationHarness({ context, page }, { pageKind: "watch", videoId: VIDEO_A });
+ await expectWatchInitialized(page, VIDEO_A);
+
+ await page.locator("#watch-next").click();
+
+ await expectWatchInitialized(page, VIDEO_B);
+ const currentControls = page.locator(`[data-fixture-control-video-id="${VIDEO_B}"]`);
+ const currentReactionRegion = currentControls.locator("xpath=..");
+ const wrapper = currentReactionRegion.locator(":scope > .ryd-tooltip");
+ const container = wrapper.locator("#return-youtube-dislike-bar-container");
+ const bar = container.locator("#return-youtube-dislike-bar");
+ const tooltip = wrapper.locator("#ryd-dislike-tooltip");
+ const countRequestsBeforePrune = harness.backend.requestsFor("GET", "/votes").length;
+
+ await expect(wrapper).toHaveCount(1);
+ await expect(tooltip).toContainText("200 / 22");
+ await currentReactionRegion.evaluate((reactionRegion) => {
+ const mutationStats = { addedWrappers: 0, removedWrappers: 0 };
+ const observer = new MutationObserver((mutations) => {
+ for (const mutation of mutations) {
+ for (const node of mutation.addedNodes) {
+ if (node instanceof Element && node.matches(".ryd-tooltip")) mutationStats.addedWrappers += 1;
+ }
+ for (const node of mutation.removedNodes) {
+ if (node instanceof Element && node.matches(".ryd-tooltip")) mutationStats.removedWrappers += 1;
+ }
+ }
+ });
+ observer.observe(reactionRegion, { childList: true });
+ globalThis.__fixtureWatchBarMutationObserver = observer;
+ globalThis.__fixtureWatchBarMutationStats = mutationStats;
+
+ const currentWrapper = reactionRegion.querySelector(":scope > .ryd-tooltip");
+ if (!currentWrapper) throw new Error("The current watch reaction tree has no ratio bar to prune.");
+ globalThis.__fixturePrunedWatchBar = currentWrapper;
+ currentWrapper.remove();
+ });
+
+ await expect(wrapper).toHaveCount(1);
+ await expect(wrapper).toBeVisible();
+ await expect(container).toBeVisible();
+ await expect(bar).toBeVisible();
+ await expect(currentControls.locator('[data-ryd-role="dislike"] #text')).toHaveText("22");
+ await expect(tooltip).toContainText("200 / 22");
+ await expect(tooltip).toBeHidden();
+ expect(await wrapper.evaluate((restoredWrapper) => restoredWrapper !== globalThis.__fixturePrunedWatchBar)).toBe(
+ true,
+ );
+ expect(
+ await bar.evaluate((fill) => fill.getBoundingClientRect().width / fill.parentElement.getBoundingClientRect().width),
+ ).toBeCloseTo(200 / (200 + 22), 2);
+
+ await page.waitForTimeout(600);
+ const mutationStats = await page.evaluate(() => {
+ globalThis.__fixtureWatchBarMutationObserver.disconnect();
+ return globalThis.__fixtureWatchBarMutationStats;
+ });
+ expect(mutationStats).toEqual({ addedWrappers: 1, removedWrappers: 1 });
+ await expect(page.locator(".ryd-tooltip")).toHaveCount(1);
+ await expect(page.locator("#return-youtube-dislike-bar-container")).toHaveCount(1);
+ await expect(page.locator("#return-youtube-dislike-bar")).toHaveCount(1);
+ await expect(page.locator("#ryd-dislike-tooltip")).toHaveCount(1);
+ expect(harness.backend.requestsFor("GET", "/votes")).toHaveLength(countRequestsBeforePrune);
+ await expectHealthyRuntime(page, harness);
+});
+
+test("same-video whole watch action replacement rehydrates without navigation or refetch", async ({
+ context,
+ page,
+}) => {
+ const harness = await launchNavigationHarness({ context, page }, { pageKind: "watch", videoId: VIDEO_B });
+ await expectWatchInitialized(page, VIDEO_B);
+ const countRequestsBeforeReplacement = harness.backend
+ .requestsFor("GET", "/votes")
+ .filter((request) => request.query.videoId === VIDEO_B).length;
+
+ expect(await page.evaluate(() => window.__navigationFixture.replaceCurrentWatchActions())).toBe(true);
+
+ const replacement = page.locator(`#top-level-buttons-computed[data-fixture-watch-actions-replacement="${VIDEO_B}"]`);
+ await expect(replacement).toHaveCount(1);
+ await expectWatchInitialized(page, VIDEO_B);
+ await expect(replacement.locator(":scope > .ryd-tooltip")).toHaveCount(1);
+ await expect(replacement.locator("#ryd-dislike-tooltip")).toContainText("200 / 22");
+ expect(await page.evaluate(() => !globalThis.__fixtureReplacedWatchActions.isConnected)).toBe(true);
+
+ await page.waitForTimeout(600);
+ await expect(page.locator(".ryd-tooltip")).toHaveCount(1);
+ await expect(page.locator("#return-youtube-dislike-bar-container")).toHaveCount(1);
+ await expect(page.locator("#return-youtube-dislike-bar")).toHaveCount(1);
+ await expect(page.locator("#ryd-dislike-tooltip")).toHaveCount(1);
+ expect(
+ harness.backend.requestsFor("GET", "/votes").filter((request) => request.query.videoId === VIDEO_B),
+ ).toHaveLength(countRequestsBeforeReplacement);
+
+ await expectOneActivation(page, harness.backend, VIDEO_B);
+ await expectWatchInitialized(page, VIDEO_B, COUNTS[VIDEO_B].dislikes + 1);
+ expect(
+ harness.backend.requestsFor("GET", "/votes").filter((request) => request.query.videoId === VIDEO_B),
+ ).toHaveLength(countRequestsBeforeReplacement);
+ await expectHealthyRuntime(page, harness);
+});
+
+test("sidebar watch navigation survives a retained settling action-container replacement", async ({
+ context,
+ page,
+}) => {
+ const harness = await launchNavigationHarness({ context, page }, { pageKind: "watch", videoId: VIDEO_A });
+ await expectWatchInitialized(page, VIDEO_A);
+
+ await page.locator("#watch-related").click();
+ await expectWatchInitialized(page, VIDEO_B);
+ const countRequestsBeforeReplacement = harness.backend
+ .requestsFor("GET", "/votes")
+ .filter((request) => request.query.videoId === VIDEO_B).length;
+
+ expect(
+ await page.evaluate(() => window.__navigationFixture.replaceCurrentWatchActions({ retainOutgoing: true })),
+ ).toBe(true);
+
+ const replacement = page.locator(`#top-level-buttons-computed[data-fixture-watch-actions-replacement="${VIDEO_B}"]`);
+ const retained = page.locator(`[data-fixture-retained-settling-watch-actions="${VIDEO_B}"]`);
+ await expect(replacement).toHaveCount(1);
+ await expect(retained).toHaveCount(1);
+ await expect(retained).toBeHidden();
+ await expectWatchInitialized(page, VIDEO_B);
+ await expect(replacement.locator(":scope > .ryd-tooltip")).toHaveCount(1);
+ await expect(replacement.locator("#ryd-dislike-tooltip")).toContainText("200 / 22");
+ await expect(retained.locator(".ryd-tooltip")).toHaveCount(0);
+ await expect(retained.locator("#return-youtube-dislike-bar-container")).toHaveCount(0);
+ await expect(retained.locator("#return-youtube-dislike-bar")).toHaveCount(0);
+ await expect(retained.locator("#ryd-dislike-tooltip")).toHaveCount(0);
+
+ await page.waitForTimeout(600);
+ await expect(page.locator(".ryd-tooltip")).toHaveCount(1);
+ await expect(page.locator("#return-youtube-dislike-bar-container")).toHaveCount(1);
+ await expect(page.locator("#return-youtube-dislike-bar")).toHaveCount(1);
+ await expect(page.locator("#ryd-dislike-tooltip")).toHaveCount(1);
+ expect(
+ harness.backend.requestsFor("GET", "/votes").filter((request) => request.query.videoId === VIDEO_B),
+ ).toHaveLength(countRequestsBeforeReplacement);
+
+ await expectOneActivation(page, harness.backend, VIDEO_B);
+ await expectWatchInitialized(page, VIDEO_B, COUNTS[VIDEO_B].dislikes + 1);
+ expect(
+ harness.backend.requestsFor("GET", "/votes").filter((request) => request.query.videoId === VIDEO_B),
+ ).toHaveLength(countRequestsBeforeReplacement);
+ await expectHealthyRuntime(page, harness);
+});
+
+test("watch autoplay initializes replaced controls without a navigation event", async ({ context, page }) => {
+ const harness = await launchNavigationHarness({ context, page }, { pageKind: "watch", videoId: VIDEO_A });
+ await expectWatchInitialized(page, VIDEO_A);
+
+ await page.evaluate(() => window.__navigationFixture.dispatchEnded());
+
+ await expectWatchInitialized(page, VIDEO_B);
+ await expectOneActivation(page, harness.backend, VIDEO_B);
+ await expectWatchInitialized(page, VIDEO_B, COUNTS[VIDEO_B].dislikes + 1);
+ expect(
+ harness.backend.requestsFor("GET", "/votes").filter((request) => request.query.videoId === VIDEO_B),
+ ).toHaveLength(1);
+ await expectHealthyRuntime(page, harness);
+});
+
+test("delayed watch navigation never binds outgoing controls to the target video", async ({ context, page }) => {
+ const harness = await launchNavigationHarness({ context, page }, { pageKind: "watch", videoId: VIDEO_A });
+ await expectWatchInitialized(page, VIDEO_A);
+ const outgoingControls = page.locator(`[data-fixture-control-video-id="${VIDEO_A}"]`);
+
+ await page.evaluate((videoId) => window.__navigationFixture.navigateDelayedWatch(videoId), VIDEO_B);
+
+ await expect(page).toHaveURL((url) => url.pathname === "/watch" && url.searchParams.get("v") === VIDEO_B);
+ await expect(page.locator(`ytd-watch-flexy[video-id="${VIDEO_B}"]`)).toHaveCount(1);
+ await expect(outgoingControls).toBeVisible();
+ await page.evaluate(() => window.__navigationFixture.mutateOutgoingWatchDescendant());
+ await expect(outgoingControls.locator('[data-fixture-irrelevant-watch-count-mutation="true"]')).toHaveCount(1);
+ await expect(page.locator('[data-fixture-irrelevant-watch-tooltip-mutation="true"]')).toHaveCount(1);
+ await page.waitForTimeout(600);
+ await expect(outgoingControls.locator('[data-ryd-role="dislike"] #text')).toHaveText("11");
+ await outgoingControls.locator('[data-ryd-role="dislike"] button').click();
+ await page.waitForTimeout(100);
+ expect(
+ harness.backend.requestsFor("GET", "/votes").filter((request) => request.query.videoId === VIDEO_B),
+ ).toHaveLength(0);
+ expect(harness.backend.requestsFor("POST", "/interact/vote")).toHaveLength(0);
+ expect(harness.backend.requestsFor("POST", "/interact/confirmVote")).toHaveLength(0);
+
+ const outgoingDislike = outgoingControls.locator('[data-ryd-role="dislike"]');
+ await page.evaluate(() => window.__navigationFixture.replaceDelayedWatchControl("like"));
+ await expect(outgoingControls.locator('[data-fixture-watch-replacement="like"]')).toHaveCount(1);
+ await expect(outgoingDislike).toBeVisible();
+ await page.waitForTimeout(600);
+ await outgoingDislike.locator("button").click();
+ await page.waitForTimeout(100);
+ expect(
+ harness.backend.requestsFor("GET", "/votes").filter((request) => request.query.videoId === VIDEO_B),
+ ).toHaveLength(0);
+ expect(harness.backend.requestsFor("POST", "/interact/vote")).toHaveLength(0);
+
+ await page.evaluate(() => window.__navigationFixture.replaceDelayedWatchControl("dislike"));
+ await expect(outgoingControls.locator('[data-fixture-watch-replacement="dislike"]')).toHaveCount(1);
+ await expectWatchInitialized(page, VIDEO_B);
+ await expectOneActivation(page, harness.backend, VIDEO_B);
+ await expectWatchInitialized(page, VIDEO_B, COUNTS[VIDEO_B].dislikes + 1);
+ expect(
+ harness.backend.requestsFor("GET", "/votes").filter((request) => request.query.videoId === VIDEO_B),
+ ).toHaveLength(1);
+ await expectHealthyRuntime(page, harness);
+});
+
+test("same-node watch reuse requires independent native refresh on both activation paths", async ({
+ context,
+ page,
+}) => {
+ const harness = await launchNavigationHarness({ context, page }, { pageKind: "watch", videoId: VIDEO_A });
+ await expectWatchInitialized(page, VIDEO_A);
+ const reusedControls = page.locator(`[data-fixture-control-video-id="${VIDEO_A}"]`);
+ const reusedLikeButton = reusedControls.locator('[data-ryd-role="like"] button');
+ const reusedDislikeButton = reusedControls.locator('[data-ryd-role="dislike"] button');
+ await reusedLikeButton.evaluate((button) => button.setAttribute("data-fixture-same-node", "like"));
+ await reusedDislikeButton.evaluate((button) => button.setAttribute("data-fixture-same-node", "dislike"));
+
+ await page.evaluate((videoId) => window.__navigationFixture.navigateDelayedWatch(videoId), VIDEO_B);
+ await page.evaluate(() => window.__navigationFixture.mutateOutgoingWatchDescendant());
+
+ await expect(page).toHaveURL((url) => url.pathname === "/watch" && url.searchParams.get("v") === VIDEO_B);
+ await page.waitForTimeout(600);
+ await expect(reusedControls.locator('[data-ryd-role="dislike"] #text')).toHaveText("11");
+ await reusedDislikeButton.click();
+ await page.waitForTimeout(100);
+ expect(
+ harness.backend.requestsFor("GET", "/votes").filter((request) => request.query.videoId === VIDEO_B),
+ ).toHaveLength(0);
+ expect(harness.backend.requestsFor("POST", "/interact/vote")).toHaveLength(0);
+
+ await page.evaluate((videoId) => window.__navigationFixture.refreshReusedWatchControl("like", videoId), VIDEO_B);
+ await expect(reusedLikeButton).toHaveAttribute("aria-label", `like refreshed for ${VIDEO_B}`);
+ await expect(reusedLikeButton).toHaveAttribute("data-fixture-same-node", "like");
+ await page.waitForTimeout(600);
+ await reusedDislikeButton.click();
+ await page.waitForTimeout(100);
+ expect(
+ harness.backend.requestsFor("GET", "/votes").filter((request) => request.query.videoId === VIDEO_B),
+ ).toHaveLength(0);
+ expect(harness.backend.requestsFor("POST", "/interact/vote")).toHaveLength(0);
+
+ await page.evaluate((videoId) => window.__navigationFixture.refreshReusedWatchControl("dislike", videoId), VIDEO_B);
+ await expect(reusedDislikeButton).toHaveAttribute("aria-label", `dislike refreshed for ${VIDEO_B}`);
+ await expect(reusedDislikeButton).toHaveAttribute("data-fixture-same-node", "dislike");
+ await expectWatchInitialized(page, VIDEO_B);
+ await expectOneActivation(page, harness.backend, VIDEO_B);
+ await expectWatchInitialized(page, VIDEO_B, COUNTS[VIDEO_B].dislikes + 1);
+ expect(
+ harness.backend.requestsFor("GET", "/votes").filter((request) => request.query.videoId === VIDEO_B),
+ ).toHaveLength(1);
+ expect(harness.backend.requestsFor("POST", "/interact/vote")).toHaveLength(1);
+ await expectHealthyRuntime(page, harness);
+});
+
+test("pre-navigation native drift cannot satisfy same-node watch refresh for the next video", async ({
+ context,
+ page,
+}) => {
+ const harness = await launchNavigationHarness({ context, page }, { pageKind: "watch", videoId: VIDEO_A });
+ await expectWatchInitialized(page, VIDEO_A);
+ const reusedControls = page.locator(`[data-fixture-control-video-id="${VIDEO_A}"]`);
+ const reusedLikeButton = reusedControls.locator('[data-ryd-role="like"] button');
+ const reusedDislikeButton = reusedControls.locator('[data-ryd-role="dislike"] button');
+ await reusedLikeButton.evaluate((button) => button.setAttribute("data-fixture-pre-navigation-node", "like"));
+ await reusedDislikeButton.evaluate((button) => button.setAttribute("data-fixture-pre-navigation-node", "dislike"));
+
+ await page.evaluate(
+ ({ videoId }) => {
+ window.__navigationFixture.driftWatchControlBeforeNavigation("like", videoId);
+ window.__navigationFixture.driftWatchControlBeforeNavigation("dislike", videoId);
+ },
+ { videoId: VIDEO_A },
+ );
+ await expect(reusedLikeButton).toHaveAttribute("aria-label", `like drift while ${VIDEO_A}`);
+ await expect(reusedDislikeButton).toHaveAttribute("aria-label", `dislike drift while ${VIDEO_A}`);
+ await page.waitForTimeout(600);
+
+ await page.evaluate((videoId) => window.__navigationFixture.navigateDelayedWatch(videoId), VIDEO_B);
+ await page.evaluate(() => window.__navigationFixture.mutateOutgoingWatchDescendant());
+
+ await expect(page).toHaveURL((url) => url.pathname === "/watch" && url.searchParams.get("v") === VIDEO_B);
+ await page.waitForTimeout(600);
+ await expect(reusedControls.locator('[data-ryd-role="dislike"] #text')).toHaveText("11");
+ await expect(page.locator("#ryd-dislike-tooltip")).toContainText("100 / 11");
+ await reusedDislikeButton.click();
+ await page.waitForTimeout(100);
+ expect(
+ harness.backend.requestsFor("GET", "/votes").filter((request) => request.query.videoId === VIDEO_B),
+ ).toHaveLength(0);
+ expect(harness.backend.requestsFor("POST", "/interact/vote")).toHaveLength(0);
+
+ await page.evaluate((videoId) => window.__navigationFixture.refreshReusedWatchControl("like", videoId), VIDEO_B);
+ await expect(reusedLikeButton).toHaveAttribute("aria-label", `like refreshed for ${VIDEO_B}`);
+ await expect(reusedLikeButton).toHaveAttribute("data-fixture-pre-navigation-node", "like");
+ await page.waitForTimeout(600);
+ expect(
+ harness.backend.requestsFor("GET", "/votes").filter((request) => request.query.videoId === VIDEO_B),
+ ).toHaveLength(0);
+
+ await page.evaluate((videoId) => window.__navigationFixture.refreshReusedWatchControl("dislike", videoId), VIDEO_B);
+ await expect(reusedDislikeButton).toHaveAttribute("aria-label", `dislike refreshed for ${VIDEO_B}`);
+ await expect(reusedDislikeButton).toHaveAttribute("data-fixture-pre-navigation-node", "dislike");
+ await expectWatchInitialized(page, VIDEO_B);
+ await expectOneActivation(page, harness.backend, VIDEO_B);
+ await expectWatchInitialized(page, VIDEO_B, COUNTS[VIDEO_B].dislikes + 1);
+ expect(
+ harness.backend.requestsFor("GET", "/votes").filter((request) => request.query.videoId === VIDEO_B),
+ ).toHaveLength(1);
+ expect(harness.backend.requestsFor("POST", "/interact/vote")).toHaveLength(1);
+ await expectHealthyRuntime(page, harness);
+});
+
+test("navigation-start snapshots same-node watch refreshes that occur before navigation finish", async ({
+ context,
+ page,
+}) => {
+ const harness = await launchNavigationHarness({ context, page }, { pageKind: "watch", videoId: VIDEO_A });
+ await expectWatchInitialized(page, VIDEO_A);
+ const reusedControls = page.locator(`[data-fixture-control-video-id="${VIDEO_A}"]`);
+ const reusedLikeButton = reusedControls.locator('[data-ryd-role="like"] button');
+ const reusedDislikeButton = reusedControls.locator('[data-ryd-role="dislike"] button');
+ await reusedLikeButton.evaluate((button) => button.setAttribute("data-fixture-pre-finish-node", "like"));
+ await reusedDislikeButton.evaluate((button) => button.setAttribute("data-fixture-pre-finish-node", "dislike"));
+
+ await page.evaluate(
+ ({ videoId }) => {
+ window.__navigationFixture.driftWatchControlBeforeNavigation("like", videoId);
+ window.__navigationFixture.driftWatchControlBeforeNavigation("dislike", videoId);
+ },
+ { videoId: VIDEO_A },
+ );
+ await page.waitForTimeout(600);
+
+ await page.evaluate((videoId) => window.__navigationFixture.beginSameNodeWatchNavigation(videoId), VIDEO_B);
+ await page.evaluate((videoId) => {
+ window.__navigationFixture.refreshReusedWatchControl("like", videoId);
+ window.__navigationFixture.refreshReusedWatchControl("dislike", videoId);
+ }, VIDEO_B);
+
+ await expect(page).toHaveURL((url) => url.pathname === "/watch" && url.searchParams.get("v") === VIDEO_B);
+ await expect(page.locator(`ytd-watch-flexy[video-id="${VIDEO_B}"]`)).toHaveCount(1);
+ await expect(reusedLikeButton).toHaveAttribute("aria-label", `like refreshed for ${VIDEO_B}`);
+ await expect(reusedDislikeButton).toHaveAttribute("aria-label", `dislike refreshed for ${VIDEO_B}`);
+ expect(
+ harness.backend.requestsFor("GET", "/votes").filter((request) => request.query.videoId === VIDEO_B),
+ ).toHaveLength(0);
+
+ await page.evaluate(() => window.__navigationFixture.finishSameNodeWatchNavigation());
+
+ await expectWatchInitialized(page, VIDEO_B);
+ await expect(reusedLikeButton).toHaveAttribute("data-fixture-pre-finish-node", "like");
+ await expect(reusedDislikeButton).toHaveAttribute("data-fixture-pre-finish-node", "dislike");
+ await expectOneActivation(page, harness.backend, VIDEO_B);
+ await expectWatchInitialized(page, VIDEO_B, COUNTS[VIDEO_B].dislikes + 1);
+ expect(
+ harness.backend.requestsFor("GET", "/votes").filter((request) => request.query.videoId === VIDEO_B),
+ ).toHaveLength(1);
+ expect(harness.backend.requestsFor("POST", "/interact/vote")).toHaveLength(1);
+ await expectHealthyRuntime(page, harness);
+});
+
+test("navigation-start combines a replaced Like target with a reused Dislike refresh before finish", async ({
+ context,
+ page,
+}) => {
+ const harness = await launchNavigationHarness({ context, page }, { pageKind: "watch", videoId: VIDEO_A });
+ await expectWatchInitialized(page, VIDEO_A);
+ const controls = page.locator(`[data-fixture-control-video-id="${VIDEO_A}"]`);
+ const originalLikeButton = controls.locator('[data-ryd-role="like"] button');
+ const reusedDislikeButton = controls.locator('[data-ryd-role="dislike"] button');
+ await originalLikeButton.evaluate((button) => {
+ window.__fixtureOriginalMixedLikeTarget = button;
+ });
+ await reusedDislikeButton.evaluate((button) => button.setAttribute("data-fixture-mixed-reused-node", "dislike"));
+
+ await page.evaluate((videoId) => window.__navigationFixture.beginSameNodeWatchNavigation(videoId), VIDEO_B);
+ await page.evaluate((videoId) => {
+ window.__navigationFixture.replaceDelayedWatchControl("like");
+ window.__navigationFixture.refreshReusedWatchControl("dislike", videoId);
+ }, VIDEO_B);
+
+ const replacementLike = controls.locator('[data-fixture-watch-replacement="like"]');
+ await expect(page).toHaveURL((url) => url.pathname === "/watch" && url.searchParams.get("v") === VIDEO_B);
+ await expect(page.locator(`ytd-watch-flexy[video-id="${VIDEO_B}"]`)).toHaveCount(1);
+ await expect(replacementLike).toHaveCount(1);
+ expect(
+ await replacementLike
+ .locator("button")
+ .evaluate(
+ (button) =>
+ !window.__fixtureOriginalMixedLikeTarget.isConnected && button !== window.__fixtureOriginalMixedLikeTarget,
+ ),
+ ).toBe(true);
+ await expect(reusedDislikeButton).toHaveAttribute("aria-label", `dislike refreshed for ${VIDEO_B}`);
+ await expect(reusedDislikeButton).toHaveAttribute("data-fixture-mixed-reused-node", "dislike");
+ expect(
+ harness.backend.requestsFor("GET", "/votes").filter((request) => request.query.videoId === VIDEO_B),
+ ).toHaveLength(0);
+
+ await page.evaluate(() => window.__navigationFixture.finishSameNodeWatchNavigation());
+
+ await expectWatchInitialized(page, VIDEO_B);
+ await expect(replacementLike).toHaveCount(1);
+ await expect(reusedDislikeButton).toHaveAttribute("data-fixture-mixed-reused-node", "dislike");
+ expect(
+ harness.backend.requestsFor("GET", "/votes").filter((request) => request.query.videoId === VIDEO_B),
+ ).toHaveLength(1);
+ await expectOneActivation(page, harness.backend, VIDEO_B);
+ await expectWatchInitialized(page, VIDEO_B, COUNTS[VIDEO_B].dislikes + 1);
+ expect(harness.backend.requestsFor("POST", "/interact/vote")).toHaveLength(1);
+ expect(harness.backend.requestsFor("POST", "/interact/confirmVote")).toHaveLength(1);
+ await expectHealthyRuntime(page, harness);
+});
+
+test("mobile channel to Short ignores stale channel controls until the target mounts", async ({ context, page }) => {
+ const harness = await launchNavigationHarness(
+ { context, page },
+ { hostname: "m.youtube.com", pageKind: "channel", videoId: VIDEO_A },
+ );
+
+ await page.locator("#channel-short").click();
+
+ await expect(page).toHaveURL((url) => url.pathname === `/shorts/${VIDEO_A}`);
+ const decoyDislikeCount = page.locator('[data-fixture-decoy-controls] [data-ryd-role="dislike"] #text');
+ await page.waitForTimeout(250);
+ await expect(decoyDislikeCount).toHaveText("");
+ expect(harness.backend.requestsFor("GET", "/votes")).toHaveLength(0);
+ await expectMobileShortInitialized(page, VIDEO_A);
+ await expectOneActivation(page, harness.backend, VIDEO_A);
+ await expectMobileShortInitialized(page, VIDEO_A, COUNTS[VIDEO_A].dislikes + 1);
+ await expectHealthyRuntime(page, harness);
+});
+
+test("mobile Short autoplay activates its preloaded sibling without a navigation event", async ({ context, page }) => {
+ const harness = await launchNavigationHarness(
+ { context, page },
+ { hostname: "m.youtube.com", pageKind: "shorts", videoId: VIDEO_A },
+ );
+ await expectMobileShortInitialized(page, VIDEO_A);
+ const preloadedNextShort = page.locator(`[data-fixture-mobile-short="${VIDEO_B}"]`);
+ await expect(preloadedNextShort).toBeHidden();
+ await preloadedNextShort.evaluate((renderer) => renderer.setAttribute("data-fixture-preloaded-marker", "true"));
+
+ await page.evaluate(() => window.__navigationFixture.dispatchEnded());
+
+ await expectMobileShortInitialized(page, VIDEO_B);
+ await expect(page.locator(`[data-fixture-mobile-short="${VIDEO_B}"][is-active]`)).toHaveAttribute(
+ "data-fixture-preloaded-marker",
+ "true",
+ );
+ await expectOneActivation(page, harness.backend, VIDEO_B);
+ await expectMobileShortInitialized(page, VIDEO_B, COUNTS[VIDEO_B].dislikes + 1);
+ await expectHealthyRuntime(page, harness);
+});
+
+test("same-video mobile overlay replacement reinitializes one control without a navigation event", async ({
+ context,
+ page,
+}) => {
+ const harness = await launchNavigationHarness(
+ { context, page },
+ { hostname: "m.youtube.com", pageKind: "shorts", videoId: VIDEO_A },
+ );
+ await expectMobileShortInitialized(page, VIDEO_A);
+
+ await page.evaluate(() => window.__navigationFixture.replaceActiveMobileOverlay());
+
+ const replacement = page.locator(
+ `ytm-reel-video-renderer[video-id="${VIDEO_A}"][is-active] ytm-reel-player-overlay-renderer[data-fixture-replacement="true"]`,
+ );
+ await expect(replacement).toBeVisible();
+ await expectMobileShortInitialized(page, VIDEO_A);
+ await expectOneActivation(page, harness.backend, VIDEO_A);
+ await expectMobileShortInitialized(page, VIDEO_A, COUNTS[VIDEO_A].dislikes + 1);
+ expect(harness.backend.requestsFor("POST", "/interact/vote")).toHaveLength(1);
+ await expectHealthyRuntime(page, harness);
+});
diff --git a/Extensions/UserScript/e2e/userscript-navigation-matrix.e2e.js b/Extensions/UserScript/e2e/userscript-navigation-matrix.e2e.js
new file mode 100644
index 0000000..5a79fa7
--- /dev/null
+++ b/Extensions/UserScript/e2e/userscript-navigation-matrix.e2e.js
@@ -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([]);
+ });
+}
diff --git a/Extensions/UserScript/e2e/userscript-rate-bar.e2e.js b/Extensions/UserScript/e2e/userscript-rate-bar.e2e.js
new file mode 100644
index 0000000..248fb46
--- /dev/null
+++ b/Extensions/UserScript/e2e/userscript-rate-bar.e2e.js
@@ -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([]);
+});
diff --git a/Extensions/UserScript/e2e/userscript-shorts-layout.e2e.js b/Extensions/UserScript/e2e/userscript-shorts-layout.e2e.js
new file mode 100644
index 0000000..a2024b7
--- /dev/null
+++ b/Extensions/UserScript/e2e/userscript-shorts-layout.e2e.js
@@ -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([]);
+ });
+}
diff --git a/Extensions/UserScript/e2e/userscript-state-visual-contract.e2e.js b/Extensions/UserScript/e2e/userscript-state-visual-contract.e2e.js
new file mode 100644
index 0000000..4913bee
--- /dev/null
+++ b/Extensions/UserScript/e2e/userscript-state-visual-contract.e2e.js
@@ -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;
+ }
+ });
+ }
+ });
+}
diff --git a/Extensions/UserScript/e2e/userscript-trusted-types.e2e.js b/Extensions/UserScript/e2e/userscript-trusted-types.e2e.js
new file mode 100644
index 0000000..473c783
--- /dev/null
+++ b/Extensions/UserScript/e2e/userscript-trusted-types.e2e.js
@@ -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);
+});
diff --git a/Extensions/UserScript/e2e/userscript-voting.e2e.js b/Extensions/UserScript/e2e/userscript-voting.e2e.js
new file mode 100644
index 0000000..b022ead
--- /dev/null
+++ b/Extensions/UserScript/e2e/userscript-voting.e2e.js
@@ -0,0 +1,1028 @@
+const { test, expect, devices } = require("@playwright/test");
+const {
+ CREDENTIAL_KEY,
+ VIDEO_A,
+ VIDEO_B,
+ createFakeBackend,
+ injectGeneratedUserscript,
+ installGmEnvironment,
+ installHermeticRoutes,
+ openShortsFixture,
+ openWatchFixture,
+ readGmValue,
+} = require("./harness");
+
+const EXISTING_CREDENTIALS = {
+ userId: "ExistingUserscriptCredential000000000001",
+ registrationConfirmed: true,
+};
+const SYNTHETIC_STATE_KEY_PREFIX = "rydSyntheticDislikedShort:";
+const PIXEL_5 = Object.fromEntries(Object.entries(devices["Pixel 5"]).filter(([key]) => key !== "defaultBrowserType"));
+
+function monitorPage(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 { page, consoleErrors, pageErrors };
+}
+
+async function installUnhandledRejectionCapture(context) {
+ await context.addInitScript(() => {
+ globalThis.__unhandledRejections = [];
+ addEventListener("unhandledrejection", (event) => {
+ const reason = event.reason;
+ globalThis.__unhandledRejections.push(reason instanceof Error ? reason.message : String(reason));
+ });
+ });
+}
+
+async function launchHarness(
+ { context, page },
+ {
+ backendOptions,
+ beforeInject,
+ coloredThumbs,
+ disableVoteSubmission = false,
+ gmValues,
+ hostname = "www.youtube.com",
+ pageKind = "watch",
+ videoId = VIDEO_A,
+ } = {},
+) {
+ const backend = createFakeBackend(backendOptions);
+ const monitoredPages = [monitorPage(page)];
+
+ await installUnhandledRejectionCapture(context);
+ await installGmEnvironment(context, gmValues);
+ await installHermeticRoutes(context, backend);
+ if (pageKind === "shorts") await openShortsFixture(page, videoId, { hostname });
+ else await openWatchFixture(page, videoId, { hostname });
+ if (beforeInject) await beforeInject(page);
+ await injectGeneratedUserscript(page, { coloredThumbs, disableVoteSubmission });
+
+ return { backend, monitoredPages };
+}
+
+async function expectNoRuntimeFailures({ backend, monitoredPages }, { allowedConsoleErrors = [] } = {}) {
+ expect(backend.blockedRequests, "all network traffic must be served by the hermetic harness").toEqual([]);
+
+ for (const monitored of monitoredPages) {
+ const unexpectedConsoleErrors = monitored.consoleErrors.filter(
+ (message) => !allowedConsoleErrors.some((pattern) => pattern.test(message)),
+ );
+ expect(unexpectedConsoleErrors, "unexpected browser console errors").toEqual([]);
+ expect(monitored.pageErrors, "uncaught page errors").toEqual([]);
+ expect(
+ await monitored.page.evaluate(() => globalThis.__unhandledRejections || []),
+ "unhandled promise rejections",
+ ).toEqual([]);
+ }
+}
+
+async function waitForCredentials(page) {
+ let credentials = null;
+ await expect
+ .poll(async () => {
+ credentials = await readGmValue(page, CREDENTIAL_KEY);
+ return credentials;
+ })
+ .toMatchObject({ registrationConfirmed: true });
+ return credentials;
+}
+
+function visibleVoteButton(page, role) {
+ return page.locator(`[data-ryd-role="${role}"]:visible button`);
+}
+
+async function waitForDislikeCount(page, value) {
+ await expect(page.locator('[data-ryd-role="dislike"]:visible #text')).toHaveText(String(value));
+}
+
+async function clickVoteAndWait(page, backend, role, expectedConfirmations) {
+ await visibleVoteButton(page, role).click();
+ await expect.poll(() => backend.requestsFor("POST", "/interact/confirmVote").length).toBe(expectedConfirmations);
+}
+
+test("fresh startup eagerly registers and submits a complete dislike handshake", async ({ context, page }) => {
+ const harness = await launchHarness({ context, page });
+ const { backend } = harness;
+ await waitForDislikeCount(page, 25);
+
+ const credentials = await waitForCredentials(page);
+ expect(credentials.userId).toHaveLength(36);
+
+ const registrationGet = backend.requestsFor("GET", "/puzzle/registration");
+ const registrationPost = backend.requestsFor("POST", "/puzzle/registration");
+ expect(registrationGet).toHaveLength(1);
+ expect(registrationPost).toHaveLength(1);
+ expect(registrationGet[0].query.userId).toBe(credentials.userId);
+ expect(Buffer.from(registrationPost[0].body.solution, "base64")).toHaveLength(4);
+ expect(backend.requestsFor("POST", "/interact/vote")).toHaveLength(0);
+ expect(backend.requestsFor("POST", "/interact/confirmVote")).toHaveLength(0);
+
+ await clickVoteAndWait(page, backend, "dislike", 1);
+
+ const vote = backend.requestsFor("POST", "/interact/vote")[0];
+ const confirmation = backend.requestsFor("POST", "/interact/confirmVote")[0];
+ expect(vote.body).toEqual({ userId: credentials.userId, videoId: VIDEO_A, value: -1 });
+ expect(confirmation.body.userId).toBe(credentials.userId);
+ expect(confirmation.body.videoId).toBe(VIDEO_A);
+ expect(Buffer.from(confirmation.body.solution, "base64")).toHaveLength(4);
+ await expectNoRuntimeFailures(harness);
+});
+
+test("credentials survive a page reload and prevent duplicate registration", async ({ context, page }) => {
+ const harness = await launchHarness({ context, page });
+ const { backend } = harness;
+ const firstCredentials = await waitForCredentials(page);
+ await waitForDislikeCount(page, 25);
+ const registrationCount = backend.requestsFor("GET", "/puzzle/registration").length;
+
+ await page.reload({ waitUntil: "domcontentloaded" });
+ await injectGeneratedUserscript(page);
+ await waitForDislikeCount(page, 25);
+
+ expect(await readGmValue(page, CREDENTIAL_KEY)).toEqual(firstCredentials);
+ expect(backend.requestsFor("GET", "/puzzle/registration")).toHaveLength(registrationCount);
+
+ await clickVoteAndWait(page, backend, "like", 1);
+ expect(backend.requestsFor("POST", "/interact/vote")[0].body).toEqual({
+ userId: firstCredentials.userId,
+ videoId: VIDEO_A,
+ value: 1,
+ });
+ await expectNoRuntimeFailures(harness);
+});
+
+test("a second page reuses the registered identity", async ({ context, page }) => {
+ const harness = await launchHarness({ context, page });
+ const { backend, monitoredPages } = harness;
+ const credentials = await waitForCredentials(page);
+
+ const secondPage = await context.newPage();
+ monitoredPages.push(monitorPage(secondPage));
+ await openWatchFixture(secondPage, VIDEO_B);
+ await injectGeneratedUserscript(secondPage);
+ await waitForDislikeCount(secondPage, 25);
+
+ expect(backend.requestsFor("GET", "/puzzle/registration")).toHaveLength(1);
+ await clickVoteAndWait(secondPage, backend, "like", 1);
+ expect(backend.requestsFor("POST", "/interact/vote")[0].body).toEqual({
+ userId: credentials.userId,
+ videoId: VIDEO_B,
+ value: 1,
+ });
+ await expectNoRuntimeFailures(harness);
+});
+
+const initialStateTransitions = [
+ { initialState: "neutral", action: "like", expectedValue: 1, expectedDislikes: 25 },
+ { initialState: "neutral", action: "dislike", expectedValue: -1, expectedDislikes: 26 },
+ { initialState: "liked", action: "like", expectedValue: 0, expectedDislikes: 25 },
+ { initialState: "liked", action: "dislike", expectedValue: -1, expectedDislikes: 26 },
+ { initialState: "disliked", action: "like", expectedValue: 1, expectedDislikes: 24 },
+ { initialState: "disliked", action: "dislike", expectedValue: 0, expectedDislikes: 24 },
+];
+
+for (const scenario of initialStateTransitions) {
+ test(`${scenario.initialState} plus ${scenario.action} submits ${scenario.expectedValue}`, async ({
+ context,
+ page,
+ }) => {
+ const harness = await launchHarness(
+ { context, page },
+ {
+ backendOptions: { fixture: { initialState: scenario.initialState } },
+ gmValues: { [CREDENTIAL_KEY]: EXISTING_CREDENTIALS },
+ },
+ );
+ const { backend } = harness;
+ await waitForDislikeCount(page, 25);
+
+ await clickVoteAndWait(page, backend, scenario.action, 1);
+
+ expect(backend.requestsFor("GET", "/puzzle/registration")).toHaveLength(0);
+ expect(backend.requestsFor("POST", "/interact/vote")[0].body.value).toBe(scenario.expectedValue);
+ await waitForDislikeCount(page, scenario.expectedDislikes);
+ await expectNoRuntimeFailures(harness);
+ });
+}
+
+test("disabled vote submission keeps local UI transitions without API interaction", async ({ context, page }) => {
+ const harness = await launchHarness(
+ { context, page },
+ {
+ disableVoteSubmission: true,
+ },
+ );
+ const { backend } = harness;
+ await waitForDislikeCount(page, 25);
+ await waitForCredentials(page);
+
+ await visibleVoteButton(page, "dislike").click();
+ await waitForDislikeCount(page, 26);
+ await page.waitForTimeout(100);
+
+ expect(backend.requestsFor("POST", "/interact/vote")).toHaveLength(0);
+ expect(backend.requestsFor("POST", "/interact/confirmVote")).toHaveLength(0);
+ expect(backend.requestsFor("GET", "/puzzle/registration")).toHaveLength(1);
+ await expectNoRuntimeFailures(harness);
+});
+
+test("signed-out controls never submit a vote", async ({ context, page }) => {
+ const harness = await launchHarness(
+ { context, page },
+ {
+ backendOptions: { fixture: { signedIn: false } },
+ gmValues: { [CREDENTIAL_KEY]: EXISTING_CREDENTIALS },
+ },
+ );
+ const { backend } = harness;
+ await waitForDislikeCount(page, 25);
+
+ await visibleVoteButton(page, "dislike").click();
+ await page.waitForTimeout(100);
+
+ expect(backend.requestsFor("POST", "/interact/vote")).toHaveLength(0);
+ expect(backend.requestsFor("POST", "/interact/confirmVote")).toHaveLength(0);
+ await expectNoRuntimeFailures(harness);
+});
+
+test("late button insertion still initializes rendering and voting", async ({ context, page }) => {
+ const harness = await launchHarness(
+ { context, page },
+ {
+ backendOptions: { fixture: { initialButtons: false } },
+ gmValues: { [CREDENTIAL_KEY]: EXISTING_CREDENTIALS },
+ },
+ );
+ const { backend } = harness;
+
+ await page.waitForTimeout(150);
+ await page.evaluate(() => window.__youtubeFixture.insertButtons("neutral"));
+ await waitForDislikeCount(page, 25);
+ await clickVoteAndWait(page, backend, "dislike", 1);
+
+ expect(backend.requestsFor("POST", "/interact/vote")[0].body.videoId).toBe(VIDEO_A);
+ await expectNoRuntimeFailures(harness);
+});
+
+test("an early vote is reconciled onto a delayed same-video count", async ({ context, page }) => {
+ const harness = await launchHarness(
+ { context, page },
+ {
+ backendOptions: {
+ countDelayByVideo: { [VIDEO_A]: 350 },
+ },
+ gmValues: { [CREDENTIAL_KEY]: EXISTING_CREDENTIALS },
+ },
+ );
+ await expect.poll(() => harness.backend.requestsFor("GET", "/votes").length).toBe(1);
+
+ await visibleVoteButton(page, "dislike").click();
+ await expect.poll(() => harness.backend.requestsFor("POST", "/interact/confirmVote").length).toBe(1);
+ await waitForDislikeCount(page, 26);
+
+ await visibleVoteButton(page, "dislike").click();
+ await expect.poll(() => harness.backend.requestsFor("POST", "/interact/confirmVote").length).toBe(2);
+ await waitForDislikeCount(page, 25);
+ expect(harness.backend.requestsFor("POST", "/interact/vote").map((request) => request.body.value)).toEqual([-1, 0]);
+ await expectNoRuntimeFailures(harness);
+});
+
+test("SPA navigation ignores a stale count and submits only the current video id", async ({ context, page }) => {
+ const harness = await launchHarness(
+ { context, page },
+ {
+ backendOptions: {
+ countDelayByVideo: { [VIDEO_A]: 300 },
+ countsByVideo: {
+ [VIDEO_A]: { dislikes: 11, likes: 100 },
+ [VIDEO_B]: { dislikes: 22, likes: 200 },
+ },
+ },
+ gmValues: { [CREDENTIAL_KEY]: EXISTING_CREDENTIALS },
+ },
+ );
+ const { backend } = harness;
+
+ await expect
+ .poll(() => backend.requestsFor("GET", "/votes").filter((request) => request.query.videoId === VIDEO_A).length)
+ .toBe(1);
+ await page.evaluate((videoId) => window.__youtubeFixture.navigate(videoId), VIDEO_B);
+
+ await expect
+ .poll(() => backend.requestsFor("GET", "/votes").filter((request) => request.query.videoId === VIDEO_B).length)
+ .toBe(1);
+ await waitForDislikeCount(page, 22);
+ await page.evaluate(() => {
+ document.dispatchEvent(new Event("yt-navigate-finish", { bubbles: true }));
+ document.dispatchEvent(new Event("yt-navigate-finish", { bubbles: true }));
+ });
+ await page.waitForTimeout(350);
+ await waitForDislikeCount(page, 22);
+
+ await clickVoteAndWait(page, backend, "dislike", 1);
+ const votes = backend.requestsFor("POST", "/interact/vote");
+ expect(votes).toHaveLength(1);
+ expect(votes[0].body.videoId).toBe(VIDEO_B);
+ await expectNoRuntimeFailures(harness);
+});
+
+test("navigation during an in-flight vote does not block the next video", async ({ context, page }) => {
+ const harness = await launchHarness(
+ { context, page },
+ {
+ backendOptions: {
+ countsByVideo: {
+ [VIDEO_A]: { dislikes: 11, likes: 100 },
+ [VIDEO_B]: { dislikes: 22, likes: 200 },
+ },
+ },
+ gmValues: { [CREDENTIAL_KEY]: EXISTING_CREDENTIALS },
+ },
+ );
+ const { backend } = harness;
+ backend.enqueue("POST", "/interact/confirmVote", { body: true, delayMs: 1_000 });
+ await waitForDislikeCount(page, 11);
+
+ await visibleVoteButton(page, "dislike").click();
+ await expect.poll(() => backend.requestsFor("POST", "/interact/confirmVote").length).toBe(1);
+
+ await page.evaluate((videoId) => window.__youtubeFixture.navigate(videoId), VIDEO_B);
+ await waitForDislikeCount(page, 22);
+ await visibleVoteButton(page, "like").click();
+ await expect.poll(() => backend.requestsFor("POST", "/interact/confirmVote").length).toBe(2);
+ await expect
+ .poll(() => backend.requestsFor("POST", "/interact/confirmVote").every((request) => request.respondedAt))
+ .toBe(true);
+
+ const votes = backend.requestsFor("POST", "/interact/vote");
+ const confirmations = backend.requestsFor("POST", "/interact/confirmVote");
+ expect(votes.map((request) => request.body.videoId)).toEqual([VIDEO_A, VIDEO_B]);
+ expect(confirmations.map((request) => request.body.videoId)).toEqual([VIDEO_A, VIDEO_B]);
+ expect(votes[1].at).toBeLessThan(confirmations[0].respondedAt);
+ await expectNoRuntimeFailures(harness);
+});
+
+test("active Shorts controls switch video identity", async ({ context, page }) => {
+ const harness = await launchHarness(
+ { context, page },
+ {
+ backendOptions: {
+ countsByVideo: {
+ [VIDEO_A]: { dislikes: 11, likes: 100 },
+ [VIDEO_B]: { dislikes: 22, likes: 200 },
+ },
+ },
+ gmValues: { [CREDENTIAL_KEY]: EXISTING_CREDENTIALS },
+ pageKind: "shorts",
+ },
+ );
+ const { backend } = harness;
+ await waitForDislikeCount(page, 11);
+
+ await page.evaluate((videoId) => window.__shortsFixture.activate(videoId), VIDEO_B);
+ await waitForDislikeCount(page, 22);
+ await expect(page.locator("[data-short-video]:not([hidden])")).toHaveCount(1);
+
+ await clickVoteAndWait(page, backend, "dislike", 1);
+ expect(backend.requestsFor("POST", "/interact/vote")[0].body.videoId).toBe(VIDEO_B);
+ expect(backend.requestsFor("GET", "/votes").filter((request) => request.query.videoId === VIDEO_A)).toHaveLength(1);
+ expect(backend.requestsFor("GET", "/votes").filter((request) => request.query.videoId === VIDEO_B)).toHaveLength(1);
+ await expectNoRuntimeFailures(harness);
+});
+
+test("a recycled Shorts renderer is retagged and initialized for its new video", async ({ context, page }) => {
+ const harness = await launchHarness(
+ { context, page },
+ {
+ backendOptions: {
+ countsByVideo: {
+ [VIDEO_A]: { dislikes: 11, likes: 100 },
+ [VIDEO_B]: { dislikes: 22, likes: 200 },
+ },
+ },
+ gmValues: { [CREDENTIAL_KEY]: EXISTING_CREDENTIALS },
+ pageKind: "shorts",
+ },
+ );
+ await waitForDislikeCount(page, 11);
+
+ await page.evaluate((videoId) => window.__shortsFixture.recycleActiveRenderer(videoId), VIDEO_B);
+ await waitForDislikeCount(page, 22);
+ await expect(page.locator("[data-ryd-synthetic-shorts-dislike]:visible")).toHaveAttribute(
+ "data-ryd-video-id",
+ VIDEO_B,
+ );
+ await expect(visibleVoteButton(page, "dislike")).toHaveAttribute("aria-disabled", "false");
+ await expect(visibleVoteButton(page, "dislike")).toHaveAttribute("aria-pressed", "false");
+
+ await visibleVoteButton(page, "dislike").click();
+ await expect.poll(() => harness.backend.requestsFor("POST", "/interact/confirmVote").length).toBe(1);
+ expect(harness.backend.requestsFor("POST", "/interact/vote")[0].body.videoId).toBe(VIDEO_B);
+ await expectNoRuntimeFailures(harness);
+});
+
+test("current desktop Shorts UI gets one owned dislike control and covers all six transitions", async ({
+ context,
+ page,
+}) => {
+ const harness = await launchHarness(
+ { context, page },
+ {
+ gmValues: { [CREDENTIAL_KEY]: EXISTING_CREDENTIALS },
+ pageKind: "shorts",
+ },
+ );
+ const { backend } = harness;
+ await waitForDislikeCount(page, 25);
+
+ const synthetic = page.locator("[data-ryd-synthetic-shorts-dislike]:visible");
+ await expect(synthetic).toHaveCount(1);
+ await expect(synthetic.locator("button")).toHaveAttribute("aria-label", "Dislike this video");
+ await expect(page.locator("dislike-button-view-model, #dislike-button")).toHaveCount(0);
+ await expect(page.locator('[data-fixture-control="comments"]:visible')).toContainText("12");
+ await expect(page.locator('[data-fixture-control="share"]:visible')).toContainText("Share");
+ await expect(page.locator('[data-fixture-control="remix"]:visible')).toContainText("Remix");
+
+ const actions = ["like", "like", "dislike", "like", "dislike", "dislike"];
+ const expectedStates = ["liked", "neutral", "disliked", "liked", "disliked", "neutral"];
+ const expectedValues = [1, 0, -1, 1, -1, 0];
+ for (let index = 0; index < actions.length; index += 1) {
+ await visibleVoteButton(page, actions[index]).click();
+ await expect.poll(() => backend.requestsFor("POST", "/interact/confirmVote").length).toBe(index + 1);
+ await expect
+ .poll(async () => {
+ const likePressed = await visibleVoteButton(page, "like").getAttribute("aria-pressed");
+ const dislikePressed = await visibleVoteButton(page, "dislike").getAttribute("aria-pressed");
+ if (likePressed === "true") return "liked";
+ if (dislikePressed === "true") return "disliked";
+ return "neutral";
+ })
+ .toBe(expectedStates[index]);
+ }
+
+ expect(backend.requestsFor("POST", "/interact/vote").map((request) => request.body.value)).toEqual(expectedValues);
+ await expect(synthetic).toHaveCount(1);
+ await expect(page.locator('[data-fixture-control="comments"]:visible')).toContainText("12");
+ await expectNoRuntimeFailures(harness);
+});
+
+test("modern Shorts supports the colored-thumbs option", async ({ context, page }) => {
+ const harness = await launchHarness(
+ { context, page },
+ {
+ coloredThumbs: true,
+ gmValues: { [CREDENTIAL_KEY]: EXISTING_CREDENTIALS },
+ pageKind: "shorts",
+ },
+ );
+ await waitForDislikeCount(page, 25);
+
+ await visibleVoteButton(page, "dislike").click();
+ await expect.poll(() => harness.backend.requestsFor("POST", "/interact/confirmVote").length).toBe(1);
+ await expect.poll(() => visibleVoteButton(page, "dislike").evaluate((button) => button.style.color)).toBe("red");
+
+ await visibleVoteButton(page, "dislike").click();
+ await expect.poll(() => harness.backend.requestsFor("POST", "/interact/confirmVote").length).toBe(2);
+ await expect.poll(() => visibleVoteButton(page, "dislike").evaluate((button) => button.style.color)).toBe("unset");
+ await expectNoRuntimeFailures(harness);
+});
+
+test("a recreated synthetic Shorts control is restored and rebound exactly once", async ({ context, page }) => {
+ const harness = await launchHarness(
+ { context, page },
+ {
+ gmValues: { [CREDENTIAL_KEY]: EXISTING_CREDENTIALS },
+ pageKind: "shorts",
+ },
+ );
+ await waitForDislikeCount(page, 25);
+
+ await page.evaluate(() => window.__shortsFixture.removeSyntheticDislike());
+ await expect(page.locator("[data-ryd-synthetic-shorts-dislike]:visible")).toHaveCount(1);
+ await waitForDislikeCount(page, 25);
+
+ await visibleVoteButton(page, "dislike").click();
+ await expect.poll(() => harness.backend.requestsFor("POST", "/interact/confirmVote").length).toBe(1);
+ expect(harness.backend.requestsFor("POST", "/interact/vote").map((request) => request.body.value)).toEqual([-1]);
+ await expect(page.locator("[data-ryd-synthetic-shorts-dislike]:visible")).toHaveCount(1);
+ await expectNoRuntimeFailures(harness);
+});
+
+test("a replaced Shorts action bar gets a fresh initialized control", async ({ context, page }) => {
+ const harness = await launchHarness(
+ { context, page },
+ {
+ gmValues: { [CREDENTIAL_KEY]: EXISTING_CREDENTIALS },
+ pageKind: "shorts",
+ },
+ );
+ await waitForDislikeCount(page, 25);
+
+ await page.evaluate(() => window.__shortsFixture.replaceActionBar());
+ await expect(page.locator("[data-ryd-synthetic-shorts-dislike]:visible")).toHaveCount(1);
+ await waitForDislikeCount(page, 25);
+ await expect(visibleVoteButton(page, "dislike")).toHaveAttribute("aria-disabled", "false");
+
+ await visibleVoteButton(page, "dislike").click();
+ await expect.poll(() => harness.backend.requestsFor("POST", "/interact/confirmVote").length).toBe(1);
+ expect(harness.backend.requestsFor("POST", "/interact/vote").map((request) => request.body.value)).toEqual([-1]);
+ await expectNoRuntimeFailures(harness);
+});
+
+test("a pending count keeps its optimistic delta across same-video action-bar replacement", async ({
+ context,
+ page,
+}) => {
+ const harness = await launchHarness(
+ { context, page },
+ {
+ backendOptions: {
+ countDelayByVideo: { [VIDEO_A]: 350 },
+ },
+ gmValues: { [CREDENTIAL_KEY]: EXISTING_CREDENTIALS },
+ pageKind: "shorts",
+ },
+ );
+ await expect.poll(() => harness.backend.requestsFor("GET", "/votes").length).toBe(1);
+
+ await visibleVoteButton(page, "dislike").click();
+ await expect.poll(() => harness.backend.requestsFor("POST", "/interact/confirmVote").length).toBe(1);
+ await page.evaluate(() => window.__shortsFixture.replaceActionBar());
+ await expect(page.locator("[data-ryd-synthetic-shorts-dislike]:visible")).toHaveCount(1);
+ await waitForDislikeCount(page, 26);
+ await expect(visibleVoteButton(page, "dislike")).toHaveAttribute("aria-pressed", "true");
+ expect(harness.backend.requestsFor("GET", "/votes")).toHaveLength(1);
+
+ await visibleVoteButton(page, "dislike").click();
+ await expect.poll(() => harness.backend.requestsFor("POST", "/interact/confirmVote").length).toBe(2);
+ await waitForDislikeCount(page, 25);
+ expect(harness.backend.requestsFor("POST", "/interact/vote").map((request) => request.body.value)).toEqual([-1, 0]);
+ await expectNoRuntimeFailures(harness);
+});
+
+test("a replaced synthetic inner button is rebound", async ({ context, page }) => {
+ const harness = await launchHarness(
+ { context, page },
+ {
+ gmValues: { [CREDENTIAL_KEY]: EXISTING_CREDENTIALS },
+ pageKind: "shorts",
+ },
+ );
+ await waitForDislikeCount(page, 25);
+
+ await page.evaluate(() => window.__shortsFixture.replaceInnerButton("dislike"));
+ await expect(visibleVoteButton(page, "dislike")).toHaveAttribute("aria-disabled", "false");
+ await visibleVoteButton(page, "dislike").click();
+ await expect.poll(() => harness.backend.requestsFor("POST", "/interact/confirmVote").length).toBe(1);
+ expect(harness.backend.requestsFor("POST", "/interact/vote").map((request) => request.body.value)).toEqual([-1]);
+ await expectNoRuntimeFailures(harness);
+});
+
+test("Like and synthetic Dislike label-count clicks each submit once", async ({ context, page }) => {
+ const harness = await launchHarness(
+ { context, page },
+ {
+ gmValues: { [CREDENTIAL_KEY]: EXISTING_CREDENTIALS },
+ pageKind: "shorts",
+ },
+ );
+ await waitForDislikeCount(page, 25);
+
+ await page.locator('[data-ryd-role="like"]:visible #text').click();
+ await expect.poll(() => harness.backend.requestsFor("POST", "/interact/confirmVote").length).toBe(1);
+ await expect(visibleVoteButton(page, "like")).toHaveAttribute("aria-pressed", "true");
+
+ await page.locator("[data-ryd-synthetic-shorts-dislike]:visible #text").click();
+ await expect.poll(() => harness.backend.requestsFor("POST", "/interact/confirmVote").length).toBe(2);
+ await page.waitForTimeout(200);
+ expect(harness.backend.requestsFor("POST", "/interact/vote").map((request) => request.body.value)).toEqual([1, -1]);
+ await expect(visibleVoteButton(page, "like")).toHaveAttribute("aria-pressed", "false");
+ await expect(visibleVoteButton(page, "dislike")).toHaveAttribute("aria-pressed", "true");
+ await expectNoRuntimeFailures(harness);
+});
+
+test("a native Shorts dislike arriving later replaces the owned control", async ({ context, page }) => {
+ const harness = await launchHarness(
+ { context, page },
+ {
+ gmValues: { [CREDENTIAL_KEY]: EXISTING_CREDENTIALS },
+ pageKind: "shorts",
+ },
+ );
+ await waitForDislikeCount(page, 25);
+
+ await page.evaluate(() => window.__shortsFixture.installNativeDislike());
+ await expect(page.locator("[data-ryd-synthetic-shorts-dislike]")).toHaveCount(0);
+ await expect(page.locator("dislike-button-view-model:visible")).toHaveCount(1);
+ await waitForDislikeCount(page, 25);
+ await expect(page.locator('[data-fixture-control="comments"]:visible')).toContainText("12");
+
+ await visibleVoteButton(page, "dislike").click();
+ await expect.poll(() => harness.backend.requestsFor("POST", "/interact/confirmVote").length).toBe(1);
+ expect(harness.backend.requestsFor("POST", "/interact/vote").map((request) => request.body.value)).toEqual([-1]);
+ await expectNoRuntimeFailures(harness);
+});
+
+test("native Shorts takeover and return preserve the final per-video state", async ({ context, page }) => {
+ const stateKey = `${SYNTHETIC_STATE_KEY_PREFIX}${VIDEO_A}`;
+ const harness = await launchHarness(
+ { context, page },
+ {
+ gmValues: { [CREDENTIAL_KEY]: EXISTING_CREDENTIALS },
+ pageKind: "shorts",
+ },
+ );
+ await waitForDislikeCount(page, 25);
+
+ await visibleVoteButton(page, "dislike").click();
+ await expect.poll(() => harness.backend.requestsFor("POST", "/interact/confirmVote").length).toBe(1);
+ await expect.poll(() => readGmValue(page, stateKey)).toBe(true);
+
+ await page.evaluate(() => window.__shortsFixture.installNativeDislike());
+ await expect(page.locator("[data-ryd-synthetic-shorts-dislike]")).toHaveCount(0);
+ await page.evaluate(() => window.__shortsFixture.removeNativeDislike());
+ await waitForDislikeCount(page, 26);
+ await expect(visibleVoteButton(page, "dislike")).toHaveAttribute("aria-pressed", "true");
+
+ await page.evaluate(() => window.__shortsFixture.installNativeDislike());
+ await expect(page.locator("[data-ryd-synthetic-shorts-dislike]")).toHaveCount(0);
+ await waitForDislikeCount(page, 26);
+ await visibleVoteButton(page, "dislike").click();
+ await expect.poll(() => harness.backend.requestsFor("POST", "/interact/confirmVote").length).toBe(2);
+ await expect.poll(() => readGmValue(page, stateKey)).toBe(true);
+ await visibleVoteButton(page, "dislike").click();
+ await expect.poll(() => harness.backend.requestsFor("POST", "/interact/confirmVote").length).toBe(3);
+ await expect.poll(() => readGmValue(page, stateKey)).toBe(null);
+
+ await page.evaluate(() => window.__shortsFixture.removeNativeDislike());
+ await waitForDislikeCount(page, 25);
+ await expect(visibleVoteButton(page, "dislike")).toHaveAttribute("aria-pressed", "false");
+ expect(harness.backend.requestsFor("POST", "/interact/vote").map((request) => request.body.value)).toEqual([
+ -1, -1, 0,
+ ]);
+ await expectNoRuntimeFailures(harness);
+});
+
+test("class-only active Like switches to synthetic Dislike with one backend vote", async ({ context, page }) => {
+ const harness = await launchHarness(
+ { context, page },
+ {
+ backendOptions: {
+ countsByVideo: {
+ [VIDEO_A]: { dislikes: 25, likes: 100 },
+ [VIDEO_B]: { dislikes: 30, likes: 200 },
+ },
+ },
+ gmValues: { [CREDENTIAL_KEY]: EXISTING_CREDENTIALS },
+ pageKind: "shorts",
+ },
+ );
+ await waitForDislikeCount(page, 25);
+ await page.evaluate((videoId) => window.__shortsFixture.activate(videoId), VIDEO_B);
+ await waitForDislikeCount(page, 30);
+ await page.evaluate((videoId) => {
+ window.__shortsFixture.activate(videoId, { state: "liked" });
+ window.__shortsFixture.setClassOnlyLiked();
+ }, VIDEO_A);
+ await waitForDislikeCount(page, 25);
+ await expect(page.locator('[data-ryd-role="like"]:visible')).toHaveClass(/style-default-active/);
+ await expect(visibleVoteButton(page, "like")).toHaveAttribute("aria-pressed", "false");
+
+ await visibleVoteButton(page, "dislike").click();
+ await expect.poll(() => harness.backend.requestsFor("POST", "/interact/confirmVote").length).toBe(1);
+ await expect(visibleVoteButton(page, "like")).toHaveAttribute("aria-pressed", "false");
+ await expect(visibleVoteButton(page, "dislike")).toHaveAttribute("aria-pressed", "true");
+ expect(harness.backend.requestsFor("POST", "/interact/vote").map((request) => request.body.value)).toEqual([-1]);
+ await expectNoRuntimeFailures(harness);
+});
+
+test("a delayed state read cannot restore video A onto video B", async ({ context, page }) => {
+ const videoAStateKey = `${SYNTHETIC_STATE_KEY_PREFIX}${VIDEO_A}`;
+ const harness = await launchHarness(
+ { context, page },
+ {
+ backendOptions: {
+ countsByVideo: {
+ [VIDEO_A]: { dislikes: 11, likes: 100 },
+ [VIDEO_B]: { dislikes: 22, likes: 200 },
+ },
+ },
+ beforeInject: async (fixturePage) => {
+ await fixturePage.evaluate((delayedKey) => {
+ const originalGetValue = globalThis.GM.getValue;
+ let releaseRead;
+ const readGate = new Promise((resolve) => {
+ releaseRead = resolve;
+ });
+ globalThis.__releaseSyntheticStateRead = releaseRead;
+ globalThis.GM.getValue = async (key, fallbackValue) => {
+ if (key === delayedKey) await readGate;
+ return originalGetValue(key, fallbackValue);
+ };
+ }, videoAStateKey);
+ },
+ gmValues: {
+ [CREDENTIAL_KEY]: EXISTING_CREDENTIALS,
+ [videoAStateKey]: true,
+ },
+ pageKind: "shorts",
+ },
+ );
+
+ const initialSyntheticButton = visibleVoteButton(page, "dislike");
+ await expect(initialSyntheticButton).toHaveAttribute("aria-disabled", "true");
+ await page.evaluate((videoId) => window.__shortsFixture.activate(videoId), VIDEO_B);
+ await page.evaluate(() => globalThis.__releaseSyntheticStateRead());
+
+ await waitForDislikeCount(page, 22);
+ await expect(page.locator("[data-ryd-synthetic-shorts-dislike]:visible")).toHaveAttribute(
+ "data-ryd-video-id",
+ VIDEO_B,
+ );
+ await expect(visibleVoteButton(page, "dislike")).toHaveAttribute("aria-pressed", "false");
+ await expect(visibleVoteButton(page, "dislike")).toHaveAttribute("aria-disabled", "false");
+ await expectNoRuntimeFailures(harness);
+});
+
+test("synthetic state storage failure falls back to an enabled neutral control", async ({ context, page }) => {
+ const harness = await launchHarness(
+ { context, page },
+ {
+ beforeInject: async (fixturePage) => {
+ await fixturePage.evaluate((keyPrefix) => {
+ const originalGetValue = globalThis.GM.getValue;
+ globalThis.GM.getValue = (key, fallbackValue) => {
+ if (key.startsWith(keyPrefix)) throw new Error("synthetic state read failed");
+ return originalGetValue(key, fallbackValue);
+ };
+ }, SYNTHETIC_STATE_KEY_PREFIX);
+ },
+ gmValues: { [CREDENTIAL_KEY]: EXISTING_CREDENTIALS },
+ pageKind: "shorts",
+ },
+ );
+
+ await waitForDislikeCount(page, 25);
+ await expect(visibleVoteButton(page, "dislike")).toHaveAttribute("aria-pressed", "false");
+ await expect(visibleVoteButton(page, "dislike")).toHaveAttribute("aria-disabled", "false");
+ await expectNoRuntimeFailures(harness);
+});
+
+test("synthetic Shorts dislike state survives reload and toggles back to neutral", async ({ context, page }) => {
+ const harness = await launchHarness(
+ { context, page },
+ {
+ gmValues: { [CREDENTIAL_KEY]: EXISTING_CREDENTIALS },
+ pageKind: "shorts",
+ },
+ );
+ const { backend } = harness;
+ await waitForDislikeCount(page, 25);
+
+ await visibleVoteButton(page, "dislike").click();
+ await expect.poll(() => backend.requestsFor("POST", "/interact/confirmVote").length).toBe(1);
+ await expect(visibleVoteButton(page, "dislike")).toHaveAttribute("aria-pressed", "true");
+
+ await page.reload({ waitUntil: "domcontentloaded" });
+ await injectGeneratedUserscript(page);
+ await waitForDislikeCount(page, 25);
+ await expect(page.locator("[data-ryd-synthetic-shorts-dislike]:visible")).toHaveCount(1);
+ await expect(visibleVoteButton(page, "dislike")).toHaveAttribute("aria-pressed", "true");
+
+ await visibleVoteButton(page, "dislike").click();
+ await expect.poll(() => backend.requestsFor("POST", "/interact/confirmVote").length).toBe(2);
+ await expect(visibleVoteButton(page, "dislike")).toHaveAttribute("aria-pressed", "false");
+ expect(backend.requestsFor("POST", "/interact/vote").map((request) => request.body.value)).toEqual([-1, 0]);
+ await expectNoRuntimeFailures(harness);
+});
+
+test("corrupt synthetic Shorts state falls back to neutral without runtime errors", async ({ context, page }) => {
+ const harness = await launchHarness(
+ { context, page },
+ {
+ gmValues: {
+ [CREDENTIAL_KEY]: EXISTING_CREDENTIALS,
+ rydSyntheticDislikedShorts: { invalid: true },
+ },
+ pageKind: "shorts",
+ },
+ );
+ await waitForDislikeCount(page, 25);
+
+ await expect(page.locator("[data-ryd-synthetic-shorts-dislike]:visible")).toHaveCount(1);
+ await expect(visibleVoteButton(page, "dislike")).toHaveAttribute("aria-pressed", "false");
+ await expectNoRuntimeFailures(harness);
+});
+
+test("synthetic Shorts control honors the disabled vote gate", async ({ context, page }) => {
+ const harness = await launchHarness(
+ { context, page },
+ {
+ disableVoteSubmission: true,
+ gmValues: { [CREDENTIAL_KEY]: EXISTING_CREDENTIALS },
+ pageKind: "shorts",
+ },
+ );
+ await waitForDislikeCount(page, 25);
+ await visibleVoteButton(page, "dislike").click();
+ await waitForDislikeCount(page, 26);
+ expect(harness.backend.requestsFor("POST", "/interact/vote")).toHaveLength(0);
+ await expectNoRuntimeFailures(harness);
+});
+
+test("synthetic Shorts control honors the signed-out vote gate", async ({ context, page }) => {
+ const harness = await launchHarness(
+ { context, page },
+ {
+ backendOptions: { fixture: { signedIn: false } },
+ gmValues: { [CREDENTIAL_KEY]: EXISTING_CREDENTIALS },
+ pageKind: "shorts",
+ },
+ );
+ await waitForDislikeCount(page, 25);
+ await visibleVoteButton(page, "dislike").click();
+ await page.waitForTimeout(150);
+ expect(harness.backend.requestsFor("POST", "/interact/vote")).toHaveLength(0);
+ await expect(visibleVoteButton(page, "dislike")).toHaveAttribute("aria-pressed", "false");
+ await expectNoRuntimeFailures(harness);
+});
+
+test("rapid synthetic Shorts toggles remain serialized", async ({ context, page }) => {
+ const harness = await launchHarness(
+ { context, page },
+ {
+ gmValues: { [CREDENTIAL_KEY]: EXISTING_CREDENTIALS },
+ pageKind: "shorts",
+ },
+ );
+ const { backend } = harness;
+ backend.enqueue("POST", "/interact/confirmVote", { body: true, delayMs: 200 });
+ await waitForDislikeCount(page, 25);
+
+ const syntheticButton = visibleVoteButton(page, "dislike");
+ await syntheticButton.click();
+ await syntheticButton.click();
+ await expect.poll(() => backend.requestsFor("POST", "/interact/vote").length).toBe(1);
+ await page.waitForTimeout(75);
+ expect(backend.requestsFor("POST", "/interact/vote")).toHaveLength(1);
+ await expect.poll(() => backend.requestsFor("POST", "/interact/confirmVote").length).toBe(2);
+
+ expect(backend.requestsFor("POST", "/interact/vote").map((request) => request.body.value)).toEqual([-1, 0]);
+ await expect(syntheticButton).toHaveAttribute("aria-pressed", "false");
+ await expectNoRuntimeFailures(harness);
+});
+
+test("rapid votes for one video remain serialized and ordered", async ({ context, page }) => {
+ const harness = await launchHarness({ context, page }, { gmValues: { [CREDENTIAL_KEY]: EXISTING_CREDENTIALS } });
+ const { backend } = harness;
+ backend.enqueue("POST", "/interact/confirmVote", { body: true, delayMs: 200 });
+ await waitForDislikeCount(page, 25);
+
+ const likeButton = visibleVoteButton(page, "like");
+ await likeButton.click();
+ await likeButton.click();
+
+ await expect.poll(() => backend.requestsFor("POST", "/interact/vote").length).toBe(1);
+ await page.waitForTimeout(75);
+ expect(backend.requestsFor("POST", "/interact/vote")).toHaveLength(1);
+
+ await expect.poll(() => backend.requestsFor("POST", "/interact/confirmVote").length).toBe(2);
+ expect(backend.requestsFor("POST", "/interact/vote").map((request) => request.body.value)).toEqual([1, 0]);
+ await expectNoRuntimeFailures(harness);
+});
+
+test("a rejected vote does not poison the per-video queue", async ({ context, page }) => {
+ const harness = await launchHarness({ context, page }, { gmValues: { [CREDENTIAL_KEY]: EXISTING_CREDENTIALS } });
+ const { backend } = harness;
+ backend.enqueue("POST", "/interact/vote", { body: { invalidPuzzle: true } });
+ await waitForDislikeCount(page, 25);
+
+ const likeButton = visibleVoteButton(page, "like");
+ await likeButton.click();
+ await expect.poll(() => backend.requestsFor("POST", "/interact/vote").length).toBe(1);
+ await likeButton.click();
+ await expect.poll(() => backend.requestsFor("POST", "/interact/confirmVote").length).toBe(1);
+
+ expect(backend.requestsFor("POST", "/interact/vote").map((request) => request.body.value)).toEqual([1, 0]);
+ await expectNoRuntimeFailures(harness);
+});
+
+test("a rejected confirmation does not poison the per-video queue", async ({ context, page }) => {
+ const harness = await launchHarness({ context, page }, { gmValues: { [CREDENTIAL_KEY]: EXISTING_CREDENTIALS } });
+ const { backend } = harness;
+ backend.enqueue("POST", "/interact/confirmVote", { body: false });
+ await waitForDislikeCount(page, 25);
+
+ const dislikeButton = visibleVoteButton(page, "dislike");
+ await dislikeButton.click();
+ await expect.poll(() => backend.requestsFor("POST", "/interact/confirmVote").length).toBe(1);
+ await dislikeButton.click();
+ await expect.poll(() => backend.requestsFor("POST", "/interact/confirmVote").length).toBe(2);
+
+ expect(backend.requestsFor("POST", "/interact/vote").map((request) => request.body.value)).toEqual([-1, 0]);
+ await expectNoRuntimeFailures(harness);
+});
+
+test("a failed vote still allows later SPA navigation and voting", async ({ context, page }) => {
+ const harness = await launchHarness(
+ { context, page },
+ {
+ backendOptions: {
+ countsByVideo: {
+ [VIDEO_A]: { dislikes: 11, likes: 100 },
+ [VIDEO_B]: { dislikes: 22, likes: 200 },
+ },
+ },
+ gmValues: { [CREDENTIAL_KEY]: EXISTING_CREDENTIALS },
+ },
+ );
+ const { backend } = harness;
+ backend.enqueue("POST", "/interact/vote", { status: 500, body: { error: "temporary failure" } });
+ await waitForDislikeCount(page, 11);
+
+ await visibleVoteButton(page, "dislike").click();
+ await expect.poll(() => backend.requestsFor("POST", "/interact/vote").length).toBe(1);
+ await page.evaluate((videoId) => window.__youtubeFixture.navigate(videoId), VIDEO_B);
+ await waitForDislikeCount(page, 22);
+ await clickVoteAndWait(page, backend, "like", 1);
+
+ expect(backend.requestsFor("POST", "/interact/vote").map((request) => request.body.videoId)).toEqual([
+ VIDEO_A,
+ VIDEO_B,
+ ]);
+ await expectNoRuntimeFailures(harness, { allowedConsoleErrors: [/status of 500/] });
+});
+
+test("a 401 clears stale credentials, registers once, and retries the vote", async ({ context, page }) => {
+ const harness = await launchHarness({ context, page }, { gmValues: { [CREDENTIAL_KEY]: EXISTING_CREDENTIALS } });
+ const { backend } = harness;
+ backend.enqueue("POST", "/interact/vote", { status: 401, body: { error: "expired" } });
+ await waitForDislikeCount(page, 25);
+
+ await clickVoteAndWait(page, backend, "dislike", 1);
+
+ const votes = backend.requestsFor("POST", "/interact/vote");
+ expect(votes).toHaveLength(2);
+ expect(votes[0].body.userId).toBe(EXISTING_CREDENTIALS.userId);
+ expect(votes[1].body.userId).not.toBe(EXISTING_CREDENTIALS.userId);
+ expect(backend.requestsFor("GET", "/puzzle/registration")).toHaveLength(1);
+ expect(backend.requestsFor("POST", "/puzzle/registration")).toHaveLength(1);
+
+ const replacementCredentials = await readGmValue(page, CREDENTIAL_KEY);
+ expect(replacementCredentials).toMatchObject({
+ userId: votes[1].body.userId,
+ registrationConfirmed: true,
+ });
+ await expectNoRuntimeFailures(harness, { allowedConsoleErrors: [/status of 401/] });
+});
+
+test.describe("touch-enabled mobile fixture", () => {
+ test.use(PIXEL_5);
+
+ test("a real tap submits exactly one vote", async ({ context, page }) => {
+ const harness = await launchHarness(
+ { context, page },
+ {
+ gmValues: { [CREDENTIAL_KEY]: EXISTING_CREDENTIALS },
+ hostname: "m.youtube.com",
+ },
+ );
+ const { backend } = harness;
+ await waitForDislikeCount(page, 25);
+
+ await visibleVoteButton(page, "dislike").tap();
+ await expect.poll(() => backend.requestsFor("POST", "/interact/confirmVote").length).toBe(1);
+ await page.waitForTimeout(150);
+
+ expect(backend.requestsFor("POST", "/interact/vote").map((request) => request.body.value)).toEqual([-1]);
+ await expectNoRuntimeFailures(harness);
+ });
+
+ test("mobile Shorts boot and vote through the active mobile controls", async ({ context, page }) => {
+ const harness = await launchHarness(
+ { context, page },
+ {
+ gmValues: { [CREDENTIAL_KEY]: EXISTING_CREDENTIALS },
+ hostname: "m.youtube.com",
+ pageKind: "shorts",
+ },
+ );
+ const { backend } = harness;
+ await waitForDislikeCount(page, 25);
+
+ await visibleVoteButton(page, "dislike").tap();
+ await expect.poll(() => backend.requestsFor("POST", "/interact/confirmVote").length).toBe(1);
+
+ expect(backend.requestsFor("POST", "/interact/vote")[0].body).toMatchObject({
+ videoId: VIDEO_A,
+ value: -1,
+ });
+ await expectNoRuntimeFailures(harness);
+ });
+});
diff --git a/Extensions/UserScript/live/live-diagnostics.spec.js b/Extensions/UserScript/live/live-diagnostics.spec.js
new file mode 100644
index 0000000..62d5739
--- /dev/null
+++ b/Extensions/UserScript/live/live-diagnostics.spec.js
@@ -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 = `
+
+ Short
+
+
+
+ 123
+
+
+
+ `;
+ 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: "", 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: "", userId: "", videoId: "abcdefghijk" },
+ });
+ });
+});
diff --git a/Extensions/UserScript/live/live-options.js b/Extensions/UserScript/live/live-options.js
new file mode 100644
index 0000000..83325ce
--- /dev/null
+++ b/Extensions/UserScript/live/live-options.js
@@ -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,
+};
diff --git a/Extensions/UserScript/live/live-options.spec.js b/Extensions/UserScript/live/live-options.spec.js
new file mode 100644
index 0000000..70de12c
--- /dev/null
+++ b/Extensions/UserScript/live/live-options.spec.js
@@ -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 });
+ }
+ });
+});
diff --git a/Extensions/UserScript/live/live-readonly-guard.spec.js b/Extensions/UserScript/live/live-readonly-guard.spec.js
new file mode 100644
index 0000000..96d0b3b
--- /dev/null
+++ b/Extensions/UserScript/live/live-readonly-guard.spec.js
@@ -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([]);
+ });
+});
diff --git a/Extensions/UserScript/live/live-scenarios.spec.js b/Extensions/UserScript/live/live-scenarios.spec.js
new file mode 100644
index 0000000..2f01432
--- /dev/null
+++ b/Extensions/UserScript/live/live-scenarios.spec.js
@@ -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);
+ });
+});
diff --git a/Extensions/UserScript/live/live-vote-traffic-recorder.spec.js b/Extensions/UserScript/live/live-vote-traffic-recorder.spec.js
new file mode 100644
index 0000000..368752f
--- /dev/null
+++ b/Extensions/UserScript/live/live-vote-traffic-recorder.spec.js
@@ -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/,
+ );
+ });
+});
diff --git a/Extensions/UserScript/live/live-youtube-driver.spec.js b/Extensions/UserScript/live/live-youtube-driver.spec.js
new file mode 100644
index 0000000..8fab4f1
--- /dev/null
+++ b/Extensions/UserScript/live/live-youtube-driver.spec.js
@@ -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 = `
+
+ ${href ? `` : ""}
+
+
+ `;
+ 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 = `
+
+ `;
+
+ expect(readDislikeControlText(document.querySelector("button"))).toBe("1.2K");
+ });
+
+ test("continues to read native dislike text from the button", () => {
+ document.body.innerHTML = ``;
+
+ 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);
+ });
+});
diff --git a/Extensions/UserScript/src/gm-credential-store.js b/Extensions/UserScript/src/gm-credential-store.js
new file mode 100644
index 0000000..7c787b0
--- /dev/null
+++ b/Extensions/UserScript/src/gm-credential-store.js
@@ -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 };
diff --git a/Extensions/UserScript/src/gm-credential-store.spec.js b/Extensions/UserScript/src/gm-credential-store.spec.js
new file mode 100644
index 0000000..10c9fae
--- /dev/null
+++ b/Extensions/UserScript/src/gm-credential-store.spec.js
@@ -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",
+ );
+ });
+});
diff --git a/Extensions/UserScript/src/gm-synthetic-dislike-store.js b/Extensions/UserScript/src/gm-synthetic-dislike-store.js
new file mode 100644
index 0000000..9d5e9e9
--- /dev/null
+++ b/Extensions/UserScript/src/gm-synthetic-dislike-store.js
@@ -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 };
diff --git a/Extensions/UserScript/src/gm-synthetic-dislike-store.spec.js b/Extensions/UserScript/src/gm-synthetic-dislike-store.spec.js
new file mode 100644
index 0000000..8885a2f
--- /dev/null
+++ b/Extensions/UserScript/src/gm-synthetic-dislike-store.spec.js
@@ -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();
+ });
+});
diff --git a/Extensions/UserScript/src/userscript-entry.js b/Extensions/UserScript/src/userscript-entry.js
new file mode 100644
index 0000000..c6976d9
--- /dev/null
+++ b/Extensions/UserScript/src/userscript-entry.js
@@ -0,0 +1,2336 @@
+import { createVoteClient } from "../../common/vote-client";
+import {
+ LIKED_STATE,
+ DISLIKED_STATE,
+ NEUTRAL_STATE,
+ LIKE_ACTION,
+ DISLIKE_ACTION,
+ resolveVoteTransition,
+ applyVoteTransitionCounts,
+ shouldSubmitVote,
+} from "../../common/vote-transition";
+import { createGmCredentialStore } from "./gm-credential-store";
+import { createGmSyntheticDislikeStore } from "./gm-synthetic-dislike-store";
+import USER_SCRIPT_VERSION from "../userscript-version.json";
+
+if (__RYD_LIVE_TEST_BUILD__) {
+ document.documentElement.setAttribute("data-ryd-userscript-version", USER_SCRIPT_VERSION);
+ document.documentElement.setAttribute("data-ryd-userscript-build", __RYD_LIVE_BUILD_ID__);
+}
+
+const API_BASE_URL = "https://returnyoutubedislikeapi.com";
+const fetchImpl = globalThis.fetch.bind(globalThis);
+const voteClient = createVoteClient({
+ apiBaseUrl: API_BASE_URL,
+ fetchImpl,
+ credentialStore: createGmCredentialStore(),
+ cryptoImpl: globalThis.crypto,
+});
+const syntheticDislikeStore = createGmSyntheticDislikeStore();
+
+const extConfig = {
+ // BEGIN USER OPTIONS
+ // You may change the following variables to allowed values listed in the corresponding brackets (* means default). Keep the style and keywords intact.
+ showUpdatePopup: false, // [true, false*] Show a popup tab after extension update (See what's new)
+ disableVoteSubmission: false, // [true, false*] Disable like/dislike submission (Stops counting your likes and dislikes)
+ disableLogging: true, // [true*, false] Disable Logging API Response in JavaScript Console.
+ coloredThumbs: false, // [true, false*] Colorize thumbs (Use custom colors for thumb icons)
+ coloredBar: false, // [true, false*] Colorize ratio bar (Use custom colors for ratio bar)
+ colorTheme: "classic", // [classic*, accessible, neon] Color theme (red/green, blue/yellow, pink/cyan)
+ numberDisplayFormat: "compactShort", // [compactShort*, compactLong, standard] Number format (For non-English locale users, you may be able to improve appearance with a different option. Please file a feature request if your locale is not covered)
+ numberDisplayRoundDown: true, // [true*, false] Round down numbers (Show rounded down numbers)
+ tooltipPercentageMode: "none", // [none*, dash_like, dash_dislike, both, only_like, only_dislike] Mode of showing percentage in like/dislike bar tooltip.
+ numberDisplayReformatLikes: false, // [true, false*] Re-format like numbers (Make likes and dislikes format consistent)
+ rateBarEnabled: true, // [true*, false] Enables ratio bar under like/dislike buttons
+ // END USER OPTIONS
+};
+
+let previousState = NEUTRAL_STATE;
+let likesvalue = 0;
+let dislikesvalue = 0;
+
+let isMobile = location.hostname == "m.youtube.com";
+
+function getShortVideoIdFromPathname(pathname) {
+ return pathname.match(/^\/shorts\/([^/]+)\/?$/)?.[1] ?? null;
+}
+
+let isShorts = () => getShortVideoIdFromPathname(location.pathname) !== null;
+let mobileDislikes = 0;
+let suppressNextLikeActivation = false;
+const boundLikeButtons = new WeakSet();
+const boundDislikeButtons = new WeakSet();
+const boundActivationVideoIds = new WeakMap();
+const suppressedStaleRefreshTargets = new WeakSet();
+const removingSyntheticShortsDislikes = new WeakSet();
+const pendingWatchControlResets = new Map();
+let pendingWatchNavigationBoundary = null;
+const hydratingShortsActivationTargets = new WeakMap();
+const shortsHydrationTails = new Map();
+let shortsLifecycleObserver = null;
+let shortsLifecycleObserverTarget = null;
+let initializationGeneration = 0;
+let initializationTimer = null;
+let activeCountRequest = null;
+let countStateVideoId = null;
+let countStateLoaded = false;
+let countStateEpoch = 0;
+let shortsSubmittedStateVideoId = null;
+let shortsSubmittedState = NEUTRAL_STATE;
+let watchRateBarObserver = null;
+let watchRateBarObserverTarget = null;
+let watchRateBarObserverVideoId = null;
+let watchRateBarRepairTimer = null;
+const SYNTHETIC_SHORTS_DISLIKE_SELECTOR = "[data-ryd-synthetic-shorts-dislike]";
+const SHORTS_DISLIKE_ICON_PATH =
+ "m8.482 1.5.294.005a9.01 9.01 0 013.918 1.04l.257.143.203.116c.17.097.357.16.55.185l.194.012h1.477l.115.006c.53.054.95.475 1.004 1.005l.006.114v4.499c0 .621-.504 1.125-1.125 1.125h-1.343a.75.75 0 00-.66.395l-.048.107-2.24 6.402a.75.75 0 01-.832.491l-.78-.13a3 3 0 01-2.439-3.587L7.5 11.25H4.454a2.749 2.749 0 01-2.683-2.151 2.762 2.762 0 01.479-2.237l-.016-.065A2.862 2.862 0 013 4.125v-.032c0-.227.037-.453.108-.668l.08-.211A2.816 2.816 0 015.78 1.5h2.703ZM5.78 3c-.566 0-1.069.362-1.248.9a.613.613 0 00-.031.193v.654l-.44.44c-.333.332-.47.813-.364 1.271l.015.065.157.675-.413.557a1.248 1.248 0 00.999 1.995H7.5a1.501 1.501 0 011.467 1.815L8.5 13.742a1.5 1.5 0 001.22 1.794l.157.027 2.031-5.806a2.25 2.25 0 012.124-1.507H15V4.501h-1.102a3.001 3.001 0 01-1.489-.396l-.202-.116A7.504 7.504 0 008.482 3H5.78Z";
+function cLog(text, subtext = "") {
+ if (!extConfig.disableLogging) {
+ subtext = subtext.trim() === "" ? "" : `(${subtext})`;
+ console.log(`[Return YouTube Dislikes] ${text} ${subtext}`);
+ }
+}
+
+function isInViewport(element) {
+ const rect = element.getBoundingClientRect();
+ const height = innerHeight || document.documentElement.clientHeight;
+ const width = innerWidth || document.documentElement.clientWidth;
+ return (
+ // When short (channel) is ignored, the element (like/dislike AND short itself) is
+ // hidden with a 0 DOMRect. In this case, consider it outside of Viewport
+ !(rect.top == 0 && rect.left == 0 && rect.bottom == 0 && rect.right == 0) &&
+ rect.top >= 0 &&
+ rect.left >= 0 &&
+ rect.bottom <= height &&
+ rect.right <= width
+ );
+}
+
+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 hasRenderedBox(element) {
+ if (!element?.isConnected || element.closest("[hidden], [aria-hidden='true'], [inert]")) {
+ return false;
+ }
+ for (let current = element; current; current = current.parentElement) {
+ const style = 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 getRendererShortVideoIds(renderer) {
+ const identities = new Set();
+ const attributeVideoId = renderer.getAttribute("video-id");
+ if (attributeVideoId) {
+ identities.add(attributeVideoId);
+ return identities;
+ }
+ for (const link of renderer.querySelectorAll('a[href*="/shorts/"]')) {
+ try {
+ const linkVideoId = getShortVideoIdFromPathname(new URL(link.getAttribute("href"), location.origin).pathname);
+ if (linkVideoId) {
+ identities.add(linkVideoId);
+ }
+ } catch {
+ // Ignore malformed or incomplete links while YouTube hydrates the reel.
+ }
+ }
+ return identities;
+}
+
+function rendererMatchesShort(renderer, videoId) {
+ return Boolean(videoId) && getRendererShortVideoIds(renderer).has(videoId);
+}
+
+function getControlOwnershipVideoIds(container) {
+ const identities = new Set();
+ if (!container) {
+ return identities;
+ }
+ const ownedElements = [container, ...container.querySelectorAll("[data-ryd-video-id]")];
+ for (const element of ownedElements) {
+ const ownedVideoId = element.getAttribute?.("data-ryd-video-id");
+ if (ownedVideoId) {
+ identities.add(ownedVideoId);
+ }
+ }
+ for (const target of container.querySelectorAll("button, tp-yt-paper-button#button")) {
+ const boundVideoId = boundActivationVideoIds.get(target);
+ if (boundVideoId) {
+ identities.add(boundVideoId);
+ }
+ }
+ return identities;
+}
+
+function hasConflictingControlOwnership(container, videoId) {
+ return Array.from(getControlOwnershipVideoIds(container)).some((ownedVideoId) => ownedVideoId !== videoId);
+}
+
+function clearPendingWatchControlObservers() {
+ for (const resetState of pendingWatchControlResets.values()) {
+ resetState.observer.disconnect();
+ }
+ pendingWatchControlResets.clear();
+}
+
+function clearPendingWatchNavigationBoundary() {
+ pendingWatchNavigationBoundary?.observer?.disconnect();
+ pendingWatchNavigationBoundary = null;
+}
+
+function activationTargetHasVideoIdentity(target, videoId, buttons) {
+ let current = target;
+ while (current) {
+ if (current.getAttribute?.("video-id") === videoId || current.getAttribute?.("data-video-id") === videoId) {
+ return true;
+ }
+ if (current === buttons) {
+ break;
+ }
+ current = current.parentElement;
+ }
+ return false;
+}
+
+function watchTargetIsReady(targetState, buttons, videoId) {
+ return (
+ !buttons.contains(targetState.activationTarget) ||
+ activationTargetHasVideoIdentity(targetState.activationTarget, videoId, buttons) ||
+ targetState.refreshed
+ );
+}
+
+function originalWatchTargetsAreReady(resetState, buttons, videoId) {
+ return [resetState.like, resetState.dislike].every((targetState) =>
+ watchTargetIsReady(targetState, buttons, videoId),
+ );
+}
+
+function currentWatchTargetIsReady(target, targetState, buttons, videoId) {
+ const boundVideoId = boundActivationVideoIds.get(target);
+ return (
+ !boundVideoId ||
+ boundVideoId === videoId ||
+ activationTargetHasVideoIdentity(target, videoId, buttons) ||
+ (target === targetState.activationTarget && targetState.refreshed)
+ );
+}
+
+function elementIsOwnedDisplayMutation(element, targetState) {
+ if (!element || !targetState.host.contains(element)) {
+ return false;
+ }
+ return Boolean(
+ element.closest(
+ "#text, [role='text'], yt-formatted-string, #return-youtube-dislike-bar-container, #return-youtube-dislike-bar, .ryd-tooltip, [data-ryd-synthetic-shorts-dislike]",
+ ),
+ );
+}
+
+function mutationIsMeaningfulWatchRefresh(mutation, targetState, videoId, buttons) {
+ if (suppressedStaleRefreshTargets.has(targetState.activationTarget)) {
+ return false;
+ }
+ const mutationElement =
+ mutation.target.nodeType === Node.ELEMENT_NODE ? mutation.target : mutation.target.parentElement;
+ if (!mutationElement || !targetState.host.contains(mutationElement)) {
+ return false;
+ }
+
+ if (mutation.type === "attributes") {
+ if (["data-video-id", "video-id"].includes(mutation.attributeName)) {
+ return activationTargetHasVideoIdentity(targetState.activationTarget, videoId, buttons);
+ }
+ return (
+ ["aria-disabled", "aria-label", "disabled", "title"].includes(mutation.attributeName) &&
+ (mutationElement === targetState.activationTarget || mutationElement === targetState.host)
+ );
+ }
+
+ if (mutation.type !== "childList" || elementIsOwnedDisplayMutation(mutationElement, targetState)) {
+ return false;
+ }
+ const changedElements = [...mutation.addedNodes, ...mutation.removedNodes]
+ .map((node) => (node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement))
+ .filter(Boolean);
+ return changedElements.some((element) => !elementIsOwnedDisplayMutation(element, targetState));
+}
+
+function captureMeaningfulWatchRefreshes(resetState, mutations, videoId, buttons) {
+ for (const targetState of [resetState.like, resetState.dislike]) {
+ if (
+ !targetState.refreshed &&
+ mutations.some((mutation) => mutationIsMeaningfulWatchRefresh(mutation, targetState, videoId, buttons))
+ ) {
+ targetState.refreshed = true;
+ }
+ }
+}
+
+function captureWatchNavigationBoundaryRefreshes(boundary, mutations) {
+ for (const targetState of [boundary.like, boundary.dislike]) {
+ if (
+ !targetState.refreshed &&
+ mutations.some((mutation) => mutationIsMeaningfulWatchRefresh(mutation, targetState, "", boundary.buttons))
+ ) {
+ targetState.refreshed = true;
+ }
+ }
+}
+
+function seedWatchTargetFromNavigationBoundary(targetState, boundaryTargetState, buttons, videoId) {
+ if (targetState.activationTarget === boundaryTargetState.activationTarget) {
+ targetState.refreshed ||= boundaryTargetState.refreshed;
+ return;
+ }
+
+ const boundVideoId = boundActivationVideoIds.get(targetState.activationTarget);
+ targetState.refreshed ||=
+ !boundVideoId ||
+ boundVideoId === videoId ||
+ activationTargetHasVideoIdentity(targetState.activationTarget, videoId, buttons);
+}
+
+function seedWatchResetFromNavigationBoundary(resetState, buttons, videoId) {
+ const boundary = pendingWatchNavigationBoundary;
+ if (!boundary || boundary.sourceVideoId === videoId) {
+ return;
+ }
+
+ captureWatchNavigationBoundaryRefreshes(boundary, boundary.observer.takeRecords());
+ clearPendingWatchNavigationBoundary();
+ if (boundary.buttons !== buttons) {
+ return;
+ }
+
+ seedWatchTargetFromNavigationBoundary(resetState.like, boundary.like, buttons, videoId);
+ seedWatchTargetFromNavigationBoundary(resetState.dislike, boundary.dislike, buttons, videoId);
+}
+
+function suppressStaleTargetRefresh(target) {
+ suppressedStaleRefreshTargets.add(target);
+ setTimeout(() => suppressedStaleRefreshTargets.delete(target), 0);
+}
+
+function watchControlsAreReadyForVideo(buttons, likeButton, dislikeButton, videoId) {
+ if (!hasConflictingControlOwnership(buttons, videoId)) {
+ clearPendingWatchControlObservers();
+ if (pendingWatchNavigationBoundary?.sourceVideoId !== videoId) {
+ clearPendingWatchNavigationBoundary();
+ }
+ return true;
+ }
+
+ // YouTube occasionally completes a watch-to-watch navigation while reusing
+ // the exact same reaction-control nodes and without mutating anything inside
+ // them. In that state the old per-node ownership is the only conflicting
+ // signal, so waiting for a control mutation can never finish. A completed
+ // navigation plus a matching current watch root is the route-level ownership
+ // proof for this otherwise indistinguishable case.
+ const watchRoot = buttons.closest("ytd-watch-flexy, ytd-watch-grid");
+ if (
+ pendingWatchNavigationBoundary?.completedVideoId === videoId &&
+ pendingWatchNavigationBoundary.sourceVideoId !== videoId &&
+ pendingWatchNavigationBoundary.buttons === buttons &&
+ watchRoot?.getAttribute("video-id") === videoId &&
+ getButtons() === buttons &&
+ hasRenderedBox(buttons)
+ ) {
+ clearPendingWatchControlObservers();
+ clearPendingWatchNavigationBoundary();
+ return true;
+ }
+
+ const existingReset = pendingWatchControlResets.get(buttons);
+ if (existingReset?.videoId === videoId) {
+ seedWatchResetFromNavigationBoundary(existingReset, buttons, videoId);
+ if (!originalWatchTargetsAreReady(existingReset, buttons, videoId)) {
+ return false;
+ }
+
+ const currentLikeButton = getLikeButton();
+ const currentDislikeButton = getDislikeButton();
+ if (!currentLikeButton || !currentDislikeButton || getButtons() !== buttons) {
+ return false;
+ }
+ const currentLikeTarget = getActivationTarget(currentLikeButton);
+ const currentDislikeTarget = getActivationTarget(currentDislikeButton);
+ if (
+ !currentWatchTargetIsReady(currentLikeTarget, existingReset.like, buttons, videoId) ||
+ !currentWatchTargetIsReady(currentDislikeTarget, existingReset.dislike, buttons, videoId)
+ ) {
+ return false;
+ }
+
+ existingReset.observer.disconnect();
+ pendingWatchControlResets.delete(buttons);
+ return true;
+ }
+
+ existingReset?.observer.disconnect();
+ const likeActivationTarget = getActivationTarget(likeButton);
+ const dislikeActivationTarget = getActivationTarget(dislikeButton);
+ const resetState = {
+ dislike: {
+ activationTarget: dislikeActivationTarget,
+ host: dislikeButton,
+ refreshed: false,
+ },
+ like: {
+ activationTarget: likeActivationTarget,
+ host: likeButton,
+ refreshed: false,
+ },
+ observer: null,
+ videoId,
+ };
+ seedWatchResetFromNavigationBoundary(resetState, buttons, videoId);
+ if (
+ originalWatchTargetsAreReady(resetState, buttons, videoId) &&
+ currentWatchTargetIsReady(likeActivationTarget, resetState.like, buttons, videoId) &&
+ currentWatchTargetIsReady(dislikeActivationTarget, resetState.dislike, buttons, videoId)
+ ) {
+ pendingWatchControlResets.delete(buttons);
+ return true;
+ }
+ const observer = new MutationObserver((mutations) => {
+ captureMeaningfulWatchRefreshes(resetState, mutations, videoId, buttons);
+ if (originalWatchTargetsAreReady(resetState, buttons, videoId)) {
+ observer.disconnect();
+ setEventListeners();
+ }
+ });
+ resetState.observer = observer;
+ pendingWatchControlResets.set(buttons, resetState);
+ observer.observe(buttons, {
+ attributeFilter: ["aria-disabled", "aria-label", "data-video-id", "disabled", "title", "video-id"],
+ attributes: true,
+ childList: true,
+ subtree: true,
+ });
+ return false;
+}
+
+function getActiveDesktopShortsActionBar() {
+ const videoId = getVideoId();
+ const candidates = Array.from(document.querySelectorAll("ytd-reel-video-renderer"))
+ .filter((renderer) => intersectsViewport(renderer))
+ .map((renderer) => ({
+ actionBar: renderer.querySelector("reel-action-bar-view-model"),
+ renderer,
+ }))
+ .filter(({ actionBar }) => actionBar);
+
+ const matchingCandidate = candidates.find(({ renderer }) => rendererMatchesShort(renderer, videoId));
+ if (matchingCandidate) {
+ return matchingCandidate.actionBar;
+ }
+
+ // During channel/watch -> Shorts SPA transitions, YouTube can render the active
+ // reel before its video-id/link metadata is hydrated. A single visible reel is
+ // still unambiguous; waiting for metadata in this state leaves the controls
+ // permanently uninitialized on some page variants.
+ if (
+ candidates.length === 1 &&
+ getRendererShortVideoIds(candidates[0].renderer).size === 0 &&
+ !hasConflictingControlOwnership(candidates[0].actionBar, videoId)
+ ) {
+ return candidates[0].actionBar;
+ }
+
+ const fullyVisibleCandidates = candidates.filter(
+ ({ actionBar, renderer }) =>
+ isInViewport(renderer) &&
+ getRendererShortVideoIds(renderer).size === 0 &&
+ !hasConflictingControlOwnership(actionBar, videoId),
+ );
+ return fullyVisibleCandidates.length === 1 ? fullyVisibleCandidates[0].actionBar : null;
+}
+
+function getActiveMobileShortsButtons() {
+ const videoId = getVideoId();
+ const candidates = Array.from(document.querySelectorAll("ytm-like-button-renderer"))
+ .filter((buttons) => isInViewport(buttons))
+ .map((buttons) => ({ buttons, ...getMobileShortOwnership(buttons) }));
+
+ const matchingCandidates = candidates.filter(({ identities }) => identities.has(videoId));
+ if (matchingCandidates.length === 1) {
+ return matchingCandidates[0].buttons;
+ }
+
+ if (
+ candidates.length === 1 &&
+ candidates[0].identities.size === 0 &&
+ !hasConflictingControlOwnership(candidates[0].buttons, videoId)
+ ) {
+ return candidates[0].buttons;
+ }
+ return null;
+}
+
+function getExactShortLinkVideoIds(element) {
+ const identities = new Set();
+ for (const link of element.querySelectorAll('a[href*="/shorts/"]')) {
+ try {
+ const linkVideoId = getShortVideoIdFromPathname(new URL(link.getAttribute("href"), location.origin).pathname);
+ if (linkVideoId) {
+ identities.add(linkVideoId);
+ }
+ } catch {
+ // Ignore malformed or incomplete links while YouTube hydrates the reel.
+ }
+ }
+ return identities;
+}
+
+function getMobileShortOwnership(buttons) {
+ const ancestors = [];
+ let current = buttons;
+ while (current && current !== document.body) {
+ if (current.matches("ytm-shorts, ytm-shorts-container, #shorts-container, #shorts-inner-container")) {
+ break;
+ }
+ ancestors.push(current);
+ current = current.parentElement;
+ }
+
+ for (const ancestor of ancestors) {
+ const attributeVideoId = ancestor.getAttribute("video-id") || ancestor.getAttribute("data-video-id");
+ if (attributeVideoId) {
+ return { identities: new Set([attributeVideoId]), owner: ancestor };
+ }
+ }
+
+ for (const ancestor of ancestors) {
+ const identities = getExactShortLinkVideoIds(ancestor);
+ if (identities.size > 0) {
+ return { identities, owner: ancestor };
+ }
+ }
+
+ const owner =
+ ancestors.find((ancestor) => ancestor.matches("ytm-reel-video-renderer, ytm-shorts-video-renderer")) ??
+ ancestors.find((ancestor) => ancestor.matches("ytm-reel-player-overlay-renderer")) ??
+ buttons;
+ return { identities: new Set(), owner };
+}
+
+function getDesktopWatchButtonCandidates() {
+ return Array.from(
+ new Set(
+ document.querySelectorAll(
+ "#menu-container #top-level-buttons-computed, ytd-menu-renderer.ytd-watch-metadata > div, ytd-menu-renderer.ytd-video-primary-info-renderer > div",
+ ),
+ ),
+ ).filter((candidate) =>
+ candidate.querySelector(
+ "segmented-like-dislike-button-view-model, ytd-segmented-like-dislike-button-renderer, like-button-view-model, #segmented-like-button",
+ ),
+ );
+}
+
+function selectCurrentWatchButtons(candidates) {
+ const videoId = getVideoId();
+ return (
+ 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 rendered = hasRenderedBox(candidate);
+ const inViewport = rendered && intersectsViewport(candidate);
+ const conflicts = Boolean(videoId && hasConflictingControlOwnership(candidate, videoId));
+ // YouTube can retain several button groups under the same current
+ // ytd-watch-flexy while an SPA navigation settles. A matching root is
+ // therefore useful ownership evidence, but it must never make a
+ // hidden stale group outrank the rendered controls the user can see.
+ const tier =
+ rootMatches && inViewport && !conflicts
+ ? 10
+ : rootMatches && inViewport
+ ? 9
+ : inViewport && !conflicts
+ ? 8
+ : inViewport
+ ? 7
+ : rootMatches && rendered && !conflicts
+ ? 6
+ : rootMatches && rendered
+ ? 5
+ : rendered && !conflicts
+ ? 4
+ : rendered
+ ? 3
+ : rootMatches && !conflicts
+ ? 2
+ : !conflicts
+ ? 1
+ : 0;
+ return { candidate, index, tier };
+ })
+ .sort((left, right) => right.tier - left.tier || left.index - right.index)[0]?.candidate ?? null
+ );
+}
+
+function getButtons() {
+ if (isShorts()) {
+ if (!isMobile) {
+ const actionBar = getActiveDesktopShortsActionBar();
+ if (actionBar) {
+ return actionBar;
+ }
+ } else {
+ const buttons = getActiveMobileShortsButtons();
+ if (buttons) {
+ return buttons;
+ }
+ }
+
+ // Never bind watch/channel controls that are still connected while a Shorts
+ // SPA route is mounting. The initialization retry loop will pick up the real
+ // reel controls as soon as they are available.
+ return null;
+ }
+ if (isMobile) {
+ return (
+ document.querySelector(".slim-video-action-bar-actions .segmented-buttons") ??
+ document.querySelector(".slim-video-action-bar-actions")
+ );
+ }
+ return selectCurrentWatchButtons(getDesktopWatchButtonCandidates());
+}
+
+function removeSyntheticShortsDislike(syntheticDislike) {
+ if (!syntheticDislike || removingSyntheticShortsDislikes.has(syntheticDislike)) {
+ return;
+ }
+ removingSyntheticShortsDislikes.add(syntheticDislike);
+ try {
+ syntheticDislike.remove();
+ } finally {
+ removingSyntheticShortsDislikes.delete(syntheticDislike);
+ }
+}
+
+function ensureSyntheticShortsDislikeButton(buttons) {
+ if (!isShorts() || isMobile || !buttons) {
+ return;
+ }
+
+ const syntheticDislike = buttons.querySelector(SYNTHETIC_SHORTS_DISLIKE_SELECTOR);
+ const nativeDislike = buttons.querySelector("dislike-button-view-model, #dislike-button");
+ if (nativeDislike) {
+ removeSyntheticShortsDislike(syntheticDislike);
+ return;
+ }
+ if (syntheticDislike) {
+ const videoId = getVideoId();
+ if (videoId && syntheticDislike.getAttribute("data-ryd-video-id") !== videoId) {
+ syntheticDislike.setAttribute("data-ryd-video-id", videoId);
+ setSyntheticShortsPressed(false, syntheticDislike);
+ const button = syntheticDislike.querySelector("button");
+ if (button) {
+ button.disabled = true;
+ button.setAttribute("aria-disabled", "true");
+ }
+ const count = syntheticDislike.querySelector("#text, [role='text']");
+ if (count) {
+ count.textContent = "";
+ }
+ }
+ return;
+ }
+
+ const likeButton = buttons.querySelector("like-button-view-model");
+ const nativeLikeButton = likeButton?.querySelector("button");
+ if (!likeButton || !nativeLikeButton) {
+ return;
+ }
+
+ const ownedDislike = document.createElement("div");
+ ownedDislike.className = likeButton.getAttribute("class") || "";
+ ownedDislike.setAttribute("data-ryd-synthetic-shorts-dislike", "true");
+ ownedDislike.setAttribute("data-ryd-role", "dislike");
+ ownedDislike.setAttribute("data-ryd-video-id", getVideoId());
+ ownedDislike.classList.add("ryd-synthetic-shorts-dislike");
+
+ const button = document.createElement("button");
+ button.type = "button";
+ button.className = nativeLikeButton.className;
+ button.setAttribute("aria-label", "Dislike this video");
+ button.setAttribute("aria-pressed", "false");
+ button.setAttribute("aria-disabled", "true");
+ button.disabled = true;
+
+ const icon = document.createElement("div");
+ icon.className =
+ nativeLikeButton.querySelector(".ytSpecButtonShapeNextIcon")?.getAttribute("class") || "ytSpecButtonShapeNextIcon";
+ icon.setAttribute("aria-hidden", "true");
+ const svgNamespace = "http://www.w3.org/2000/svg";
+ const svg = document.createElementNS(svgNamespace, "svg");
+ svg.setAttribute("height", "24");
+ svg.setAttribute("viewBox", "0 0 18 18");
+ svg.setAttribute("width", "24");
+ svg.setAttribute("focusable", "false");
+ svg.setAttribute("aria-hidden", "true");
+ const path = document.createElementNS(svgNamespace, "path");
+ path.setAttribute("d", SHORTS_DISLIKE_ICON_PATH);
+ svg.appendChild(path);
+ icon.appendChild(svg);
+
+ const countContainer = document.createElement("div");
+ countContainer.className =
+ likeButton.querySelector(".ytSpecButtonShapeWithLabelLabel")?.className || "ytSpecButtonShapeWithLabelLabel";
+ const count = document.createElement("span");
+ count.id = "text";
+ count.className =
+ likeButton.querySelector('span[role="text"]')?.className ||
+ "ytAttributedStringHost ytAttributedStringTextAlignmentCenter";
+ count.setAttribute("role", "text");
+ countContainer.appendChild(count);
+ button.appendChild(icon);
+ const buttonAndCount = document.createElement("label");
+ buttonAndCount.className = nativeLikeButton.closest("label")?.className || "ytSpecButtonShapeWithLabelHost";
+ buttonAndCount.classList.add("ryd-synthetic-shorts-dislike-label");
+ buttonAndCount.append(button, countContainer);
+ ownedDislike.appendChild(buttonAndCount);
+ setSyntheticShortsPressed(false, ownedDislike);
+ likeButton.insertAdjacentElement("afterend", ownedDislike);
+}
+
+function getDislikeButton() {
+ const buttons = getButtons();
+ ensureSyntheticShortsDislikeButton(buttons);
+ if (buttons?.tagName === "REEL-ACTION-BAR-VIEW-MODEL") {
+ return (
+ buttons.querySelector("dislike-button-view-model, #dislike-button") ??
+ buttons.querySelector(SYNTHETIC_SHORTS_DISLIKE_SELECTOR)
+ );
+ }
+ const firstButton = buttons?.children?.[0];
+ if (!firstButton) {
+ return null;
+ }
+
+ if (firstButton.tagName === "YTD-SEGMENTED-LIKE-DISLIKE-BUTTON-RENDERER") {
+ if (firstButton.children[1] === undefined) {
+ return buttons.querySelector("#segmented-dislike-button");
+ } else {
+ return firstButton.children[1];
+ }
+ } else {
+ if (buttons.querySelector("segmented-like-dislike-button-view-model")) {
+ const dislikeViewModel = buttons.querySelector("dislike-button-view-model");
+ if (!dislikeViewModel) cLog("Dislike button wasn't added to DOM yet...");
+ return dislikeViewModel;
+ } else {
+ return buttons.children[1] ?? null;
+ }
+ }
+}
+
+function getLikeButton() {
+ const buttons = getButtons();
+ const firstButton = buttons?.children?.[0];
+ if (!firstButton) {
+ return null;
+ }
+
+ return firstButton.tagName === "YTD-SEGMENTED-LIKE-DISLIKE-BUTTON-RENDERER"
+ ? buttons.querySelector("#segmented-like-button") !== null
+ ? buttons.querySelector("#segmented-like-button")
+ : firstButton.children[0]
+ : buttons.querySelector("like-button-view-model") ?? firstButton;
+}
+
+function getLikeTextContainer() {
+ return (
+ getLikeButton().querySelector("#text") ??
+ getLikeButton().getElementsByTagName("yt-formatted-string")[0] ??
+ getLikeButton().querySelector("span[role='text']")
+ );
+}
+
+function getDislikeTextContainer() {
+ const dislikeButton = getDislikeButton();
+ let result =
+ dislikeButton?.querySelector("#text") ??
+ dislikeButton?.getElementsByTagName("yt-formatted-string")[0] ??
+ dislikeButton?.querySelector("span[role='text']");
+ if (result === null) {
+ let textSpan = document.createElement("span");
+ textSpan.id = "text";
+ textSpan.style.marginLeft = "6px";
+ dislikeButton?.querySelector("button").appendChild(textSpan);
+ if (dislikeButton) dislikeButton.querySelector("button").style.width = "auto";
+ result = textSpan;
+ }
+ return result;
+}
+
+function setSyntheticShortsPressed(pressed, dislikeButton = getDislikeButton()) {
+ if (!dislikeButton?.matches(SYNTHETIC_SHORTS_DISLIKE_SELECTOR)) {
+ return;
+ }
+ dislikeButton.classList.toggle("style-default-active", pressed);
+ dislikeButton.classList.toggle("style-text", !pressed);
+ dislikeButton.querySelector("button")?.setAttribute("aria-pressed", String(pressed));
+}
+
+function persistSyntheticShortsState(videoId, disliked) {
+ void syntheticDislikeStore.setDisliked(videoId, disliked).catch(reportVoteFailure);
+}
+
+async function readSyntheticShortsDisliked(videoId) {
+ try {
+ return await syntheticDislikeStore.isDisliked(videoId);
+ } catch (error) {
+ reportVoteFailure(error);
+ return false;
+ }
+}
+
+async function restoreSyntheticShortsState(
+ videoId,
+ dislikeButton = getDislikeButton(),
+ initialVisibleState = getState(),
+) {
+ if (!dislikeButton?.matches(SYNTHETIC_SHORTS_DISLIKE_SELECTOR)) {
+ return false;
+ }
+
+ const disliked = await readSyntheticShortsDisliked(videoId);
+
+ return {
+ disliked,
+ submittedState: initialVisibleState === LIKED_STATE ? LIKED_STATE : disliked ? DISLIKED_STATE : initialVisibleState,
+ };
+}
+
+function createObserver(options, callback) {
+ const observerWrapper = new Object();
+ observerWrapper.options = options;
+ observerWrapper.observer = new MutationObserver(callback);
+ observerWrapper.observe = function (element) {
+ this.observer.observe(element, this.options);
+ };
+ observerWrapper.disconnect = function () {
+ this.observer.disconnect();
+ };
+ return observerWrapper;
+}
+
+let shortsObserver = null;
+
+function getShortsObserver() {
+ if (shortsObserver) {
+ return shortsObserver;
+ }
+ cLog("Initializing shorts mutation observer");
+ shortsObserver = createObserver(
+ {
+ attributes: true,
+ attributeFilter: ["aria-pressed"],
+ },
+ (mutationList) => {
+ mutationList.forEach((mutation) => {
+ if (mutation.type === "attributes") {
+ cLog("Short thumb button status changed");
+ if (mutation.target.getAttribute("aria-pressed") === "true") {
+ mutation.target.style.color = mutation.target.closest("like-button-view-model")
+ ? getColorFromTheme(true)
+ : getColorFromTheme(false);
+ } else {
+ mutation.target.style.color = "unset";
+ }
+ }
+ });
+ },
+ );
+ return shortsObserver;
+}
+
+function isVideoLiked() {
+ const likeButton = getLikeButton();
+ const nativeButton = likeButton?.querySelector("button");
+ if (isMobile) {
+ return nativeButton?.getAttribute("aria-pressed") === "true" || nativeButton?.getAttribute("aria-label") === "true";
+ }
+ return (
+ likeButton?.classList.contains("style-default-active") || nativeButton?.getAttribute("aria-pressed") === "true"
+ );
+}
+
+function isVideoDisliked() {
+ const dislikeButton = getDislikeButton();
+ const nativeButton = dislikeButton?.querySelector("button");
+ if (isMobile) {
+ return nativeButton?.getAttribute("aria-pressed") === "true" || nativeButton?.getAttribute("aria-label") === "true";
+ }
+ return (
+ dislikeButton?.classList.contains("style-default-active") || nativeButton?.getAttribute("aria-pressed") === "true"
+ );
+}
+
+function isVideoNotLiked() {
+ if (isMobile) {
+ return !isVideoLiked();
+ }
+ return getLikeButton().classList.contains("style-text");
+}
+
+function isVideoNotDisliked() {
+ if (isMobile) {
+ return !isVideoDisliked();
+ }
+ return getDislikeButton()?.classList.contains("style-text");
+}
+
+function isSignedOut() {
+ const signInLink = document.querySelector("a[href^='https://accounts.google.com/ServiceLogin']");
+ return signInLink !== null || (!isMobile && document.querySelector("#avatar-btn") === null);
+}
+
+function getState() {
+ if (isVideoLiked()) {
+ return LIKED_STATE;
+ }
+ if (isVideoDisliked()) {
+ return DISLIKED_STATE;
+ }
+ return NEUTRAL_STATE;
+}
+
+function setLikes(likesCount) {
+ if (isMobile) {
+ getButtons().children[0].querySelector(".button-renderer-text").innerText = likesCount;
+ return;
+ }
+ getLikeTextContainer().innerText = likesCount;
+}
+
+function setDislikes(dislikesCount) {
+ if (isMobile) {
+ mobileDislikes = dislikesCount;
+ return;
+ }
+
+ const _container = getDislikeTextContainer();
+ if (!_container) {
+ return;
+ }
+ _container.removeAttribute("is-empty");
+ if (_container.innerText !== dislikesCount) {
+ _container.innerText = dislikesCount;
+ }
+}
+
+function getLikeCountFromButton() {
+ try {
+ if (isShorts()) {
+ //Youtube Shorts don't work with this query. It's not necessary; we can skip it and still see the results.
+ //It should be possible to fix this function, but it's not critical to showing the dislike count.
+ return false;
+ }
+ let likeButton =
+ getLikeButton().querySelector("yt-formatted-string#text") ?? getLikeButton().querySelector("button");
+
+ let likesStr = likeButton.getAttribute("aria-label").replace(/\D/g, "");
+ return likesStr.length > 0 ? parseInt(likesStr) : false;
+ } catch {
+ return false;
+ }
+}
+
+(typeof GM_addStyle != "undefined"
+ ? GM_addStyle
+ : (styles) => {
+ let styleNode = document.createElement("style");
+ styleNode.type = "text/css";
+ styleNode.innerText = styles;
+ document.head.appendChild(styleNode);
+ })(`
+ #return-youtube-dislike-bar-container {
+ background: #737373;
+ background: color-mix(
+ in srgb,
+ var(--yt-spec-text-primary, #f1f1f1) 55%,
+ var(--yt-spec-base-background, #0f0f0f) 45%
+ );
+ border-radius: 2px;
+ }
+
+ #return-youtube-dislike-bar {
+ background: var(--yt-spec-text-primary);
+ border-radius: 2px;
+ transition: all 0.15s ease-in-out;
+ }
+
+ .ryd-synthetic-shorts-dislike svg {
+ display: block;
+ fill: currentColor;
+ height: 24px;
+ pointer-events: none;
+ width: 24px;
+ }
+
+ .ryd-synthetic-shorts-dislike {
+ box-sizing: content-box;
+ display: block;
+ flex: 0 0 auto;
+ height: 70px;
+ margin: 0 !important;
+ padding: 0 0 8px;
+ width: 100%;
+ }
+
+ .ryd-synthetic-shorts-dislike-label {
+ align-items: center;
+ display: flex;
+ flex-direction: column;
+ }
+
+ .ryd-synthetic-shorts-dislike .ytSpecButtonShapeNextIcon {
+ flex: 0 0 24px;
+ height: 24px;
+ min-width: 24px;
+ width: 24px;
+ }
+
+ .ryd-synthetic-shorts-dislike button {
+ color: inherit;
+ cursor: pointer;
+ }
+
+ .ryd-synthetic-shorts-dislike button[aria-pressed="true"] {
+ color: var(--yt-spec-call-to-action, #3ea6ff);
+ }
+
+ .ryd-tooltip {
+ bottom: -10px;
+ display: block;
+ height: 2px;
+ outline: none;
+ position: absolute;
+ }
+
+ .ryd-tooltip-bar-container {
+ width: 100%;
+ height: 2px;
+ position: absolute;
+ padding-top: 6px;
+ padding-bottom: 12px;
+ top: -6px;
+ }
+
+ .ryd-tooltip-label {
+ background: rgba(28, 28, 28, 0.96);
+ border-radius: 4px;
+ bottom: 10px;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35);
+ box-sizing: border-box;
+ color: #fff;
+ font-family: Roboto, Arial, sans-serif;
+ font-size: 12px;
+ font-weight: 500;
+ line-height: 16px;
+ max-width: calc(100vw - 24px);
+ opacity: 0;
+ overflow: hidden;
+ padding: 6px 8px;
+ pointer-events: none;
+ position: absolute;
+ right: 0;
+ text-overflow: ellipsis;
+ transform: translateY(4px);
+ transition: opacity 0.12s ease-out, transform 0.12s ease-out, visibility 0s linear 0.12s;
+ visibility: hidden;
+ white-space: nowrap;
+ width: max-content;
+ z-index: 2200;
+ }
+
+ .ryd-tooltip:hover .ryd-tooltip-label,
+ .ryd-tooltip:focus-within .ryd-tooltip-label {
+ opacity: 1;
+ transform: translateY(0);
+ transition-delay: 0s;
+ visibility: visible;
+ }
+
+ .ryd-tooltip:focus-visible .ryd-tooltip-bar-container {
+ outline: 2px solid var(--yt-spec-call-to-action, #3ea6ff);
+ outline-offset: 2px;
+ }
+
+ ytd-menu-renderer.ytd-watch-metadata {
+ overflow-y: visible !important;
+ }
+
+ #top-level-buttons-computed {
+ position: relative !important;
+ }
+ `);
+
+function createRateBar(likes, dislikes) {
+ if (isMobile || isShorts() || !extConfig.rateBarEnabled) {
+ return;
+ }
+
+ const buttons = getButtons();
+ const likeButton = getLikeButton();
+ const dislikeButton = getDislikeButton();
+ if (!buttons || !likeButton) {
+ return;
+ }
+
+ // YouTube retains the outgoing watch metadata tree during some SPA
+ // transitions. A document-wide ID lookup can therefore find the old bar,
+ // update it with the new video's counts, and then lose it when YouTube
+ // removes that stale tree. Keep the single owned bar scoped to the active
+ // reaction controls instead.
+ for (const candidate of document.querySelectorAll("#return-youtube-dislike-bar-container")) {
+ if (!buttons.contains(candidate)) {
+ (candidate.closest(".ryd-tooltip") ?? candidate).remove();
+ }
+ }
+ let rateBar = buttons.querySelector("#return-youtube-dislike-bar-container");
+ if (rateBar && !watchRateBarIsHealthy(buttons, getVideoId())) {
+ removeWatchRateBarArtifacts(buttons);
+ rateBar = null;
+ }
+
+ const widthPx = likeButton.clientWidth + (dislikeButton?.clientWidth ?? 52);
+
+ const widthPercent = likes + dislikes > 0 ? (likes / (likes + dislikes)) * 100 : 50;
+
+ var likePercentage = parseFloat(widthPercent.toFixed(1));
+ const dislikePercentage = (100 - likePercentage).toLocaleString();
+ likePercentage = likePercentage.toLocaleString();
+
+ const separator = "\u00a0/\u00a0";
+ const percentageSeparator = "\u00a0\u00a0-\u00a0\u00a0";
+ let tooltipText;
+ switch (extConfig.tooltipPercentageMode) {
+ case "dash_like":
+ tooltipText = `${likes.toLocaleString()}${separator}${dislikes.toLocaleString()}${percentageSeparator}${likePercentage}%`;
+ break;
+ case "dash_dislike":
+ tooltipText = `${likes.toLocaleString()}${separator}${dislikes.toLocaleString()}${percentageSeparator}${dislikePercentage}%`;
+ break;
+ case "both":
+ tooltipText = `${likePercentage}%${separator}${dislikePercentage}%`;
+ break;
+ case "only_like":
+ tooltipText = `${likePercentage}%`;
+ break;
+ case "only_dislike":
+ tooltipText = `${dislikePercentage}%`;
+ break;
+ default:
+ tooltipText = `${likes.toLocaleString()}${separator}${dislikes.toLocaleString()}`;
+ }
+
+ if (!rateBar && !isMobile) {
+ const tooltip = document.createElement("div");
+ tooltip.className = "ryd-tooltip";
+ tooltip.setAttribute("data-ryd-rate-bar-wrapper", "true");
+ tooltip.setAttribute("data-ryd-video-id", getVideoId());
+ tooltip.style.width = `${widthPx}px`;
+ tooltip.setAttribute("aria-describedby", "ryd-dislike-tooltip");
+ tooltip.setAttribute("tabindex", "0");
+
+ const tooltipBarContainer = document.createElement("div");
+ tooltipBarContainer.className = "ryd-tooltip-bar-container";
+
+ rateBar = document.createElement("div");
+ rateBar.id = "return-youtube-dislike-bar-container";
+ rateBar.style.width = "100%";
+ rateBar.style.height = "2px";
+
+ const rateBarFill = document.createElement("div");
+ rateBarFill.id = "return-youtube-dislike-bar";
+ rateBarFill.style.width = `${widthPercent}%`;
+ rateBarFill.style.height = "100%";
+ if (extConfig.coloredBar) {
+ rateBar.style.backgroundColor = getColorFromTheme(false);
+ rateBarFill.style.backgroundColor = getColorFromTheme(true);
+ }
+ rateBar.appendChild(rateBarFill);
+ tooltipBarContainer.appendChild(rateBar);
+
+ const tooltipLabel = document.createElement("div");
+ tooltipLabel.id = "ryd-dislike-tooltip";
+ tooltipLabel.className = "ryd-tooltip-label";
+ tooltipLabel.setAttribute("role", "tooltip");
+ tooltipLabel.textContent = tooltipText;
+
+ tooltip.append(tooltipBarContainer, tooltipLabel);
+ buttons.appendChild(tooltip);
+ const descriptionAndActionsElement = buttons.closest("#top-row");
+ if (descriptionAndActionsElement) {
+ descriptionAndActionsElement.style.borderBottom = "1px solid var(--yt-spec-10-percent-layer)";
+ descriptionAndActionsElement.style.paddingBottom = "10px";
+ }
+ } else {
+ const tooltip = rateBar.closest(".ryd-tooltip");
+ const rateBarFill = rateBar.querySelector("#return-youtube-dislike-bar");
+ if (!tooltip || !rateBarFill) {
+ (tooltip ?? rateBar).remove();
+ createRateBar(likes, dislikes);
+ return;
+ }
+ tooltip.setAttribute("data-ryd-video-id", getVideoId());
+ tooltip.style.width = widthPx + "px";
+ rateBarFill.style.width = widthPercent + "%";
+ const tooltipLabel = tooltip.querySelector("#ryd-dislike-tooltip");
+ if (tooltipLabel) {
+ tooltipLabel.textContent = tooltipText;
+ }
+
+ if (extConfig.coloredBar) {
+ rateBar.style.backgroundColor = getColorFromTheme(false);
+ rateBarFill.style.backgroundColor = getColorFromTheme(true);
+ }
+ }
+}
+
+function elementTouchesWatchRateBar(element) {
+ return Boolean(
+ element?.matches?.(
+ ".ryd-tooltip, .ryd-tooltip-bar-container, .ryd-tooltip-label, #return-youtube-dislike-bar-container, #return-youtube-dislike-bar, #ryd-dislike-tooltip",
+ ) ||
+ element?.matches?.('[data-ryd-rate-bar-wrapper="true"]') ||
+ element?.closest?.(".ryd-tooltip, .ryd-tooltip-bar-container, #return-youtube-dislike-bar-container") ||
+ element?.querySelector?.(
+ ".ryd-tooltip, .ryd-tooltip-bar-container, .ryd-tooltip-label, #return-youtube-dislike-bar-container, #return-youtube-dislike-bar, #ryd-dislike-tooltip",
+ ),
+ );
+}
+
+function mutationTouchesWatchRateBar(mutation) {
+ if (mutation.type === "attributes") {
+ return elementTouchesWatchRateBar(mutation.target);
+ }
+ return (
+ mutation.type === "childList" &&
+ [...mutation.addedNodes, ...mutation.removedNodes].some(
+ (node) => node.nodeType === Node.ELEMENT_NODE && elementTouchesWatchRateBar(node),
+ )
+ );
+}
+
+function watchRateBarIsHealthy(buttons, videoId) {
+ if (!buttons || !videoId) {
+ return false;
+ }
+
+ const wrappers = Array.from(buttons.querySelectorAll('[data-ryd-rate-bar-wrapper="true"]'));
+ const containers = Array.from(buttons.querySelectorAll("#return-youtube-dislike-bar-container"));
+ const fills = Array.from(buttons.querySelectorAll("#return-youtube-dislike-bar"));
+ const labels = Array.from(buttons.querySelectorAll("#ryd-dislike-tooltip"));
+ if (wrappers.length !== 1 || containers.length !== 1 || fills.length !== 1 || labels.length !== 1) {
+ return false;
+ }
+
+ const [wrapper] = wrappers;
+ const [container] = containers;
+ const [fill] = fills;
+ const [label] = labels;
+ if (
+ wrapper.parentElement !== buttons ||
+ !wrapper.matches(".ryd-tooltip") ||
+ wrapper.getAttribute("data-ryd-video-id") !== videoId ||
+ !wrapper.contains(container) ||
+ !container.contains(fill) ||
+ !wrapper.contains(label) ||
+ !hasRenderedBox(wrapper) ||
+ !hasRenderedBox(container)
+ ) {
+ return false;
+ }
+
+ const fillStyle = getComputedStyle(fill);
+ const fillBounds = fill.getBoundingClientRect();
+ return (
+ fillStyle.display !== "none" &&
+ fillStyle.visibility !== "hidden" &&
+ fillStyle.visibility !== "collapse" &&
+ Number.parseFloat(fillStyle.opacity) !== 0 &&
+ fillBounds.height > 0
+ );
+}
+
+function removeWatchRateBarArtifacts(buttons) {
+ if (!buttons) {
+ return;
+ }
+
+ for (const wrapper of buttons.querySelectorAll('.ryd-tooltip, [data-ryd-rate-bar-wrapper="true"]')) {
+ wrapper.remove();
+ }
+ for (const fragment of buttons.querySelectorAll(
+ ".ryd-tooltip-bar-container, .ryd-tooltip-label, #return-youtube-dislike-bar-container, #return-youtube-dislike-bar, #ryd-dislike-tooltip",
+ )) {
+ fragment.remove();
+ }
+}
+
+function clearStaleWatchPresentation(buttons, dislikeButton, videoId) {
+ if (isMobile || isShorts() || !initializedVideoId || initializedVideoId === videoId) {
+ return;
+ }
+
+ removeWatchRateBarArtifacts(buttons);
+ const dislikeText =
+ dislikeButton?.querySelector("#text") ??
+ dislikeButton?.getElementsByTagName("yt-formatted-string")[0] ??
+ dislikeButton?.querySelector("span[role='text']");
+ if (dislikeText) {
+ dislikeText.textContent = "";
+ }
+}
+
+function canRepairWatchRateBar(buttons, videoId) {
+ if (
+ isMobile ||
+ isShorts() ||
+ !extConfig.rateBarEnabled ||
+ !videoId ||
+ !countStateLoaded ||
+ countStateVideoId !== videoId ||
+ initializedVideoId !== videoId ||
+ getVideoId() !== videoId ||
+ !buttons?.isConnected ||
+ getButtons() !== buttons ||
+ !hasRenderedBox(buttons)
+ ) {
+ return false;
+ }
+
+ const likeButton = getLikeButton();
+ const dislikeButton = getDislikeButton();
+ return (
+ likeButton === initializedLikeButton &&
+ dislikeButton === initializedDislikeButton &&
+ buttons.contains(likeButton) &&
+ buttons.contains(dislikeButton) &&
+ !watchRateBarIsHealthy(buttons, videoId)
+ );
+}
+
+function repairWatchRateBar(buttons = getButtons(), videoId = getVideoId()) {
+ if (canRepairWatchRateBar(buttons, videoId)) {
+ removeWatchRateBarArtifacts(buttons);
+ createRateBar(likesvalue, dislikesvalue);
+ }
+}
+
+function scheduleWatchRateBarRepair() {
+ if (watchRateBarRepairTimer !== null) {
+ return;
+ }
+ watchRateBarRepairTimer = setTimeout(() => {
+ watchRateBarRepairTimer = null;
+ repairWatchRateBar(watchRateBarObserverTarget, watchRateBarObserverVideoId);
+ }, 0);
+}
+
+function disconnectWatchRateBarObserver() {
+ watchRateBarObserver?.disconnect();
+ watchRateBarObserver = null;
+ watchRateBarObserverTarget = null;
+ watchRateBarObserverVideoId = null;
+ if (watchRateBarRepairTimer !== null) {
+ clearTimeout(watchRateBarRepairTimer);
+ watchRateBarRepairTimer = null;
+ }
+}
+
+function observeWatchRateBar(buttons, videoId) {
+ if (isMobile || isShorts() || !extConfig.rateBarEnabled || !buttons) {
+ disconnectWatchRateBarObserver();
+ return;
+ }
+
+ if (watchRateBarObserverTarget === buttons) {
+ watchRateBarObserverVideoId = videoId;
+ return;
+ }
+
+ disconnectWatchRateBarObserver();
+ watchRateBarObserverTarget = buttons;
+ watchRateBarObserverVideoId = videoId;
+ watchRateBarObserver = new MutationObserver((mutations) => {
+ if (mutations.some(mutationTouchesWatchRateBar)) {
+ scheduleWatchRateBarRepair();
+ }
+ });
+ watchRateBarObserver.observe(buttons, {
+ attributeFilter: ["aria-hidden", "class", "hidden", "inert", "style"],
+ attributes: true,
+ childList: true,
+ subtree: true,
+ });
+}
+
+function setState() {
+ const videoId = getVideoId();
+ previousState = getState();
+ if (countStateVideoId === videoId && (activeCountRequest?.videoId === videoId || countStateLoaded)) {
+ updateDOMDislikes();
+ refreshFormattedLikes();
+ return;
+ }
+ if (countStateVideoId !== videoId) {
+ likesvalue = 0;
+ dislikesvalue = 0;
+ countStateVideoId = videoId;
+ countStateLoaded = false;
+ countStateEpoch += 1;
+ }
+ const countRequest = {
+ dislikesDelta: 0,
+ likesDelta: 0,
+ videoId,
+ };
+ activeCountRequest = countRequest;
+ cLog("Fetching votes...");
+
+ fetchImpl(`${API_BASE_URL}/votes?videoId=${videoId}`)
+ .then((response) => response.json())
+ .then((json) => {
+ if (getVideoId() !== videoId || activeCountRequest !== countRequest) {
+ return;
+ }
+ if (json && !("traceId" in json)) {
+ const { dislikes, likes } = json;
+ cLog(`Received count: ${dislikes}`);
+ likesvalue = Math.max(0, likes + countRequest.likesDelta);
+ dislikesvalue = Math.max(0, dislikes + countRequest.dislikesDelta);
+ countStateLoaded = true;
+ setDislikes(numberFormat(dislikesvalue));
+ if (extConfig.numberDisplayReformatLikes === true) {
+ const nativeLikes = getLikeCountFromButton();
+ if (nativeLikes !== false) {
+ setLikes(numberFormat(nativeLikes));
+ }
+ }
+ createRateBar(likesvalue, dislikesvalue);
+ if (extConfig.coloredThumbs === true) {
+ const dislikeButton = getDislikeButton();
+ if (isShorts()) {
+ // for shorts, leave deactived buttons in default color
+ const shortLikeButton = getLikeButton()?.querySelector("button, tp-yt-paper-button#button");
+ const shortDislikeButton = dislikeButton?.querySelector("button, tp-yt-paper-button#button");
+ if (shortLikeButton?.getAttribute("aria-pressed") === "true") {
+ shortLikeButton.style.color = getColorFromTheme(true);
+ }
+ if (shortDislikeButton?.getAttribute("aria-pressed") === "true") {
+ shortDislikeButton.style.color = getColorFromTheme(false);
+ }
+ const observer = getShortsObserver();
+ if (shortLikeButton) observer.observe(shortLikeButton);
+ if (shortDislikeButton) observer.observe(shortDislikeButton);
+ } else {
+ getLikeButton().style.color = getColorFromTheme(true);
+ if (dislikeButton) dislikeButton.style.color = getColorFromTheme(false);
+ }
+ }
+ }
+ })
+ .catch((error) => cLog("Fetching votes failed", error instanceof Error ? error.message : String(error)))
+ .finally(() => {
+ if (activeCountRequest === countRequest) {
+ activeCountRequest = null;
+ }
+ });
+}
+
+function updateDOMDislikes() {
+ setDislikes(numberFormat(dislikesvalue));
+ createRateBar(likesvalue, dislikesvalue);
+}
+
+function reportVoteFailure(error) {
+ const message = error instanceof Error ? error.message : String(error);
+ cLog("Vote submission failed", message);
+}
+
+function refreshFormattedLikes() {
+ if (extConfig.numberDisplayReformatLikes !== true) {
+ return;
+ }
+
+ const nativeLikes = getLikeCountFromButton();
+ if (nativeLikes !== false) {
+ setLikes(numberFormat(nativeLikes));
+ }
+}
+
+function getVoteStateCounts(state) {
+ return {
+ dislikes: state === DISLIKED_STATE ? 1 : 0,
+ likes: state === LIKED_STATE ? 1 : 0,
+ };
+}
+
+function getShortsCountTransition(videoId, transition) {
+ if (!isShorts() || shortsSubmittedStateVideoId !== videoId) {
+ return transition;
+ }
+ const previousCounts = getVoteStateCounts(shortsSubmittedState);
+ const nextCounts = getVoteStateCounts(transition.nextState);
+ return {
+ ...transition,
+ dislikesDelta: nextCounts.dislikes - previousCounts.dislikes,
+ likesDelta: nextCounts.likes - previousCounts.likes,
+ };
+}
+
+function applyCountTransition(videoId, countTransition) {
+ const counts = applyVoteTransitionCounts(likesvalue, dislikesvalue, countTransition);
+ if (activeCountRequest?.videoId === videoId) {
+ activeCountRequest.likesDelta += countTransition.likesDelta;
+ activeCountRequest.dislikesDelta += countTransition.dislikesDelta;
+ }
+ likesvalue = counts.likes;
+ dislikesvalue = counts.dislikes;
+}
+
+function applyVoteTransition(videoId, transition, syntheticShortsDislike) {
+ const countTransition = getShortsCountTransition(videoId, transition);
+ applyCountTransition(videoId, countTransition);
+ previousState = transition.nextState;
+ if (syntheticShortsDislike) {
+ setSyntheticShortsPressed(previousState === DISLIKED_STATE);
+ }
+ if (isShorts()) {
+ shortsSubmittedStateVideoId = videoId;
+ shortsSubmittedState = previousState;
+ persistSyntheticShortsState(videoId, previousState === DISLIKED_STATE);
+ }
+ updateDOMDislikes();
+ refreshFormattedLikes();
+}
+
+function applyHydratingVoteTransition(hydration, transition, syntheticShortsDislike) {
+ applyCountTransition(hydration.videoId, transition);
+ previousState = transition.nextState;
+ if (syntheticShortsDislike) {
+ setSyntheticShortsPressed(previousState === DISLIKED_STATE);
+ }
+ updateDOMDislikes();
+ refreshFormattedLikes();
+}
+
+function reconcileHydratingVoteTransition(hydration, transition, syntheticShortsDislike) {
+ const submittedCountTransition = getShortsCountTransition(hydration.videoId, transition);
+ if (transition.optimisticCountStateEpoch === countStateEpoch) {
+ applyCountTransition(hydration.videoId, {
+ ...transition,
+ dislikesDelta: submittedCountTransition.dislikesDelta - transition.dislikesDelta,
+ likesDelta: submittedCountTransition.likesDelta - transition.likesDelta,
+ });
+ }
+ previousState = transition.nextState;
+ shortsSubmittedStateVideoId = hydration.videoId;
+ shortsSubmittedState = previousState;
+ persistSyntheticShortsState(hydration.videoId, previousState === DISLIKED_STATE);
+ if (syntheticShortsDislike) {
+ setSyntheticShortsPressed(previousState === DISLIKED_STATE);
+ }
+}
+
+function submitVoteTransition(videoId, transition, signedOut) {
+ if (shouldSubmitVote({ disableVoteSubmission: extConfig.disableVoteSubmission, signedOut })) {
+ void voteClient.submitVote(videoId, transition.value).catch(reportVoteFailure);
+ }
+}
+
+function clearNativeLikeForSyntheticDislike(action, stateBeforeActivation, syntheticShortsDislike) {
+ if (!syntheticShortsDislike || action !== DISLIKE_ACTION || stateBeforeActivation !== LIKED_STATE) {
+ return;
+ }
+
+ const nativeLikeButton = getLikeButton()?.querySelector("button");
+ if (nativeLikeButton && isVideoLiked()) {
+ suppressNextLikeActivation = true;
+ try {
+ nativeLikeButton.click();
+ } finally {
+ suppressNextLikeActivation = false;
+ }
+ }
+}
+
+function handleVoteActivation(action) {
+ const signedOut = isSignedOut();
+ if (signedOut) {
+ return;
+ }
+
+ const videoId = getVideoId();
+ if (!videoId) {
+ return;
+ }
+
+ const transition = resolveVoteTransition(previousState, action);
+ const syntheticShortsDislike = getDislikeButton()?.matches(SYNTHETIC_SHORTS_DISLIKE_SELECTOR) === true;
+ clearNativeLikeForSyntheticDislike(action, previousState, syntheticShortsDislike);
+ applyVoteTransition(videoId, transition, syntheticShortsDislike);
+ submitVoteTransition(videoId, transition, signedOut);
+}
+
+function captureHydratingShortsActivation(event, action) {
+ const hydration = hydratingShortsActivationTargets.get(event.currentTarget);
+ if (!hydration) {
+ return false;
+ }
+ if (hydration.videoId !== getVideoId()) {
+ return true;
+ }
+
+ const signedOut = isSignedOut();
+ const syntheticShortsDislike = getDislikeButton()?.matches(SYNTHETIC_SHORTS_DISLIKE_SELECTOR) === true;
+ if (!signedOut) {
+ clearNativeLikeForSyntheticDislike(action, hydration.visibleState, syntheticShortsDislike);
+ }
+ const transition = {
+ ...resolveVoteTransition(hydration.visibleState, action),
+ optimisticCountStateEpoch: countStateEpoch,
+ };
+ hydration.visibleState = transition.nextState;
+ if (!signedOut) {
+ hydration.activations.push(transition);
+ applyHydratingVoteTransition(hydration, transition, syntheticShortsDislike);
+ submitVoteTransition(hydration.videoId, transition, signedOut);
+ }
+ return true;
+}
+
+function likeClicked(event) {
+ if (suppressNextLikeActivation) {
+ return;
+ }
+ if (boundActivationVideoIds.get(event.currentTarget) !== getVideoId()) {
+ suppressStaleTargetRefresh(event.currentTarget);
+ return;
+ }
+ if (captureHydratingShortsActivation(event, LIKE_ACTION)) {
+ return;
+ }
+ handleVoteActivation(LIKE_ACTION);
+}
+
+function dislikeClicked(event) {
+ if (boundActivationVideoIds.get(event.currentTarget) !== getVideoId()) {
+ suppressStaleTargetRefresh(event.currentTarget);
+ return;
+ }
+ if (captureHydratingShortsActivation(event, DISLIKE_ACTION)) {
+ return;
+ }
+ handleVoteActivation(DISLIKE_ACTION);
+}
+
+function refreshDislikesForBoundControl(event) {
+ if (boundActivationVideoIds.get(event.currentTarget) === getVideoId()) {
+ updateDOMDislikes();
+ }
+}
+
+function getVideoId() {
+ const urlObject = new URL(window.location.href);
+ const pathname = urlObject.pathname;
+ if (pathname.startsWith("/clip")) {
+ return (document.querySelector("meta[itemprop='videoId']") || document.querySelector("meta[itemprop='identifier']"))
+ ?.content;
+ } else {
+ const shortVideoId = getShortVideoIdFromPathname(pathname);
+ if (shortVideoId) {
+ return shortVideoId;
+ }
+ return urlObject.searchParams.get("v");
+ }
+}
+
+function isVideoLoaded() {
+ if (isMobile) {
+ return document.getElementById("player")?.getAttribute("loading") == "false";
+ }
+ const videoId = getVideoId();
+
+ return (
+ // desktop: spring 2024 UI
+ document.querySelector(`ytd-watch-grid[video-id='${videoId}']`) !== null ||
+ // desktop: older UI
+ document.querySelector(`ytd-watch-flexy[video-id='${videoId}']`) !== null ||
+ // mobile: no video-id attribute
+ document.querySelector('#player[loading="false"]:not([hidden])') !== null
+ );
+}
+
+function roundDown(num) {
+ if (num < 1000) return num;
+ const int = Math.floor(Math.log10(num) - 2);
+ const decimal = int + (int % 3 ? 1 : 0);
+ const value = Math.floor(num / 10 ** decimal);
+ return value * 10 ** decimal;
+}
+
+function numberFormat(numberState) {
+ let numberDisplay;
+ if (extConfig.numberDisplayRoundDown === false) {
+ numberDisplay = numberState;
+ } else {
+ numberDisplay = roundDown(numberState);
+ }
+ return getNumberFormatter(extConfig.numberDisplayFormat).format(numberDisplay);
+}
+
+function getNumberFormatter(optionSelect) {
+ let userLocales;
+ if (document.documentElement.lang) {
+ userLocales = document.documentElement.lang;
+ } else if (navigator.language) {
+ userLocales = navigator.language;
+ } else {
+ try {
+ userLocales = new URL(
+ Array.from(document.querySelectorAll("head > link[rel='search']"))
+ ?.find((n) => n?.getAttribute("href")?.includes("?locale="))
+ ?.getAttribute("href"),
+ )?.searchParams?.get("locale");
+ } catch {
+ cLog("Cannot find browser locale. Use en as default for number formatting.");
+ userLocales = "en";
+ }
+ }
+
+ let formatterNotation;
+ let formatterCompactDisplay;
+ switch (optionSelect) {
+ case "compactLong":
+ formatterNotation = "compact";
+ formatterCompactDisplay = "long";
+ break;
+ case "standard":
+ formatterNotation = "standard";
+ formatterCompactDisplay = "short";
+ break;
+ case "compactShort":
+ default:
+ formatterNotation = "compact";
+ formatterCompactDisplay = "short";
+ }
+
+ const formatter = Intl.NumberFormat(userLocales, {
+ notation: formatterNotation,
+ compactDisplay: formatterCompactDisplay,
+ });
+ return formatter;
+}
+
+function getColorFromTheme(voteIsLike) {
+ let colorString;
+ switch (extConfig.colorTheme) {
+ case "accessible":
+ if (voteIsLike === true) {
+ colorString = "dodgerblue";
+ } else {
+ colorString = "gold";
+ }
+ break;
+ case "neon":
+ if (voteIsLike === true) {
+ colorString = "aqua";
+ } else {
+ colorString = "magenta";
+ }
+ break;
+ case "classic":
+ default:
+ if (voteIsLike === true) {
+ colorString = "lime";
+ } else {
+ colorString = "red";
+ }
+ }
+ return colorString;
+}
+
+let smartimationObserver = null;
+let initializedVideoId = null;
+let initializedButtons = null;
+let initializedLikeButton = null;
+let initializedDislikeButton = null;
+let lifecyclePageKey = null;
+
+const SHORTS_RENDERER_SELECTOR =
+ "ytd-reel-video-renderer, ytm-reel-video-renderer, ytm-shorts-video-renderer, ytm-reel-player-overlay-renderer";
+const SHORTS_CONTROL_SELECTOR = `like-button-view-model, dislike-button-view-model, ${SYNTHETIC_SHORTS_DISLIKE_SELECTOR}`;
+
+function getShortsRenderer(element) {
+ if (!element || element.nodeType !== Node.ELEMENT_NODE) {
+ return null;
+ }
+ return element.matches(SHORTS_RENDERER_SELECTOR) ? element : element.closest?.(SHORTS_RENDERER_SELECTOR) ?? null;
+}
+
+function rendererOwnsCurrentShort(renderer) {
+ if (!renderer) {
+ return false;
+ }
+ return (
+ renderer.hasAttribute("is-active") ||
+ rendererMatchesShort(renderer, getVideoId()) ||
+ (initializedLikeButton && renderer.contains(initializedLikeButton)) ||
+ (initializedDislikeButton && renderer.contains(initializedDislikeButton))
+ );
+}
+
+function elementTouchesCurrentShortRenderer(element) {
+ const containingRenderer = getShortsRenderer(element);
+ if (containingRenderer) {
+ return rendererOwnsCurrentShort(containingRenderer);
+ }
+ return Array.from(element?.querySelectorAll?.(SHORTS_RENDERER_SELECTOR) ?? []).some(rendererOwnsCurrentShort);
+}
+
+function mutationTouchesShortsControls(mutation) {
+ if (mutation.type === "attributes") {
+ if (mutation.attributeName === "is-active" && mutation.target.matches(SHORTS_RENDERER_SELECTOR)) {
+ return true;
+ }
+ if (
+ (mutation.target.matches(SHORTS_RENDERER_SELECTOR) || mutation.target.matches("a")) &&
+ elementTouchesCurrentShortRenderer(mutation.target)
+ ) {
+ return true;
+ }
+ return false;
+ }
+ if (mutation.type !== "childList") {
+ return false;
+ }
+ const changedElements = [...mutation.addedNodes, ...mutation.removedNodes].filter(
+ (node) => node.nodeType === Node.ELEMENT_NODE,
+ );
+ if (
+ changedElements.some(
+ (node) =>
+ node.matches(`reel-action-bar-view-model, ${SHORTS_CONTROL_SELECTOR}`) ||
+ node.querySelector?.(`reel-action-bar-view-model, ${SHORTS_CONTROL_SELECTOR}`),
+ )
+ ) {
+ return [mutation.target, ...changedElements].some(elementTouchesCurrentShortRenderer);
+ }
+ return (
+ mutation.target.closest?.(SHORTS_CONTROL_SELECTOR) &&
+ elementTouchesCurrentShortRenderer(mutation.target) &&
+ changedElements.some(
+ (node) =>
+ node.matches("button, tp-yt-paper-button#button") || node.querySelector?.("button, tp-yt-paper-button#button"),
+ )
+ );
+}
+
+function shortsControlsNeedInitialization() {
+ const videoId = getVideoId();
+ if (!isShorts() || !videoId) {
+ return false;
+ }
+
+ const buttons = getButtons();
+ const likeButton = getLikeButton();
+ const dislikeButton = getDislikeButton();
+ if (!buttons || !likeButton || !dislikeButton) {
+ return true;
+ }
+ if (
+ initializedVideoId !== videoId ||
+ initializedLikeButton !== likeButton ||
+ initializedDislikeButton !== dislikeButton
+ ) {
+ return true;
+ }
+
+ return (
+ boundActivationVideoIds.get(getActivationTarget(likeButton)) !== videoId ||
+ boundActivationVideoIds.get(getActivationTarget(dislikeButton)) !== videoId
+ );
+}
+
+function disconnectShortsLifecycleObserver() {
+ shortsLifecycleObserver?.disconnect();
+ shortsLifecycleObserverTarget = null;
+}
+
+function observeShortsLifecycle(buttons) {
+ if (!isShorts() || !buttons) {
+ disconnectShortsLifecycleObserver();
+ return;
+ }
+ clearPendingWatchControlObservers();
+
+ const isDesktopActionBar = buttons.tagName === "REEL-ACTION-BAR-VIEW-MODEL";
+ const isMobileActionBar = isMobile && buttons.tagName === "YTM-LIKE-BUTTON-RENDERER";
+ if (!isDesktopActionBar && !isMobileActionBar) {
+ disconnectShortsLifecycleObserver();
+ return;
+ }
+
+ const renderer = isMobileActionBar
+ ? buttons.closest("ytm-reel-video-renderer, ytm-shorts-video-renderer") ?? getMobileShortOwnership(buttons).owner
+ : buttons.closest("ytd-reel-video-renderer") ?? buttons;
+ const observerTarget =
+ renderer.closest(
+ "ytd-shorts, ytd-shorts-container, ytm-shorts, ytm-shorts-container, #shorts-container, #shorts-inner-container",
+ ) ??
+ renderer.parentElement ??
+ document.body;
+ if (shortsLifecycleObserverTarget === observerTarget) {
+ return;
+ }
+ disconnectShortsLifecycleObserver();
+ shortsLifecycleObserver = new MutationObserver((mutations) => {
+ if (mutations.some(mutationTouchesShortsControls) && shortsControlsNeedInitialization()) {
+ setEventListeners();
+ }
+ });
+ shortsLifecycleObserver.observe(observerTarget, {
+ attributeFilter: ["href", "is-active", "video-id"],
+ attributes: true,
+ childList: true,
+ subtree: true,
+ });
+ shortsLifecycleObserverTarget = observerTarget;
+}
+
+function getActivationTarget(control) {
+ if (control.matches("button, tp-yt-paper-button#button")) {
+ return control;
+ }
+ return control.querySelector("button, tp-yt-paper-button#button") ?? control;
+}
+
+function beginShortsHydration(videoId, likeButton, dislikeButton, initialVisibleState) {
+ const previousCompletion = shortsHydrationTails.get(videoId) ?? Promise.resolve();
+ let resolveCompletion;
+ const completion = new Promise((resolve) => {
+ resolveCompletion = resolve;
+ });
+ const hydration = {
+ activations: [],
+ completion,
+ initialVisibleState,
+ previousCompletion,
+ resolveCompletion,
+ videoId,
+ visibleState: initialVisibleState,
+ };
+ shortsHydrationTails.set(videoId, completion);
+ hydratingShortsActivationTargets.set(getActivationTarget(likeButton), hydration);
+ hydratingShortsActivationTargets.set(getActivationTarget(dislikeButton), hydration);
+ return hydration;
+}
+
+function finishShortsHydration(hydration, likeButton, dislikeButton) {
+ for (const target of [getActivationTarget(likeButton), getActivationTarget(dislikeButton)]) {
+ if (hydratingShortsActivationTargets.get(target) === hydration) {
+ hydratingShortsActivationTargets.delete(target);
+ }
+ }
+ if (shortsHydrationTails.get(hydration.videoId) === hydration.completion) {
+ shortsHydrationTails.delete(hydration.videoId);
+ }
+ hydration.resolveCompletion();
+}
+
+function persistFinalHydratingActivation(hydration) {
+ const finalActivation = hydration.activations[hydration.activations.length - 1];
+ if (finalActivation) {
+ persistSyntheticShortsState(hydration.videoId, finalActivation.nextState === DISLIKED_STATE);
+ }
+}
+
+function reconcileStaleShortsHydration(hydration, submittedState, storedDisliked) {
+ if (getVideoId() !== hydration.videoId || hydration.activations.length === 0) {
+ persistFinalHydratingActivation(hydration);
+ return;
+ }
+
+ const currentDislikeButton = getDislikeButton();
+ if (!currentDislikeButton) {
+ persistFinalHydratingActivation(hydration);
+ return;
+ }
+
+ applyHydratedShortsState(hydration, submittedState, currentDislikeButton, storedDisliked);
+}
+
+function applyHydratedShortsState(hydration, submittedState, dislikeButton, storedDisliked) {
+ const syntheticShortsDislike = dislikeButton.matches(SYNTHETIC_SHORTS_DISLIKE_SELECTOR);
+ shortsSubmittedStateVideoId = hydration.videoId;
+ shortsSubmittedState = submittedState;
+
+ if (hydration.activations.length === 0) {
+ previousState = syntheticShortsDislike ? submittedState : hydration.initialVisibleState;
+ if (syntheticShortsDislike) {
+ setSyntheticShortsPressed(submittedState === DISLIKED_STATE, dislikeButton);
+ }
+ if (hydration.initialVisibleState === LIKED_STATE && storedDisliked) {
+ persistSyntheticShortsState(hydration.videoId, false);
+ }
+ return;
+ }
+
+ for (const transition of hydration.activations) {
+ reconcileHydratingVoteTransition(hydration, transition, syntheticShortsDislike);
+ }
+ updateDOMDislikes();
+ refreshFormattedLikes();
+}
+
+function bindVoteButtonListeners(likeButton, dislikeButton, { enableSynthetic = true } = {}) {
+ const likeActivationTarget = getActivationTarget(likeButton);
+ const dislikeActivationTarget = getActivationTarget(dislikeButton);
+ const videoId = getVideoId();
+ boundActivationVideoIds.set(likeActivationTarget, videoId);
+ boundActivationVideoIds.set(dislikeActivationTarget, videoId);
+ if (!boundLikeButtons.has(likeActivationTarget)) {
+ likeActivationTarget.addEventListener("click", likeClicked);
+ boundLikeButtons.add(likeActivationTarget);
+ }
+ if (!boundDislikeButtons.has(dislikeActivationTarget)) {
+ dislikeActivationTarget.addEventListener("click", dislikeClicked);
+ dislikeActivationTarget.addEventListener("focusin", refreshDislikesForBoundControl);
+ dislikeActivationTarget.addEventListener("focusout", refreshDislikesForBoundControl);
+ boundDislikeButtons.add(dislikeActivationTarget);
+ }
+ if (enableSynthetic && dislikeButton.matches(SYNTHETIC_SHORTS_DISLIKE_SELECTOR)) {
+ dislikeActivationTarget.disabled = false;
+ dislikeActivationTarget.setAttribute("aria-disabled", "false");
+ }
+}
+
+async function initializeCurrentButtons(generation) {
+ const videoId = getVideoId();
+ if (!videoId) {
+ // Channel/search/home pages have no video controls to initialize. The
+ // lightweight lifecycle monitor will restart initialization when a video
+ // route appears, instead of polling the whole page every 111 ms forever.
+ return true;
+ }
+
+ if (!(isShorts() || (hasRenderedBox(getButtons()) && isVideoLoaded()))) {
+ return false;
+ }
+
+ const buttons = getButtons();
+ const likeButton = getLikeButton();
+ const dislikeButton = getDislikeButton();
+ if (!buttons || !likeButton || !dislikeButton) {
+ return false;
+ }
+ if (!isShorts() && !watchControlsAreReadyForVideo(buttons, likeButton, dislikeButton, videoId)) {
+ return false;
+ }
+
+ observeShortsLifecycle(buttons);
+ observeWatchRateBar(buttons, videoId);
+ const stateNeedsInitialization =
+ initializedVideoId !== videoId ||
+ initializedButtons !== buttons ||
+ initializedLikeButton !== likeButton ||
+ initializedDislikeButton !== dislikeButton;
+ if (stateNeedsInitialization) {
+ clearStaleWatchPresentation(buttons, dislikeButton, videoId);
+ initializedVideoId = videoId;
+ initializedButtons = buttons;
+ initializedLikeButton = likeButton;
+ initializedDislikeButton = dislikeButton;
+ setState();
+ }
+
+ if (isShorts()) {
+ const initialVisibleState = getState();
+ const likeActivationTarget = getActivationTarget(likeButton);
+ const dislikeActivationTarget = getActivationTarget(dislikeButton);
+ const existingLikeHydration = hydratingShortsActivationTargets.get(likeActivationTarget);
+ const existingDislikeHydration = hydratingShortsActivationTargets.get(dislikeActivationTarget);
+ if (
+ existingLikeHydration &&
+ existingLikeHydration === existingDislikeHydration &&
+ existingLikeHydration.videoId === videoId
+ ) {
+ return false;
+ }
+ const hydration = beginShortsHydration(videoId, likeButton, dislikeButton, initialVisibleState);
+ bindVoteButtonListeners(likeButton, dislikeButton, { enableSynthetic: false });
+ let storedDisliked;
+ let submittedState;
+ try {
+ await hydration.previousCompletion;
+ if (dislikeButton.matches(SYNTHETIC_SHORTS_DISLIKE_SELECTOR)) {
+ const restored = await restoreSyntheticShortsState(videoId, dislikeButton, initialVisibleState);
+ if (!restored) {
+ persistFinalHydratingActivation(hydration);
+ return false;
+ }
+ storedDisliked = restored.disliked;
+ submittedState = restored.submittedState;
+ } else {
+ storedDisliked = await readSyntheticShortsDisliked(videoId);
+ submittedState =
+ initialVisibleState === LIKED_STATE ? LIKED_STATE : storedDisliked ? DISLIKED_STATE : initialVisibleState;
+ }
+
+ if (
+ generation !== initializationGeneration ||
+ getVideoId() !== videoId ||
+ getLikeButton() !== likeButton ||
+ getDislikeButton() !== dislikeButton
+ ) {
+ reconcileStaleShortsHydration(hydration, submittedState, storedDisliked);
+ return false;
+ }
+
+ applyHydratedShortsState(hydration, submittedState, dislikeButton, storedDisliked);
+ bindVoteButtonListeners(likeButton, dislikeButton);
+ } finally {
+ finishShortsHydration(hydration, likeButton, dislikeButton);
+ }
+ } else {
+ if (
+ generation !== initializationGeneration ||
+ getVideoId() !== videoId ||
+ getLikeButton() !== likeButton ||
+ getDislikeButton() !== dislikeButton
+ ) {
+ return false;
+ }
+ bindVoteButtonListeners(likeButton, dislikeButton);
+ }
+
+ if (!smartimationObserver) {
+ smartimationObserver = createObserver(
+ {
+ attributes: true,
+ subtree: true,
+ childList: true,
+ },
+ updateDOMDislikes,
+ );
+ smartimationObserver.container = null;
+ }
+
+ const smartimationContainer = buttons.querySelector("yt-smartimation");
+ if (smartimationContainer && smartimationObserver.container != smartimationContainer) {
+ cLog("Initializing smartimation mutation observer");
+ smartimationObserver.disconnect();
+ smartimationObserver.observe(smartimationContainer);
+ smartimationObserver.container = smartimationContainer;
+ }
+
+ return true;
+}
+
+function setEventListeners(evt) {
+ const generation = ++initializationGeneration;
+ let checkRunning = false;
+ if (initializationTimer) {
+ clearInterval(initializationTimer);
+ }
+
+ async function checkForJSFinish() {
+ if (generation !== initializationGeneration || checkRunning) {
+ return;
+ }
+ checkRunning = true;
+ try {
+ const initialized = await initializeCurrentButtons(generation);
+ if (initialized && generation === initializationGeneration) {
+ clearInterval(initializationTimer);
+ initializationTimer = null;
+ }
+ } catch (error) {
+ reportVoteFailure(error);
+ } finally {
+ checkRunning = false;
+ }
+ }
+
+ cLog("Setting up...");
+ initializationTimer = setInterval(() => void checkForJSFinish(), 111);
+ void checkForJSFinish();
+}
+
+function getLifecyclePageKey() {
+ const videoId = getVideoId();
+ if (!videoId) {
+ return null;
+ }
+ return `${isShorts() ? "shorts" : "watch"}:${videoId}`;
+}
+
+function watchControlsNeedReinitialization() {
+ if (isMobile || isShorts() || initializationTimer !== null) {
+ return false;
+ }
+
+ const videoId = getVideoId();
+ if (!videoId || initializedVideoId !== videoId) {
+ return false;
+ }
+
+ const buttons = getButtons();
+ const likeButton = getLikeButton();
+ const dislikeButton = getDislikeButton();
+ return (
+ !initializedButtons?.isConnected ||
+ buttons !== initializedButtons ||
+ likeButton !== initializedLikeButton ||
+ dislikeButton !== initializedDislikeButton ||
+ !initializedLikeButton?.isConnected ||
+ !initializedDislikeButton?.isConnected ||
+ !buttons?.contains(initializedLikeButton) ||
+ !buttons?.contains(initializedDislikeButton)
+ );
+}
+
+function checkPageLifecycle() {
+ const pageKey = getLifecyclePageKey();
+ if (pageKey !== lifecyclePageKey) {
+ lifecyclePageKey = pageKey;
+ if (!isShorts()) {
+ disconnectShortsLifecycleObserver();
+ if (!pageKey) {
+ clearPendingWatchControlObservers();
+ }
+ }
+ setEventListeners();
+ return;
+ }
+ if (watchControlsNeedReinitialization()) {
+ setEventListeners();
+ return;
+ }
+ repairWatchRateBar();
+}
+
+function handleNavigateStart() {
+ disconnectWatchRateBarObserver();
+ clearPendingWatchNavigationBoundary();
+ clearPendingWatchControlObservers();
+ if (isShorts()) {
+ return;
+ }
+
+ const buttons = getButtons();
+ const likeButton = getLikeButton();
+ const dislikeButton = getDislikeButton();
+ if (!buttons || !likeButton || !dislikeButton) {
+ return;
+ }
+
+ const likeActivationTarget = getActivationTarget(likeButton);
+ const dislikeActivationTarget = getActivationTarget(dislikeButton);
+ const likeVideoId = boundActivationVideoIds.get(likeActivationTarget);
+ const dislikeVideoId = boundActivationVideoIds.get(dislikeActivationTarget);
+ if (
+ !likeVideoId ||
+ likeVideoId !== dislikeVideoId ||
+ likeVideoId !== getVideoId() ||
+ !buttons.contains(likeActivationTarget) ||
+ !buttons.contains(dislikeActivationTarget)
+ ) {
+ return;
+ }
+
+ const boundary = {
+ buttons,
+ completedVideoId: null,
+ dislike: {
+ activationTarget: dislikeActivationTarget,
+ host: dislikeButton,
+ refreshed: false,
+ },
+ like: {
+ activationTarget: likeActivationTarget,
+ host: likeButton,
+ refreshed: false,
+ },
+ observer: null,
+ sourceVideoId: likeVideoId,
+ };
+ const observer = new MutationObserver((mutations) => {
+ captureWatchNavigationBoundaryRefreshes(boundary, mutations);
+ });
+ boundary.observer = observer;
+ pendingWatchNavigationBoundary = boundary;
+ observer.observe(buttons, {
+ attributeFilter: ["aria-disabled", "aria-label", "data-video-id", "disabled", "title", "video-id"],
+ attributes: true,
+ childList: true,
+ subtree: true,
+ });
+}
+
+function handleNavigateFinish(event) {
+ lifecyclePageKey = getLifecyclePageKey();
+ if (isShorts()) {
+ clearPendingWatchControlObservers();
+ clearPendingWatchNavigationBoundary();
+ } else {
+ disconnectShortsLifecycleObserver();
+ const videoId = getVideoId();
+ if (pendingWatchNavigationBoundary && videoId !== pendingWatchNavigationBoundary.sourceVideoId) {
+ pendingWatchNavigationBoundary.completedVideoId = videoId;
+ }
+ if (!getVideoId()) {
+ clearPendingWatchControlObservers();
+ clearPendingWatchNavigationBoundary();
+ }
+ }
+ setEventListeners(event);
+}
+
+(function () {
+ "use strict";
+ void voteClient.ensureRegistered().catch(reportVoteFailure);
+ window.addEventListener("yt-navigate-start", handleNavigateStart, true);
+ window.addEventListener("yt-navigate-finish", handleNavigateFinish, true);
+ window.addEventListener("popstate", checkPageLifecycle, true);
+ lifecyclePageKey = getLifecyclePageKey();
+ setInterval(checkPageLifecycle, 500);
+ setEventListeners();
+})();
+if (isMobile) {
+ setInterval(() => {
+ const dislikeButton = getDislikeButton();
+ if (dislikeButton?.querySelector(".button-renderer-text") === null) {
+ getDislikeTextContainer().innerText = mobileDislikes;
+ } else {
+ if (dislikeButton) dislikeButton.querySelector(".button-renderer-text").innerText = mobileDislikes;
+ }
+ }, 1000);
+}
diff --git a/Extensions/UserScript/userscript-artifact.spec.js b/Extensions/UserScript/userscript-artifact.spec.js
new file mode 100644
index 0000000..417806a
--- /dev/null
+++ b/Extensions/UserScript/userscript-artifact.spec.js
@@ -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 });
+ }
+ });
+});
diff --git a/Extensions/UserScript/userscript-version.json b/Extensions/UserScript/userscript-version.json
new file mode 100644
index 0000000..65640cc
--- /dev/null
+++ b/Extensions/UserScript/userscript-version.json
@@ -0,0 +1 @@
+"3.2.0"
diff --git a/Extensions/UserScript/userscript.meta.js b/Extensions/UserScript/userscript.meta.js
new file mode 100644
index 0000000..875f668
--- /dev/null
+++ b/Extensions/UserScript/userscript.meta.js
@@ -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",
+};
diff --git a/Extensions/combined/content-style.css b/Extensions/combined/content-style.css
index ad43779..9a3ff42 100644
--- a/Extensions/combined/content-style.css
+++ b/Extensions/combined/content-style.css
@@ -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;
}
diff --git a/Extensions/combined/ryd.background.js b/Extensions/combined/ryd.background.js
index 80d9346..b7bfb95 100644
--- a/Extensions/combined/ryd.background.js
+++ b/Extensions/combined/ryd.background.js
@@ -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);
diff --git a/Extensions/combined/ryd.content-script.js b/Extensions/combined/ryd.content-script.js
index 5206532..a842f41 100644
--- a/Extensions/combined/ryd.content-script.js
+++ b/Extensions/combined/ryd.content-script.js
@@ -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 () {
diff --git a/Extensions/combined/src/bar.js b/Extensions/combined/src/bar.js
index 9b302f0..8144025 100644
--- a/Extensions/combined/src/bar.js
+++ b/Extensions/combined/src/bar.js
@@ -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();
}
}
}
diff --git a/Extensions/combined/src/bar.spec.js b/Extensions/combined/src/bar.spec.js
new file mode 100644
index 0000000..2b1c71a
--- /dev/null
+++ b/Extensions/combined/src/bar.spec.js
@@ -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 `
+
+
+ `;
+}
+
+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();
+ });
+});
diff --git a/Extensions/combined/src/buttons.js b/Extensions/combined/src/buttons.js
index fcf0a6c..e6d84b1 100644
--- a/Extensions/combined/src/buttons.js
+++ b/Extensions/combined/src/buttons.js
@@ -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,
};
diff --git a/Extensions/combined/src/buttons.spec.js b/Extensions/combined/src/buttons.spec.js
new file mode 100644
index 0000000..dd7b134
--- /dev/null
+++ b/Extensions/combined/src/buttons.spec.js
@@ -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 `
+
+
+
+
+
+
+
+ `;
+}
+
+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 = `
+
+ ${controls("stale", "AAAAAAAAAAA")}
+ ${controls("current", "BBBBBBBBBBB")}
+ `;
+ 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 = `
+
+ ${controls("stale", "AAAAAAAAAAA")}
+ ${controls("current", "BBBBBBBBBBB")}
+ `;
+ 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 = `
+
+ ${controls("reused", "AAAAAAAAAAA")}
+ `;
+ 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 = `
+
+ ${controls("hidden-stale", "AAAAAAAAAAA")}
+ ${controls("transparent-stale", "AAAAAAAAAAA")}
+ ${controls("inert-stale", "AAAAAAAAAAA")}
+ ${controls("current", "BBBBBBBBBBB")}
+ `;
+ ["#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 = `
+
+ ${controls("outgoing", "AAAAAAAAAAA")}
+ `;
+ setBox(document.querySelector("#outgoing"));
+
+ expect(getButtons()).toBeUndefined();
+ });
+
+ test("accepts fixed-position current controls even though they have no offset parent", () => {
+ document.body.innerHTML = `
+
+ ${controls("current", "BBBBBBBBBBB", 'style="position: fixed"')}
+ `;
+ 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 = `
+
+ ${controls("current", "BBBBBBBBBBB")}
+ `;
+ 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 = `
+
+ ${controls("stale", "AAAAAAAAAAA")}
+ ${controls("current", "BBBBBBBBBBB")}
+ `;
+ 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;
+ }
+ });
+});
diff --git a/Extensions/combined/src/events.js b/Extensions/combined/src/events.js
index 59aa3e9..142723f 100644
--- a/Extensions/combined/src/events.js
+++ b/Extensions/combined/src/events.js
@@ -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);
}
}
diff --git a/Extensions/combined/src/initialization-cycle.js b/Extensions/combined/src/initialization-cycle.js
new file mode 100644
index 0000000..1ba587c
--- /dev/null
+++ b/Extensions/combined/src/initialization-cycle.js
@@ -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 };
diff --git a/Extensions/combined/src/initialization-cycle.spec.js b/Extensions/combined/src/initialization-cycle.spec.js
new file mode 100644
index 0000000..491b5ab
--- /dev/null
+++ b/Extensions/combined/src/initialization-cycle.spec.js
@@ -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);
+});
diff --git a/Extensions/combined/src/state.js b/Extensions/combined/src/state.js
index b6076b2..dafcd5d 100644
--- a/Extensions/combined/src/state.js
+++ b/Extensions/combined/src/state.js
@@ -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,
};
diff --git a/Extensions/combined/src/state.spec.js b/Extensions/combined/src/state.spec.js
new file mode 100644
index 0000000..d0cd226
--- /dev/null
+++ b/Extensions/combined/src/state.spec.js
@@ -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();
+});
diff --git a/Extensions/combined/src/vote-client-adapter.js b/Extensions/combined/src/vote-client-adapter.js
new file mode 100644
index 0000000..b261515
--- /dev/null
+++ b/Extensions/combined/src/vote-client-adapter.js
@@ -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 };
diff --git a/Extensions/combined/src/vote-client-adapter.spec.js b/Extensions/combined/src/vote-client-adapter.spec.js
new file mode 100644
index 0000000..e0816db
--- /dev/null
+++ b/Extensions/combined/src/vote-client-adapter.spec.js
@@ -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);
+ });
+});
diff --git a/Extensions/common/vote-client.js b/Extensions/common/vote-client.js
new file mode 100644
index 0000000..fce7e2e
--- /dev/null
+++ b/Extensions/common/vote-client.js
@@ -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 };
diff --git a/Extensions/common/vote-client.spec.js b/Extensions/common/vote-client.spec.js
new file mode 100644
index 0000000..94a1e0a
--- /dev/null
+++ b/Extensions/common/vote-client.spec.js
@@ -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);
+ });
+});
diff --git a/Extensions/common/vote-transition.js b/Extensions/common/vote-transition.js
new file mode 100644
index 0000000..e32e2a4
--- /dev/null
+++ b/Extensions/common/vote-transition.js
@@ -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,
+};
diff --git a/Extensions/common/vote-transition.spec.js b/Extensions/common/vote-transition.spec.js
new file mode 100644
index 0000000..56f0a28
--- /dev/null
+++ b/Extensions/common/vote-transition.spec.js
@@ -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);
+ });
+});
diff --git a/Extensions/e2e/README.md b/Extensions/e2e/README.md
new file mode 100644
index 0000000..5f0592d
--- /dev/null
+++ b/Extensions/e2e/README.md
@@ -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.
diff --git a/Extensions/e2e/continuous-invariants.js b/Extensions/e2e/continuous-invariants.js
new file mode 100644
index 0000000..5e5f94c
--- /dev/null
+++ b/Extensions/e2e/continuous-invariants.js
@@ -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,
+};
diff --git a/Extensions/e2e/continuous-invariants.spec.js b/Extensions/e2e/continuous-invariants.spec.js
new file mode 100644
index 0000000..dd44711
--- /dev/null
+++ b/Extensions/e2e/continuous-invariants.spec.js
@@ -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();
+ });
+});
diff --git a/Extensions/e2e/hermetic-artifact-smoke.js b/Extensions/e2e/hermetic-artifact-smoke.js
new file mode 100644
index 0000000..7f8bc37
--- /dev/null
+++ b/Extensions/e2e/hermetic-artifact-smoke.js
@@ -0,0 +1,1476 @@
+const assert = require("node:assert/strict");
+const fs = require("node:fs");
+const http = require("node:http");
+const os = require("node:os");
+const path = require("node:path");
+const { chromium } = require("@playwright/test");
+const {
+ VIDEO_A,
+ VIDEO_B,
+ createFakeBackend,
+ installGmEnvironment,
+ openNavigationFixture,
+ openWatchFixture,
+} = require("../UserScript/e2e/harness");
+const { assertInvariantContinuously, waitForStableInvariant } = require("./continuous-invariants");
+const { LIVE_RUNTIME_PROFILES } = require("./live-runtime-adapter");
+const { SHARED_LIVE_SCENARIO_IDS } = require("./shared-live-scenarios");
+
+const REPOSITORY_ROOT = path.resolve(__dirname, "../..");
+const PRODUCTION_API_ORIGIN = "https://returnyoutubedislikeapi.com";
+const ARTIFACT_SMOKE_SCENARIO_ID = "watch-render";
+const ARTIFACT_WATCH_SPA_SCENARIO_ID = "watch-spa-side-panel";
+const ARTIFACT_WATCH_SPA_VOTE_SCENARIO_ID = "watch-spa-dislike-activation";
+const ARTIFACT_EXTENSION_DELAYED_FAILURE_SCENARIO_ID = "extension-watch-spa-delayed-outgoing-failure";
+const SHARED_ARTIFACT_SCENARIO_IDS = Object.freeze([
+ ARTIFACT_SMOKE_SCENARIO_ID,
+ ARTIFACT_WATCH_SPA_SCENARIO_ID,
+ ARTIFACT_WATCH_SPA_VOTE_SCENARIO_ID,
+]);
+const SPA_COUNTS = Object.freeze({
+ [VIDEO_A]: Object.freeze({ dislikes: 10, likes: 90 }),
+ [VIDEO_B]: Object.freeze({ dislikes: 65, likes: 35 }),
+});
+const DEFAULT_EXTENSION_ARTIFACT = path.join(REPOSITORY_ROOT, "Extensions", "combined", "dist", "chrome");
+const DEFAULT_USERSCRIPT_ARTIFACT = path.join(
+ REPOSITORY_ROOT,
+ "Extensions",
+ "UserScript",
+ "Return Youtube Dislike.user.js",
+);
+const ZERO_DIFFICULTY_PUZZLE = {
+ challenge: Buffer.alloc(16).toString("base64"),
+ difficulty: 0,
+};
+const ARTIFACT_UNHANDLED_REJECTION_BINDING = "__rydArtifactReportUnhandledRejection";
+const CONSOLE_FAILURE_TYPES = new Set(["assert", "error"]);
+
+function serializeBrowserError(error) {
+ return {
+ message: error?.message ?? String(error),
+ name: error?.name ?? "Error",
+ stack: error?.stack ?? null,
+ };
+}
+
+async function createPageSignalCollector(page, runtime) {
+ assert.ok(page && typeof page.on === "function", "A Playwright page is required to collect browser signals.");
+ assert.ok(["extension", "userscript"].includes(runtime), "A supported runtime is required for page diagnostics.");
+
+ const consoleErrors = [];
+ const pageErrors = [];
+ const unhandledRejections = [];
+
+ page.on("console", (message) => {
+ if (!CONSOLE_FAILURE_TYPES.has(message.type())) return;
+ consoleErrors.push({
+ location: message.location(),
+ text: message.text(),
+ type: message.type(),
+ });
+ });
+ page.on("pageerror", (error) => pageErrors.push(serializeBrowserError(error)));
+
+ await page.exposeBinding(ARTIFACT_UNHANDLED_REJECTION_BINDING, (source, rejection) => {
+ unhandledRejections.push({
+ ...rejection,
+ frameUrl: source.frame?.url() ?? null,
+ });
+ });
+ await page.addInitScript(
+ ({ bindingName }) => {
+ globalThis.addEventListener("unhandledrejection", (event) => {
+ const reason = event.reason;
+ let serialized;
+ if (reason instanceof Error || (reason && typeof reason.message === "string")) {
+ serialized = {
+ message: reason.message,
+ name: typeof reason.name === "string" ? reason.name : "Error",
+ stack: reason.stack ?? null,
+ };
+ } else {
+ let value;
+ try {
+ value = JSON.stringify(reason);
+ } catch {
+ value = String(reason);
+ }
+ serialized = {
+ message: value === undefined ? String(reason) : value,
+ name: "UnhandledRejection",
+ stack: null,
+ };
+ }
+ void Promise.resolve(globalThis[bindingName](serialized)).catch(() => {});
+ });
+ },
+ { bindingName: ARTIFACT_UNHANDLED_REJECTION_BINDING },
+ );
+
+ const snapshot = () => ({
+ consoleErrors: consoleErrors.map((signal) => ({ ...signal, location: { ...signal.location } })),
+ pageErrors: pageErrors.map((signal) => ({ ...signal })),
+ runtime,
+ unhandledRejections: unhandledRejections.map((signal) => ({ ...signal })),
+ });
+
+ return {
+ async assertClean(scenarioId) {
+ assert.equal(typeof scenarioId, "string", "A scenario id is required when checking page signals.");
+ await page.evaluate(() => new Promise((resolve) => globalThis.setTimeout(resolve, 0)));
+ const diagnostics = snapshot();
+ const failureCount =
+ diagnostics.consoleErrors.length + diagnostics.pageErrors.length + diagnostics.unhandledRejections.length;
+ assert.equal(
+ failureCount,
+ 0,
+ `${runtime} emitted unexpected browser signals during ${scenarioId}: ${JSON.stringify(diagnostics, null, 2)}`,
+ );
+ return diagnostics;
+ },
+ snapshot,
+ };
+}
+
+function assertLoopbackOrigin(value) {
+ const url = new URL(value);
+ assert.ok(["http:", "https:"].includes(url.protocol), "The hermetic API origin must use HTTP or HTTPS.");
+ assert.ok(
+ ["127.0.0.1", "::1", "[::1]", "localhost"].includes(url.hostname),
+ `Refusing to prepare a hermetic extension artifact for non-loopback origin ${url.origin}.`,
+ );
+ assert.equal(url.pathname, "/", "The hermetic API value must be an origin without a path.");
+ return url.origin;
+}
+
+function removeOwnedTemporaryDirectory(directory, prefix) {
+ if (!directory) return;
+ const resolvedDirectory = path.resolve(directory);
+ const resolvedTemporaryRoot = path.resolve(os.tmpdir());
+ assert.equal(path.dirname(resolvedDirectory), resolvedTemporaryRoot, "Refusing to remove a non-temporary directory.");
+ assert.ok(path.basename(resolvedDirectory).startsWith(prefix), "Refusing to remove an unowned temporary directory.");
+ fs.rmSync(resolvedDirectory, { force: true, recursive: true });
+}
+
+function prepareHermeticExtensionArtifact(sourceDirectory, apiOrigin) {
+ const origin = assertLoopbackOrigin(apiOrigin);
+ const source = path.resolve(sourceDirectory);
+ for (const requiredFile of ["manifest.json", "menu-fixer.js", "ryd.background.js", "ryd.content-script.js"]) {
+ if (!fs.existsSync(path.join(source, requiredFile))) {
+ throw new Error(`The extension artifact is missing ${requiredFile}: ${source}`);
+ }
+ }
+
+ const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ryd-mv3-e2e-"));
+ const extensionDirectory = path.join(temporaryRoot, "extension");
+ fs.cpSync(source, extensionDirectory, { recursive: true });
+
+ const backgroundBundlePath = path.join(extensionDirectory, "ryd.background.js");
+ const backgroundSource = fs.readFileSync(backgroundBundlePath, "utf8");
+ const replacementCount = backgroundSource.split(PRODUCTION_API_ORIGIN).length - 1;
+ if (replacementCount < 1) {
+ removeOwnedTemporaryDirectory(temporaryRoot, "ryd-mv3-e2e-");
+ throw new Error("ryd.background.js has no production API origin to replace; rebuild the extension before testing.");
+ }
+ const transformedBackground = backgroundSource.replaceAll(PRODUCTION_API_ORIGIN, origin);
+ assert.equal(
+ transformedBackground.includes(PRODUCTION_API_ORIGIN),
+ false,
+ "ryd.background.js still contains the production API origin after transformation.",
+ );
+ const changelogListener = `api.runtime.onInstalled.addListener((details) => {
+ maybeShowChangelog(details);
+});`;
+ if (!transformedBackground.includes(changelogListener)) {
+ removeOwnedTemporaryDirectory(temporaryRoot, "ryd-mv3-e2e-");
+ throw new Error("ryd.background.js has no recognized first-install changelog listener to suppress.");
+ }
+ const hermeticBackground = transformedBackground.replace(
+ changelogListener,
+ "api.runtime.onInstalled.addListener(() => {});",
+ );
+ fs.writeFileSync(backgroundBundlePath, hermeticBackground);
+
+ const contentScriptPath = path.join(extensionDirectory, "ryd.content-script.js");
+ const contentScriptSource = fs.readFileSync(contentScriptPath, "utf8");
+ if (!contentScriptSource.includes(PRODUCTION_API_ORIGIN)) {
+ removeOwnedTemporaryDirectory(temporaryRoot, "ryd-mv3-e2e-");
+ throw new Error("ryd.content-script.js has no production API origin for the pre-navigation route to intercept.");
+ }
+
+ const manifestPath = path.join(extensionDirectory, "manifest.json");
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
+ const apiUrl = new URL(origin);
+ const loopbackPermission = `${apiUrl.protocol}//${apiUrl.hostname}/*`;
+ manifest.host_permissions = [...new Set([...(manifest.host_permissions ?? []), loopbackPermission])];
+ fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
+
+ return {
+ extensionDirectory,
+ loopbackPermission,
+ replacements: { "ryd.background.js": replacementCount, firstInstallChangelogListener: 1 },
+ routedBundles: ["ryd.content-script.js"],
+ temporaryRoot,
+ };
+}
+
+function readRequestBody(request) {
+ return new Promise((resolve, reject) => {
+ const chunks = [];
+ request.on("data", (chunk) => chunks.push(chunk));
+ request.on("end", () => {
+ const text = Buffer.concat(chunks).toString("utf8");
+ if (!text) {
+ resolve(null);
+ return;
+ }
+ try {
+ resolve(JSON.parse(text));
+ } catch {
+ resolve(text);
+ }
+ });
+ request.on("error", reject);
+ });
+}
+
+async function startHermeticApiServer({ dislikes = 25, likes = 100 } = {}) {
+ const records = [];
+ const unexpectedRequests = [];
+ const server = http.createServer(async (request, response) => {
+ const origin = `http://${request.headers.host}`;
+ const url = new URL(request.url, origin);
+ const record = {
+ body: await readRequestBody(request),
+ method: request.method,
+ pathname: url.pathname,
+ query: Object.fromEntries(url.searchParams.entries()),
+ };
+ records.push(record);
+
+ const headers = {
+ "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",
+ };
+ if (request.method === "OPTIONS") {
+ record.respondedAt = Date.now();
+ record.responseBody = null;
+ record.responseStatus = 204;
+ response.writeHead(204, headers);
+ response.end();
+ return;
+ }
+
+ let body;
+ if (request.method === "GET" && url.pathname === "/configs/selectors") body = {};
+ else if (request.method === "GET" && url.pathname === "/votes") body = { dislikes, likes, rating: 4.5 };
+ else if (request.method === "GET" && url.pathname === "/puzzle/registration") body = ZERO_DIFFICULTY_PUZZLE;
+ else if (request.method === "POST" && url.pathname === "/puzzle/registration") body = true;
+ else if (request.method === "POST" && url.pathname === "/interact/vote") body = ZERO_DIFFICULTY_PUZZLE;
+ else if (request.method === "POST" && url.pathname === "/interact/confirmVote") body = true;
+ else {
+ unexpectedRequests.push(record);
+ record.respondedAt = Date.now();
+ record.responseBody = { error: "unexpected hermetic request" };
+ record.responseStatus = 404;
+ response.writeHead(404, headers);
+ response.end(JSON.stringify(record.responseBody));
+ return;
+ }
+
+ record.respondedAt = Date.now();
+ record.responseBody = body;
+ record.responseStatus = 200;
+ response.writeHead(200, headers);
+ response.end(JSON.stringify(body));
+ });
+
+ await new Promise((resolve, reject) => {
+ server.once("error", reject);
+ server.listen(0, "127.0.0.1", resolve);
+ });
+ const address = server.address();
+ assert.ok(address && typeof address === "object");
+
+ return {
+ close: () => new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))),
+ origin: `http://127.0.0.1:${address.port}`,
+ records,
+ unexpectedRequests,
+ };
+}
+
+async function installArtifactRoutes(context, backend, { passthroughOrigin = null } = {}) {
+ await context.route("**/*", async (route) => {
+ const url = new URL(route.request().url());
+ if (url.protocol === "chrome-extension:") {
+ await route.continue();
+ return;
+ }
+ if (passthroughOrigin && url.origin === passthroughOrigin) {
+ await route.continue();
+ return;
+ }
+ await backend.handle(route);
+ });
+}
+
+async function waitForWatchResult(page, runtime, videoId) {
+ const profile = LIVE_RUNTIME_PROFILES[runtime];
+ await page.waitForFunction(
+ ({ rateBarContainer, videoId: expectedVideoId }) => {
+ const watch = document.querySelector(`ytd-watch-flexy[video-id="${expectedVideoId}"]`);
+ const dislikeText = watch
+ ? document.querySelector('[data-ryd-role="dislike"] #text, [data-ryd-role="dislike"] [role="text"]')
+ : null;
+ const count = (dislikeText?.textContent ?? "").replace(/\s+/g, " ").trim();
+ const bar = document.querySelector(rateBarContainer);
+ return watch && bar && /\d/.test(count);
+ },
+ { rateBarContainer: profile.selectors.rateBarContainer, videoId },
+ );
+
+ return page.evaluate(
+ ({ rateBar, rateBarContainer, videoId: expectedVideoId }) => {
+ const dislikeText = document.querySelector(
+ '[data-ryd-role="dislike"] #text, [data-ryd-role="dislike"] [role="text"]',
+ );
+ const container = document.querySelector(rateBarContainer);
+ const fill = document.querySelector(rateBar);
+ const visible = (element) => {
+ const box = element?.getBoundingClientRect();
+ return Boolean(box && box.width > 0 && box.height > 0);
+ };
+ return {
+ count: (dislikeText?.textContent ?? "").replace(/\s+/g, " ").trim(),
+ fillRatio:
+ container && fill && container.getBoundingClientRect().width > 0
+ ? fill.getBoundingClientRect().width / container.getBoundingClientRect().width
+ : null,
+ fillVisible: visible(fill),
+ rateBarVisible: visible(container),
+ videoId: document.querySelector("ytd-watch-flexy")?.getAttribute("video-id") ?? null,
+ expectedVideoId,
+ };
+ },
+ { ...profile.selectors, videoId },
+ );
+}
+
+async function prepareSpaOutgoingControls(page, fromVideoId) {
+ return page.evaluate((expectedVideoId) => {
+ const fixturePage = document.querySelector("#fixture-page");
+ const currentSection = fixturePage?.querySelector(
+ `[data-fixture-page-kind="watch"][data-fixture-video-id="${expectedVideoId}"]`,
+ );
+ const outgoingTopRow = currentSection?.querySelector("#top-row");
+ if (!fixturePage || !currentSection || !outgoingTopRow) {
+ throw new Error(`The outgoing watch fixture for ${expectedVideoId} is not ready.`);
+ }
+ if (!outgoingTopRow.querySelector(".ryd-tooltip")) {
+ throw new Error(`The outgoing watch fixture for ${expectedVideoId} has no initialized ratio bar.`);
+ }
+
+ const beforeHolder = document.createElement("div");
+ beforeHolder.hidden = true;
+ beforeHolder.setAttribute("data-artifact-outgoing-position", "before-current-root");
+ beforeHolder.setAttribute("data-artifact-outgoing-video-id", expectedVideoId);
+ beforeHolder.appendChild(outgoingTopRow.cloneNode(true));
+ fixturePage.before(beforeHolder);
+
+ globalThis.__artifactInsideOutgoingActions = outgoingTopRow.cloneNode(true);
+ return {
+ beforeBarCount: beforeHolder.querySelectorAll(".ryd-tooltip").length,
+ fromVideoId: expectedVideoId,
+ };
+ }, fromVideoId);
+}
+
+async function preparePendingSpaOutgoingControls(page, fromVideoId) {
+ return page.evaluate((expectedVideoId) => {
+ const fixturePage = document.querySelector("#fixture-page");
+ const currentSection = fixturePage?.querySelector(
+ `[data-fixture-page-kind="watch"][data-fixture-video-id="${expectedVideoId}"]`,
+ );
+ const outgoingTopRow = currentSection?.querySelector("#top-row");
+ if (!fixturePage || !currentSection || !outgoingTopRow) {
+ throw new Error(`The pending outgoing watch fixture for ${expectedVideoId} is not ready.`);
+ }
+
+ const beforeHolder = document.createElement("div");
+ beforeHolder.hidden = true;
+ beforeHolder.setAttribute("data-artifact-outgoing-position", "before-current-root");
+ beforeHolder.setAttribute("data-artifact-outgoing-video-id", expectedVideoId);
+ beforeHolder.appendChild(outgoingTopRow.cloneNode(true));
+ fixturePage.before(beforeHolder);
+
+ globalThis.__artifactInsideOutgoingActions = outgoingTopRow.cloneNode(true);
+ return {
+ beforeBarCount: beforeHolder.querySelectorAll(".ryd-tooltip").length,
+ fromVideoId: expectedVideoId,
+ };
+ }, fromVideoId);
+}
+
+async function finishSpaDestinationReplacement(page, toVideoId) {
+ return page.evaluate(
+ ({ expectedLikes, expectedVideoId }) => {
+ const currentSection = document.querySelector(
+ `#fixture-page [data-fixture-page-kind="watch"][data-fixture-video-id="${expectedVideoId}"]`,
+ );
+ if (!currentSection) throw new Error(`The destination watch fixture for ${expectedVideoId} is missing.`);
+ if (!globalThis.__artifactInsideOutgoingActions) {
+ throw new Error("The retained inside-current-root outgoing controls are missing.");
+ }
+
+ const insideHolder = document.createElement("div");
+ insideHolder.hidden = true;
+ insideHolder.setAttribute("data-artifact-outgoing-position", "inside-current-root");
+ insideHolder.setAttribute(
+ "data-artifact-outgoing-video-id",
+ globalThis.__artifactInsideOutgoingActions
+ .querySelector("[data-fixture-control-video-id]")
+ ?.getAttribute("data-fixture-control-video-id") ?? "unknown",
+ );
+ insideHolder.appendChild(globalThis.__artifactInsideOutgoingActions);
+ currentSection.appendChild(insideHolder);
+ delete globalThis.__artifactInsideOutgoingActions;
+
+ const replaced = globalThis.__navigationFixture.replaceCurrentWatchActions({ retainOutgoing: true });
+ if (!replaced) throw new Error(`The destination action container for ${expectedVideoId} was not replaced.`);
+ const destinationActions = currentSection.querySelector(
+ `#top-level-buttons-computed[data-fixture-watch-actions-replacement="${expectedVideoId}"]`,
+ );
+ const likeButton = destinationActions?.querySelector('[data-ryd-role="like"] button');
+ if (!likeButton) throw new Error(`The replacement controls for ${expectedVideoId} have no Like button.`);
+ likeButton.setAttribute("aria-label", `${expectedLikes} likes`);
+ const likeText = likeButton.querySelector("#text, [role='text']");
+ if (likeText) likeText.textContent = String(expectedLikes);
+ return { destinationReplaced: true, insideBarCount: insideHolder.querySelectorAll(".ryd-tooltip").length };
+ },
+ { expectedLikes: SPA_COUNTS[toVideoId].likes, expectedVideoId: toVideoId },
+ );
+}
+
+async function observeSpaDestinationDislikeText(page, videoId) {
+ await page.evaluate((expectedVideoId) => {
+ globalThis.__artifactDestinationDislikeTextObserver?.disconnect();
+ const currentRoot = document.querySelector(
+ `#fixture-page [data-fixture-page-kind="watch"][data-fixture-video-id="${expectedVideoId}"]`,
+ );
+ const count = currentRoot?.querySelector(
+ `#top-level-buttons-computed[data-fixture-watch-actions-replacement="${expectedVideoId}"] ` +
+ `[data-fixture-control-video-id="${expectedVideoId}"] [data-ryd-role="dislike"] #text`,
+ );
+ if (!count) throw new Error(`The destination dislike text for ${expectedVideoId} is missing.`);
+ const read = () => (count.textContent ?? "").replace(/\s+/g, " ").trim();
+ globalThis.__artifactDestinationDislikeTexts = [read()];
+ globalThis.__artifactDestinationDislikeTextObserver = new MutationObserver(() => {
+ globalThis.__artifactDestinationDislikeTexts.push(read());
+ });
+ globalThis.__artifactDestinationDislikeTextObserver.observe(count, {
+ characterData: true,
+ childList: true,
+ subtree: true,
+ });
+ }, videoId);
+}
+
+async function readSpaWatchSnapshot(page, runtime, fromVideoId, toVideoId) {
+ const profile = LIVE_RUNTIME_PROFILES[runtime];
+ return page.evaluate(
+ ({ fromVideoId: outgoingVideoId, profile: runtimeProfile, toVideoId: destinationVideoId }) => {
+ const normalizedText = (element) => (element?.textContent ?? "").replace(/\s+/g, " ").trim();
+ const visibleBox = (element) => {
+ const box = element?.getBoundingClientRect();
+ return box && box.width > 0 && box.height > 0
+ ? { height: box.height, width: box.width, x: box.x, y: box.y }
+ : null;
+ };
+ const retainedState = (selector) => {
+ const holder = document.querySelector(selector);
+ return {
+ barCount: holder?.querySelectorAll(runtimeProfile.selectors.rateBar).length ?? -1,
+ containerCount: holder?.querySelectorAll(runtimeProfile.selectors.rateBarContainer).length ?? -1,
+ controlVideoIds: [...(holder?.querySelectorAll("[data-fixture-control-video-id]") ?? [])].map((control) =>
+ control.getAttribute("data-fixture-control-video-id"),
+ ),
+ hidden: holder?.hidden === true,
+ present: holder !== null,
+ wrapperCount: holder?.querySelectorAll(".ryd-tooltip").length ?? -1,
+ };
+ };
+
+ const currentRoot = document.querySelector(
+ `#fixture-page [data-fixture-page-kind="watch"][data-fixture-video-id="${destinationVideoId}"]`,
+ );
+ const actionHost = currentRoot?.querySelector(
+ `#top-level-buttons-computed[data-fixture-watch-actions-replacement="${destinationVideoId}"]`,
+ );
+ const controls = actionHost?.querySelector(`[data-fixture-control-video-id="${destinationVideoId}"]`);
+ const countElement = controls?.querySelector(
+ '[data-ryd-role="dislike"] #text, [data-ryd-role="dislike"] [role="text"]',
+ );
+ const wrapper = actionHost?.querySelector(":scope > .ryd-tooltip");
+ const container = wrapper?.querySelector(runtimeProfile.selectors.rateBarContainer);
+ const fill = container?.querySelector(runtimeProfile.selectors.rateBar);
+ const tooltip = wrapper?.querySelector(runtimeProfile.selectors.tooltipContent);
+ const containerBox = visibleBox(container);
+ const fillBox = visibleBox(fill);
+ const currentVideoId = currentRoot?.querySelector("ytd-watch-flexy")?.getAttribute("video-id") ?? null;
+ const url = new URL(location.href);
+
+ return {
+ actionHostCount:
+ currentRoot?.querySelectorAll(
+ `#top-level-buttons-computed[data-fixture-watch-actions-replacement="${destinationVideoId}"]`,
+ ).length ?? 0,
+ barOwnedByDestination: Boolean(fill && fill.closest("#top-level-buttons-computed") === actionHost),
+ containerOwnedByDestination: Boolean(
+ container && container.closest("#top-level-buttons-computed") === actionHost,
+ ),
+ count: normalizedText(countElement),
+ currentVideoId,
+ destinationBarCount: actionHost?.querySelectorAll(runtimeProfile.selectors.rateBar).length ?? 0,
+ destinationContainerCount: actionHost?.querySelectorAll(runtimeProfile.selectors.rateBarContainer).length ?? 0,
+ destinationControlCount:
+ actionHost?.querySelectorAll(`[data-fixture-control-video-id="${destinationVideoId}"]`).length ?? 0,
+ destinationWrapperCount: actionHost?.querySelectorAll(":scope > .ryd-tooltip").length ?? 0,
+ fillRatio: containerBox && fillBox ? fillBox.width / containerBox.width : null,
+ globalBarCount: document.querySelectorAll(runtimeProfile.selectors.rateBar).length,
+ globalContainerCount: document.querySelectorAll(runtimeProfile.selectors.rateBarContainer).length,
+ globalWrapperCount: document.querySelectorAll(".ryd-tooltip").length,
+ insideOutgoing: retainedState(
+ `[data-artifact-outgoing-position="inside-current-root"][data-artifact-outgoing-video-id="${outgoingVideoId}"]`,
+ ),
+ retainedDestination: retainedState(`[data-fixture-retained-settling-watch-actions="${destinationVideoId}"]`),
+ retainedBefore: retainedState(
+ `[data-artifact-outgoing-position="before-current-root"][data-artifact-outgoing-video-id="${outgoingVideoId}"]`,
+ ),
+ tooltipText: normalizedText(tooltip),
+ urlVideoId: url.pathname === "/watch" ? url.searchParams.get("v") : null,
+ visibleContainer: containerBox !== null,
+ visibleFill: fillBox !== null,
+ };
+ },
+ { fromVideoId, profile, toVideoId },
+ );
+}
+
+function isSpaDestinationValid(snapshot, { expectedCount, expectedRatio, fromVideoId, toVideoId }) {
+ const outgoingRetained = [snapshot.retainedBefore, snapshot.insideOutgoing];
+ const hasNoRydBar = (state) =>
+ state.present === true &&
+ state.hidden === true &&
+ state.wrapperCount === 0 &&
+ state.containerCount === 0 &&
+ state.barCount === 0;
+ return (
+ snapshot.urlVideoId === toVideoId &&
+ snapshot.currentVideoId === toVideoId &&
+ snapshot.actionHostCount === 1 &&
+ snapshot.destinationControlCount === 1 &&
+ snapshot.destinationWrapperCount === 1 &&
+ snapshot.destinationContainerCount === 1 &&
+ snapshot.destinationBarCount === 1 &&
+ snapshot.globalWrapperCount === 1 &&
+ snapshot.globalContainerCount === 1 &&
+ snapshot.globalBarCount === 1 &&
+ snapshot.barOwnedByDestination === true &&
+ snapshot.containerOwnedByDestination === true &&
+ snapshot.visibleContainer === true &&
+ snapshot.visibleFill === true &&
+ snapshot.count === String(expectedCount) &&
+ snapshot.tooltipText.includes(`${SPA_COUNTS[toVideoId].likes} / ${expectedCount}`) &&
+ Number.isFinite(snapshot.fillRatio) &&
+ Math.abs(snapshot.fillRatio - expectedRatio) <= 0.02 &&
+ outgoingRetained.every((state) => hasNoRydBar(state) && state.controlVideoIds.includes(fromVideoId)) &&
+ hasNoRydBar(snapshot.retainedDestination) &&
+ snapshot.retainedDestination.controlVideoIds.includes(toVideoId)
+ );
+}
+
+async function clickSpaDestinationDislike(page, videoId) {
+ const selector =
+ `#fixture-page [data-fixture-page-kind="watch"][data-fixture-video-id="${videoId}"] ` +
+ `#top-level-buttons-computed[data-fixture-watch-actions-replacement="${videoId}"] ` +
+ `[data-fixture-control-video-id="${videoId}"] [data-ryd-role="dislike"] button`;
+ const buttons = page.locator(selector);
+ assert.equal(await buttons.count(), 1, `Expected exactly one destination Dislike activation target for ${videoId}.`);
+ const button = buttons.first();
+ assert.equal(
+ await button.isVisible(),
+ true,
+ `The destination Dislike activation target for ${videoId} is not visible.`,
+ );
+ const ariaPressedBefore = await button.getAttribute("aria-pressed");
+ await button.click();
+ return { ariaPressedBefore, selector, videoId };
+}
+
+function interactionRecordsSince(records, startIndex) {
+ return records
+ .slice(startIndex)
+ .filter(
+ (record) => record.method === "POST" && ["/interact/vote", "/interact/confirmVote"].includes(record.pathname),
+ );
+}
+
+function readArtifactVoteHandshake(records, startIndex, videoId, value) {
+ const interactions = interactionRecordsSince(records, startIndex);
+ const votes = interactions.filter((record) => record.pathname === "/interact/vote");
+ const confirmations = interactions.filter((record) => record.pathname === "/interact/confirmVote");
+ const vote = votes[0] ?? null;
+ const confirmation = confirmations[0] ?? null;
+ const userId = vote?.body?.userId ?? null;
+ return {
+ confirmation: confirmation
+ ? {
+ body: confirmation.body,
+ responded: Number.isFinite(confirmation.respondedAt),
+ responseBody: confirmation.responseBody,
+ responseStatus: confirmation.responseStatus,
+ }
+ : null,
+ confirmationCount: confirmations.length,
+ expectedValue: value,
+ expectedVideoId: videoId,
+ interactionPaths: interactions.map((record) => record.pathname),
+ interactionCount: interactions.length,
+ sharedUserId:
+ typeof userId === "string" && userId.length > 0 && confirmation?.body?.userId === userId ? userId : null,
+ vote: vote ? { body: vote.body } : null,
+ voteCount: votes.length,
+ };
+}
+
+function enqueueRecordedSuccessfulVoteResponses(backend) {
+ backend.enqueue("POST", "/interact/vote", (record) => {
+ record.responseBody = ZERO_DIFFICULTY_PUZZLE;
+ record.responseStatus = 200;
+ return { body: record.responseBody };
+ });
+ backend.enqueue("POST", "/interact/confirmVote", (record) => {
+ record.responseBody = true;
+ record.responseStatus = 200;
+ return { body: record.responseBody };
+ });
+}
+
+function isArtifactVoteHandshakeValid(snapshot) {
+ if (
+ snapshot.interactionCount !== 2 ||
+ snapshot.voteCount !== 1 ||
+ snapshot.confirmationCount !== 1 ||
+ typeof snapshot.sharedUserId !== "string" ||
+ !/^[A-Za-z0-9]{36}$/.test(snapshot.sharedUserId) ||
+ snapshot.interactionPaths?.join(",") !== "/interact/vote,/interact/confirmVote" ||
+ snapshot.confirmation?.responded !== true ||
+ snapshot.confirmation?.responseStatus !== 200 ||
+ snapshot.confirmation?.responseBody !== true
+ ) {
+ return false;
+ }
+ const voteBody = snapshot.vote?.body;
+ const confirmationBody = snapshot.confirmation?.body;
+ let solutionBytes = null;
+ try {
+ solutionBytes = Buffer.from(confirmationBody?.solution ?? "", "base64");
+ } catch {
+ // The validity result below reports malformed proof material without throwing from a polling predicate.
+ }
+ return (
+ voteBody?.userId === snapshot.sharedUserId &&
+ voteBody?.videoId === snapshot.expectedVideoId &&
+ voteBody?.value === snapshot.expectedValue &&
+ Object.keys(voteBody).sort().join(",") === "userId,value,videoId" &&
+ confirmationBody?.userId === snapshot.sharedUserId &&
+ confirmationBody?.videoId === snapshot.expectedVideoId &&
+ Object.keys(confirmationBody).sort().join(",") === "solution,userId,videoId" &&
+ typeof confirmationBody?.solution === "string" &&
+ solutionBytes?.length === 4
+ );
+}
+
+function assertSpaStatsTraffic(backend, fromVideoId, toVideoId) {
+ const votesFor = (videoId) =>
+ backend.requestsFor("GET", "/votes").filter((request) => request.query.videoId === videoId);
+ assert.equal(votesFor(fromVideoId).length, 1, `Expected one stats request for outgoing video ${fromVideoId}.`);
+ assert.equal(votesFor(toVideoId).length, 1, `Expected one stats request for destination video ${toVideoId}.`);
+ assert.deepEqual(
+ backend.blockedRequests,
+ [],
+ `The SPA scenario attempted unexpected network traffic: ${JSON.stringify(backend.blockedRequests)}`,
+ );
+ return { fromVideoRequests: votesFor(fromVideoId).length, toVideoRequests: votesFor(toVideoId).length };
+}
+
+function assertSpaBackendTraffic(backend, fromVideoId, toVideoId) {
+ const stats = assertSpaStatsTraffic(backend, fromVideoId, toVideoId);
+ assert.equal(backend.requestsFor("POST", "/interact/vote").length, 0, "The read-only SPA scenario submitted a vote.");
+ assert.equal(
+ backend.requestsFor("POST", "/interact/confirmVote").length,
+ 0,
+ "The read-only SPA scenario confirmed a vote.",
+ );
+ return {
+ ...stats,
+ interactionRequests: 0,
+ };
+}
+
+function readSpaTraffic(backend) {
+ return backend.requests.map(({ method, pathname, query }) => ({ method, pathname, query }));
+}
+
+async function readWatchDiagnostics(page, runtime, videoId) {
+ const profile = LIVE_RUNTIME_PROFILES[runtime];
+ return page.evaluate(
+ ({ rateBar, rateBarContainer, videoId: expectedVideoId }) => ({
+ bodyText: (document.body?.innerText ?? "").replace(/\s+/g, " ").trim().slice(0, 500),
+ currentVideoId: document.querySelector("ytd-watch-flexy")?.getAttribute("video-id") ?? null,
+ dislikeText:
+ document
+ .querySelector('[data-ryd-role="dislike"] #text, [data-ryd-role="dislike"] [role="text"]')
+ ?.textContent?.trim() ?? null,
+ expectedVideoId,
+ fillPresent: document.querySelector(rateBar) !== null,
+ rateBarPresent: document.querySelector(rateBarContainer) !== null,
+ setStateCalls: globalThis.__rydSetStateCalls ?? 0,
+ }),
+ { ...profile.selectors, videoId },
+ );
+}
+
+class HermeticUserscriptArtifactAdapter {
+ constructor({
+ artifactPath = DEFAULT_USERSCRIPT_ARTIFACT,
+ backendOptions = {},
+ browserType = chromium,
+ headless = true,
+ } = {}) {
+ this.artifactPath = path.resolve(artifactPath);
+ this.backendOptions = backendOptions;
+ this.browserType = browserType;
+ this.headless = headless;
+ this.profile = LIVE_RUNTIME_PROFILES.userscript;
+ this.runtime = "userscript";
+ }
+
+ async start() {
+ if (!fs.existsSync(this.artifactPath)) throw new Error(`Generated userscript is missing: ${this.artifactPath}`);
+ this.backend = createFakeBackend(this.backendOptions);
+ enqueueRecordedSuccessfulVoteResponses(this.backend);
+ this.browser = await this.browserType.launch({ headless: this.headless });
+ this.context = await this.browser.newContext({ serviceWorkers: "block" });
+ await installGmEnvironment(this.context);
+ await installArtifactRoutes(this.context, this.backend);
+ this.page = await this.context.newPage();
+ this.pageSignals = await createPageSignalCollector(this.page, this.runtime);
+ }
+
+ async openWatch(videoId) {
+ await openWatchFixture(this.page, videoId);
+ await this.page.addScriptTag({ path: this.artifactPath });
+ }
+
+ async openSpaWatch(videoId) {
+ await openNavigationFixture(this.page, { pageKind: "watch", videoId });
+ await this.page.addScriptTag({ path: this.artifactPath });
+ }
+
+ async navigateSpaWatch(fromVideoId, toVideoId) {
+ const outgoing = await prepareSpaOutgoingControls(this.page, fromVideoId);
+ await this.page.locator("#watch-related").click();
+ const destination = await finishSpaDestinationReplacement(this.page, toVideoId);
+ return { destination, outgoing };
+ }
+
+ async activateSpaDislike(videoId) {
+ const interactionStartIndex = this.backend.requests.length;
+ const activation = await clickSpaDestinationDislike(this.page, videoId);
+ return { ...activation, interactionStartIndex };
+ }
+
+ async readSpaVoteHandshake(interactionStartIndex, videoId, value) {
+ return readArtifactVoteHandshake(this.backend.requests, interactionStartIndex, videoId, value);
+ }
+
+ async assertSpaVoteNetwork(fromVideoId, toVideoId, interactionStartIndex) {
+ const stats = assertSpaStatsTraffic(this.backend, fromVideoId, toVideoId);
+ const requestsAfterActivation = this.backend.requests.slice(interactionStartIndex);
+ assert.ok(
+ requestsAfterActivation.every(
+ (record) => record.method === "POST" && ["/interact/vote", "/interact/confirmVote"].includes(record.pathname),
+ ),
+ `The userscript made unexpected requests after activation: ${JSON.stringify(
+ readSpaTraffic({
+ requests: requestsAfterActivation,
+ }),
+ )}`,
+ );
+ return stats;
+ }
+
+ async readSpaWatchSnapshot(fromVideoId, toVideoId) {
+ return readSpaWatchSnapshot(this.page, this.runtime, fromVideoId, toVideoId);
+ }
+
+ async assertSpaNetwork(fromVideoId, toVideoId) {
+ return assertSpaBackendTraffic(this.backend, fromVideoId, toVideoId);
+ }
+
+ async readSpaTraffic() {
+ return { routedRequests: readSpaTraffic(this.backend) };
+ }
+
+ async waitForWatchResult(videoId) {
+ let result;
+ try {
+ result = await waitForWatchResult(this.page, this.runtime, videoId);
+ } catch (error) {
+ const diagnostics = {
+ page: await readWatchDiagnostics(this.page, this.runtime, videoId),
+ pageSignals: this.pageSignals.snapshot(),
+ productionOriginRequests: this.backend.requests,
+ };
+ throw new Error(`${error.message}\nUserscript artifact diagnostics: ${JSON.stringify(diagnostics, null, 2)}`, {
+ cause: error,
+ });
+ }
+ assert.equal(this.backend.blockedRequests.length, 0, "The userscript attempted unexpected network traffic.");
+ return result;
+ }
+
+ async assertNoPageSignals(scenarioId) {
+ return this.pageSignals.assertClean(scenarioId);
+ }
+
+ async close() {
+ await this.context?.close();
+ await this.browser?.close();
+ }
+}
+
+class HermeticExtensionArtifactAdapter {
+ constructor({
+ apiServer,
+ artifactDirectory = DEFAULT_EXTENSION_ARTIFACT,
+ backendOptions = {},
+ browserType = chromium,
+ channel = "chromium",
+ headless = true,
+ } = {}) {
+ if (!apiServer?.origin) throw new TypeError("The extension adapter requires a running hermetic API server.");
+ this.apiServer = apiServer;
+ this.artifactDirectory = path.resolve(artifactDirectory);
+ this.backendOptions = backendOptions;
+ this.browserType = browserType;
+ this.channel = channel;
+ this.headless = headless;
+ this.profile = LIVE_RUNTIME_PROFILES.extension;
+ this.runtime = "extension";
+ }
+
+ async start() {
+ this.backend = createFakeBackend(this.backendOptions);
+ this.backend.enqueue("GET", "/configs/selectors", {
+ body: { rateBar: { oldDesignActions: ["#top-level-buttons-computed"] } },
+ });
+ this.apiRecordStart = this.apiServer.records.length;
+ this.preparedArtifact = prepareHermeticExtensionArtifact(this.artifactDirectory, this.apiServer.origin);
+ this.profileDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "ryd-mv3-profile-"));
+ const extensionPath = this.preparedArtifact.extensionDirectory;
+ this.context = await this.browserType.launchPersistentContext(this.profileDirectory, {
+ args: [`--disable-extensions-except=${extensionPath}`, `--load-extension=${extensionPath}`, "--no-first-run"],
+ channel: this.channel,
+ headless: this.headless,
+ serviceWorkers: "allow",
+ });
+ await installArtifactRoutes(this.context, this.backend, { passthroughOrigin: this.apiServer.origin });
+
+ this.worker = this.context.serviceWorkers()[0];
+ if (!this.worker) this.worker = await this.context.waitForEvent("serviceworker", { timeout: 15_000 });
+ assert.match(this.worker.url(), /^chrome-extension:\/\//, "The real MV3 background worker did not start.");
+ this.page = await this.context.newPage();
+ this.pageSignals = await createPageSignalCollector(this.page, this.runtime);
+ }
+
+ async openWatch(videoId) {
+ await openWatchFixture(this.page, videoId);
+ }
+
+ async openSpaWatch(videoId) {
+ await openNavigationFixture(this.page, { pageKind: "watch", videoId });
+ }
+
+ async navigateSpaWatch(fromVideoId, toVideoId) {
+ const outgoing = await prepareSpaOutgoingControls(this.page, fromVideoId);
+ await this.page.locator("#watch-related").click();
+ const destination = await finishSpaDestinationReplacement(this.page, toVideoId);
+ return { destination, outgoing };
+ }
+
+ deferNextStatsRequest() {
+ return this.backend.defer("GET", "/votes");
+ }
+
+ async navigateSpaWatchWhilePending(fromVideoId, toVideoId) {
+ const outgoing = await preparePendingSpaOutgoingControls(this.page, fromVideoId);
+ await this.page.locator("#watch-related").click();
+ const destination = await finishSpaDestinationReplacement(this.page, toVideoId);
+ await observeSpaDestinationDislikeText(this.page, toVideoId);
+ return { destination, outgoing };
+ }
+
+ async readDestinationDislikeTextHistory() {
+ return this.page.evaluate(() => [...(globalThis.__artifactDestinationDislikeTexts ?? [])]);
+ }
+
+ readStatsRequestTimings() {
+ return this.backend.requestsFor("GET", "/votes").map(({ at, query, respondedAt }) => ({
+ at,
+ query: { ...query },
+ respondedAt,
+ }));
+ }
+
+ async activateSpaDislike(videoId) {
+ const interactionStartIndex = this.apiServer.records.length;
+ const activation = await clickSpaDestinationDislike(this.page, videoId);
+ return { ...activation, interactionStartIndex };
+ }
+
+ async readSpaVoteHandshake(interactionStartIndex, videoId, value) {
+ return readArtifactVoteHandshake(this.apiServer.records, interactionStartIndex, videoId, value);
+ }
+
+ async assertSpaVoteNetwork(fromVideoId, toVideoId, interactionStartIndex) {
+ const stats = assertSpaStatsTraffic(this.backend, fromVideoId, toVideoId);
+ assert.equal(
+ this.backend.requestsFor("POST", "/interact/vote").length,
+ 0,
+ "The extension content script bypassed its background vote transport.",
+ );
+ assert.equal(
+ this.backend.requestsFor("POST", "/interact/confirmVote").length,
+ 0,
+ "The extension content script bypassed its background confirmation transport.",
+ );
+ const backgroundAfterActivation = this.apiServer.records
+ .slice(interactionStartIndex)
+ .filter((record) => record.method !== "OPTIONS");
+ assert.ok(
+ backgroundAfterActivation.every(
+ (record) => record.method === "POST" && ["/interact/vote", "/interact/confirmVote"].includes(record.pathname),
+ ),
+ `The extension background made unexpected requests after activation: ${JSON.stringify(backgroundAfterActivation)}`,
+ );
+ assert.equal(this.apiServer.unexpectedRequests.length, 0, "The extension made an unexpected test-server request.");
+ return stats;
+ }
+
+ async readSpaWatchSnapshot(fromVideoId, toVideoId) {
+ return readSpaWatchSnapshot(this.page, this.runtime, fromVideoId, toVideoId);
+ }
+
+ async assertSpaNetwork(fromVideoId, toVideoId) {
+ const traffic = assertSpaBackendTraffic(this.backend, fromVideoId, toVideoId);
+ assert.equal(this.apiServer.unexpectedRequests.length, 0, "The extension made an unexpected test-server request.");
+ return traffic;
+ }
+
+ async readSpaTraffic() {
+ return {
+ backgroundRequests: this.apiServer.records.map(({ method, pathname, query }) => ({ method, pathname, query })),
+ routedRequests: readSpaTraffic(this.backend),
+ };
+ }
+
+ async waitForWatchResult(videoId) {
+ let result;
+ try {
+ result = await waitForWatchResult(this.page, this.runtime, videoId);
+ } catch (error) {
+ const diagnostics = {
+ apiRecords: this.apiServer.records,
+ page: await readWatchDiagnostics(this.page, this.runtime, videoId),
+ pageSignals: this.pageSignals.snapshot(),
+ productionOriginRequests: this.backend.requests,
+ unexpectedRequests: this.apiServer.unexpectedRequests,
+ workerUrl: this.worker.url(),
+ };
+ throw new Error(`${error.message}\nExtension artifact diagnostics: ${JSON.stringify(diagnostics, null, 2)}`, {
+ cause: error,
+ });
+ }
+ assert.deepEqual(
+ this.backend.blockedRequests,
+ [],
+ `The extension attempted unexpected network traffic: ${JSON.stringify(this.backend.blockedRequests)}`,
+ );
+ const unexpectedRoutedRequests = this.backend.requests.filter(
+ (request) => request.method !== "GET" || !["/configs/selectors", "/votes"].includes(request.pathname),
+ );
+ assert.deepEqual(
+ unexpectedRoutedRequests,
+ [],
+ "The extension content script made an unexpected production-origin request.",
+ );
+ assert.equal(this.apiServer.unexpectedRequests.length, 0, "The extension made an unexpected test-server request.");
+ return { ...result, workerUrl: this.worker.url() };
+ }
+
+ async assertNoPageSignals(scenarioId) {
+ return this.pageSignals.assertClean(scenarioId);
+ }
+
+ async close() {
+ await this.context?.close();
+ removeOwnedTemporaryDirectory(this.profileDirectory, "ryd-mv3-profile-");
+ removeOwnedTemporaryDirectory(this.preparedArtifact?.temporaryRoot, "ryd-mv3-e2e-");
+ }
+}
+
+async function runArtifactWatchRenderScenario(adapter, { videoId = VIDEO_A } = {}) {
+ assert.ok(
+ SHARED_LIVE_SCENARIO_IDS.includes(ARTIFACT_SMOKE_SCENARIO_ID),
+ `${ARTIFACT_SMOKE_SCENARIO_ID} must remain in the shared scenario catalog.`,
+ );
+ assert.ok(
+ ["extension", "userscript"].includes(adapter?.runtime),
+ "A supported artifact runtime adapter is required.",
+ );
+ for (const method of ["start", "openWatch", "waitForWatchResult", "assertNoPageSignals", "close"]) {
+ assert.equal(
+ typeof adapter[method],
+ "function",
+ `The ${adapter.runtime} artifact adapter must implement ${method}().`,
+ );
+ }
+
+ try {
+ await adapter.start();
+ await adapter.openWatch(videoId);
+ const result = await adapter.waitForWatchResult(videoId);
+ assert.equal(result.videoId, videoId);
+ assert.equal(result.rateBarVisible, true, `${adapter.runtime} did not render a visible watch ratio bar.`);
+ assert.equal(result.fillVisible, true, `${adapter.runtime} did not render a visible watch ratio fill.`);
+ assert.match(result.count, /\d/, `${adapter.runtime} did not render a numeric dislike count.`);
+ await adapter.assertNoPageSignals(ARTIFACT_SMOKE_SCENARIO_ID);
+ return { ...result, runtime: adapter.runtime, scenarioId: ARTIFACT_SMOKE_SCENARIO_ID };
+ } finally {
+ await adapter.close();
+ }
+}
+
+function assertArtifactRuntimeAdapter(adapter, methods) {
+ assert.ok(
+ ["extension", "userscript"].includes(adapter?.runtime),
+ "A supported artifact runtime adapter is required.",
+ );
+ for (const method of methods) {
+ assert.equal(
+ typeof adapter[method],
+ "function",
+ `The ${adapter.runtime} artifact adapter must implement ${method}().`,
+ );
+ }
+}
+
+function createArtifactSpaScenarioConfiguration({
+ fromVideoId = VIDEO_A,
+ intervalMs = 50,
+ maxFirstValidMs = 1_000,
+ stableForMs = 300,
+ stabilityDurationMs = 1_000,
+ timeoutMs = 15_000,
+ toVideoId = VIDEO_B,
+} = {}) {
+ const fromCounts = SPA_COUNTS[fromVideoId];
+ const toCounts = SPA_COUNTS[toVideoId];
+ if (!fromCounts || !toCounts) {
+ throw new TypeError("The SPA scenario requires configured non-proportional video counts.");
+ }
+ if (!Number.isFinite(maxFirstValidMs) || maxFirstValidMs < 0) {
+ throw new TypeError("The SPA first-valid latency budget must be a non-negative finite number.");
+ }
+ const fromRatio = fromCounts.likes / (fromCounts.likes + fromCounts.dislikes);
+ const toRatio = toCounts.likes / (toCounts.likes + toCounts.dislikes);
+ assert.notEqual(fromRatio, toRatio, "The A/B fixture ratios must not be proportional.");
+ return {
+ fromCounts,
+ fromRatio,
+ fromVideoId,
+ intervalMs,
+ maxFirstValidMs,
+ stabilityDurationMs,
+ stableForMs,
+ timeoutMs,
+ toCounts,
+ toRatio,
+ toVideoId,
+ validityOptions: {
+ expectedCount: toCounts.dislikes,
+ expectedRatio: toRatio,
+ fromVideoId,
+ toVideoId,
+ },
+ };
+}
+
+async function runArtifactWatchSpaSetup(adapter, configuration) {
+ const {
+ fromCounts,
+ fromRatio,
+ fromVideoId,
+ intervalMs,
+ maxFirstValidMs,
+ stabilityDurationMs,
+ stableForMs,
+ timeoutMs,
+ toVideoId,
+ validityOptions,
+ } = configuration;
+
+ await adapter.openSpaWatch(fromVideoId);
+ const initial = await adapter.waitForWatchResult(fromVideoId);
+ assert.equal(initial.videoId, fromVideoId, "The outgoing watch fixture reported the wrong video ID.");
+ assert.equal(initial.count, String(fromCounts.dislikes), "The outgoing watch rendered the wrong dislike count.");
+ assert.ok(
+ Number.isFinite(initial.fillRatio) && Math.abs(initial.fillRatio - fromRatio) <= 0.02,
+ `The outgoing watch rendered ratio ${initial.fillRatio}; expected ${fromRatio}.`,
+ );
+
+ const mutation = await adapter.navigateSpaWatch(fromVideoId, toVideoId);
+ assert.equal(mutation.outgoing.beforeBarCount, 1, "The retained outgoing fixture had no initialized A bar.");
+ assert.equal(mutation.destination.destinationReplaced, true, "The destination actions were not replaced.");
+
+ let readiness;
+ try {
+ readiness = await waitForStableInvariant({
+ intervalMs,
+ isValid: (snapshot) => isSpaDestinationValid(snapshot, validityOptions),
+ label: `${adapter.runtime} destination watch ownership`,
+ read: () => adapter.readSpaWatchSnapshot(fromVideoId, toVideoId),
+ stableForMs,
+ timeoutMs,
+ });
+ } catch (error) {
+ if (typeof adapter.readSpaTraffic === "function") {
+ error.message += ` Traffic: ${JSON.stringify(await adapter.readSpaTraffic())}`;
+ }
+ throw error;
+ }
+ assert.ok(
+ readiness.firstValidMs <= maxFirstValidMs,
+ `${adapter.runtime} destination watch first became valid after ${readiness.firstValidMs}ms; ` +
+ `the budget is ${maxFirstValidMs}ms.`,
+ );
+ const stability = await assertInvariantContinuously({
+ durationMs: stabilityDurationMs,
+ intervalMs,
+ isValid: (snapshot) => isSpaDestinationValid(snapshot, validityOptions),
+ label: `${adapter.runtime} settled watch SPA UI`,
+ read: () => adapter.readSpaWatchSnapshot(fromVideoId, toVideoId),
+ });
+ const destination = readiness.value;
+ return {
+ destination: {
+ count: destination.count,
+ fillRatio: destination.fillRatio,
+ globalBarCount: destination.globalBarCount,
+ retainedOutgoingBars: destination.retainedBefore.barCount + destination.insideOutgoing.barCount,
+ tooltipText: destination.tooltipText,
+ videoId: destination.currentVideoId,
+ },
+ initial: { count: initial.count, fillRatio: initial.fillRatio, videoId: initial.videoId },
+ readiness: {
+ firstValidMs: readiness.firstValidMs,
+ invalidSamples: readiness.invalidSamples,
+ maxFirstValidMs,
+ sampleCount: readiness.sampleCount,
+ stableForMs: readiness.stableForMs,
+ },
+ stability: { elapsedMs: stability.elapsedMs, sampleCount: stability.sampleCount },
+ };
+}
+
+async function runArtifactWatchSpaScenario(adapter, options = {}) {
+ assert.ok(
+ SHARED_ARTIFACT_SCENARIO_IDS.includes(ARTIFACT_WATCH_SPA_SCENARIO_ID),
+ `${ARTIFACT_WATCH_SPA_SCENARIO_ID} must remain in the shared artifact scenario catalog.`,
+ );
+ assertArtifactRuntimeAdapter(adapter, [
+ "assertSpaNetwork",
+ "assertNoPageSignals",
+ "close",
+ "navigateSpaWatch",
+ "openSpaWatch",
+ "readSpaWatchSnapshot",
+ "start",
+ "waitForWatchResult",
+ ]);
+ const configuration = createArtifactSpaScenarioConfiguration(options);
+
+ try {
+ await adapter.start();
+ const result = await runArtifactWatchSpaSetup(adapter, configuration);
+ const traffic = await adapter.assertSpaNetwork(configuration.fromVideoId, configuration.toVideoId);
+ await adapter.assertNoPageSignals(ARTIFACT_WATCH_SPA_SCENARIO_ID);
+ return {
+ ...result,
+ runtime: adapter.runtime,
+ scenarioId: ARTIFACT_WATCH_SPA_SCENARIO_ID,
+ traffic,
+ };
+ } finally {
+ await adapter.close();
+ }
+}
+
+async function runArtifactWatchSpaVoteScenario(
+ adapter,
+ { handshakeStableForMs = 1_000, handshakeTimeoutMs = 10_000, voteValue = -1, ...spaOptions } = {},
+) {
+ assert.ok(
+ SHARED_ARTIFACT_SCENARIO_IDS.includes(ARTIFACT_WATCH_SPA_VOTE_SCENARIO_ID),
+ `${ARTIFACT_WATCH_SPA_VOTE_SCENARIO_ID} must remain in the shared artifact scenario catalog.`,
+ );
+ assertArtifactRuntimeAdapter(adapter, [
+ "activateSpaDislike",
+ "assertNoPageSignals",
+ "assertSpaVoteNetwork",
+ "close",
+ "navigateSpaWatch",
+ "openSpaWatch",
+ "readSpaVoteHandshake",
+ "readSpaWatchSnapshot",
+ "start",
+ "waitForWatchResult",
+ ]);
+ assert.equal(voteValue, -1, "The shared post-SPA activation scenario must submit a dislike value of -1.");
+ const configuration = createArtifactSpaScenarioConfiguration(spaOptions);
+
+ try {
+ await adapter.start();
+ const result = await runArtifactWatchSpaSetup(adapter, configuration);
+ const activation = await adapter.activateSpaDislike(configuration.toVideoId);
+ assert.ok(
+ Number.isInteger(activation.interactionStartIndex) && activation.interactionStartIndex >= 0,
+ "The adapter did not return a valid interaction record boundary.",
+ );
+ assert.equal(activation.videoId, configuration.toVideoId, "The adapter activated the wrong destination video.");
+
+ let handshake;
+ try {
+ handshake = await waitForStableInvariant({
+ intervalMs: configuration.intervalMs,
+ isValid: isArtifactVoteHandshakeValid,
+ label: `${adapter.runtime} post-SPA dislike handshake`,
+ read: () => adapter.readSpaVoteHandshake(activation.interactionStartIndex, configuration.toVideoId, voteValue),
+ stableForMs: handshakeStableForMs,
+ timeoutMs: handshakeTimeoutMs,
+ });
+ } catch (error) {
+ if (typeof adapter.readSpaTraffic === "function") {
+ error.message += ` Traffic: ${JSON.stringify(await adapter.readSpaTraffic())}`;
+ }
+ throw error;
+ }
+ const handshakeSnapshot = handshake.value;
+ const network = await adapter.assertSpaVoteNetwork(
+ configuration.fromVideoId,
+ configuration.toVideoId,
+ activation.interactionStartIndex,
+ );
+ await adapter.assertNoPageSignals(ARTIFACT_WATCH_SPA_VOTE_SCENARIO_ID);
+
+ return {
+ ...result,
+ activation: { ariaPressedBefore: activation.ariaPressedBefore, videoId: activation.videoId },
+ handshake: {
+ confirmationRequests: handshakeSnapshot.confirmationCount,
+ confirmationStatus: handshakeSnapshot.confirmation.responseStatus,
+ confirmed: handshakeSnapshot.confirmation.responseBody === true,
+ firstValidMs: handshake.firstValidMs,
+ interactionRequests: handshakeSnapshot.interactionCount,
+ sampleCount: handshake.sampleCount,
+ stableForMs: handshake.stableForMs,
+ userId: handshakeSnapshot.sharedUserId,
+ value: voteValue,
+ videoId: configuration.toVideoId,
+ voteRequests: handshakeSnapshot.voteCount,
+ },
+ runtime: adapter.runtime,
+ scenarioId: ARTIFACT_WATCH_SPA_VOTE_SCENARIO_ID,
+ traffic: {
+ ...network,
+ confirmationRequests: handshakeSnapshot.confirmationCount,
+ interactionRequests: handshakeSnapshot.interactionCount,
+ voteRequests: handshakeSnapshot.voteCount,
+ },
+ };
+ } finally {
+ await adapter.close();
+ }
+}
+
+async function runExtensionDelayedOutgoingFailureScenario(
+ adapter,
+ { fromVideoId = VIDEO_A, maxDestinationRequestDelayMs = 250, requestTimeoutMs = 5_000, toVideoId = VIDEO_B } = {},
+) {
+ assert.equal(adapter?.runtime, "extension", "The delayed outgoing failure scenario requires the extension runtime.");
+ assertArtifactRuntimeAdapter(adapter, [
+ "assertNoPageSignals",
+ "assertSpaNetwork",
+ "close",
+ "deferNextStatsRequest",
+ "navigateSpaWatchWhilePending",
+ "openSpaWatch",
+ "readDestinationDislikeTextHistory",
+ "readSpaWatchSnapshot",
+ "readStatsRequestTimings",
+ "start",
+ "waitForWatchResult",
+ ]);
+ const configuration = createArtifactSpaScenarioConfiguration({ fromVideoId, toVideoId });
+
+ try {
+ await adapter.start();
+ const outgoingRequest = adapter.deferNextStatsRequest();
+ await adapter.openSpaWatch(fromVideoId);
+ let requestTimeout;
+ try {
+ await Promise.race([
+ outgoingRequest.seen,
+ new Promise((resolve, reject) => {
+ requestTimeout = setTimeout(
+ () => reject(new Error(`The outgoing ${fromVideoId} stats request was not observed.`)),
+ requestTimeoutMs,
+ );
+ }),
+ ]);
+ } finally {
+ clearTimeout(requestTimeout);
+ }
+
+ const mutation = await adapter.navigateSpaWatchWhilePending(fromVideoId, toVideoId);
+ assert.equal(mutation.outgoing.beforeBarCount, 0, "The intentionally pending outgoing request rendered a bar.");
+ assert.equal(mutation.destination.destinationReplaced, true, "The destination actions were not replaced.");
+
+ const releasedAt = Date.now();
+ outgoingRequest.release({ body: "{", status: 200 });
+ const readiness = await waitForStableInvariant({
+ intervalMs: configuration.intervalMs,
+ isValid: (snapshot) => isSpaDestinationValid(snapshot, configuration.validityOptions),
+ label: "extension destination after delayed outgoing failure",
+ read: () => adapter.readSpaWatchSnapshot(fromVideoId, toVideoId),
+ stableForMs: configuration.stableForMs,
+ timeoutMs: configuration.timeoutMs,
+ });
+ const destination = readiness.value;
+
+ const textHistory = await adapter.readDestinationDislikeTextHistory();
+ assert.ok(textHistory.length >= 1, "The destination dislike text history was not recorded.");
+ assert.ok(
+ textHistory.every((text) => text === "" || text === String(configuration.toCounts.dislikes)),
+ `The outgoing failure wrote into the destination dislike UI: ${JSON.stringify(textHistory)}`,
+ );
+
+ const timings = adapter.readStatsRequestTimings();
+ const destinationRequests = timings.filter((record) => record.query.videoId === toVideoId);
+ assert.equal(destinationRequests.length, 1, `Expected one destination stats request for ${toVideoId}.`);
+ const destinationRequestDelayMs = destinationRequests[0].at - releasedAt;
+ assert.ok(
+ destinationRequestDelayMs <= maxDestinationRequestDelayMs,
+ `The queued destination initialization waited ${destinationRequestDelayMs}ms after A settled; ` +
+ `the budget is ${maxDestinationRequestDelayMs}ms.`,
+ );
+ const traffic = await adapter.assertSpaNetwork(fromVideoId, toVideoId);
+ await adapter.assertNoPageSignals(ARTIFACT_EXTENSION_DELAYED_FAILURE_SCENARIO_ID);
+
+ return {
+ destination: {
+ count: destination.count,
+ fillRatio: destination.fillRatio,
+ videoId: destination.currentVideoId,
+ },
+ destinationRequestDelayMs,
+ runtime: adapter.runtime,
+ scenarioId: ARTIFACT_EXTENSION_DELAYED_FAILURE_SCENARIO_ID,
+ textHistory,
+ traffic,
+ };
+ } finally {
+ await adapter.close();
+ }
+}
+
+async function runBothArtifactSmokes() {
+ const apiServer = await startHermeticApiServer();
+ try {
+ const results = [];
+ for (const adapter of [
+ new HermeticUserscriptArtifactAdapter(),
+ new HermeticExtensionArtifactAdapter({ apiServer }),
+ ]) {
+ results.push(await runArtifactWatchRenderScenario(adapter));
+ }
+ const backendOptions = { countsByVideo: SPA_COUNTS };
+ for (const adapter of [
+ new HermeticUserscriptArtifactAdapter({ backendOptions }),
+ new HermeticExtensionArtifactAdapter({ apiServer, backendOptions }),
+ ]) {
+ results.push(await runArtifactWatchSpaScenario(adapter));
+ }
+ for (const adapter of [
+ new HermeticUserscriptArtifactAdapter({ backendOptions }),
+ new HermeticExtensionArtifactAdapter({ apiServer, backendOptions }),
+ ]) {
+ results.push(await runArtifactWatchSpaVoteScenario(adapter));
+ }
+ results.push(
+ await runExtensionDelayedOutgoingFailureScenario(
+ new HermeticExtensionArtifactAdapter({ apiServer, backendOptions }),
+ ),
+ );
+ return results;
+ } finally {
+ await apiServer.close();
+ }
+}
+
+if (require.main === module) {
+ runBothArtifactSmokes()
+ .then((results) => process.stdout.write(`${JSON.stringify(results, null, 2)}\n`))
+ .catch((error) => {
+ process.stderr.write(`${error.stack ?? error.message}\n`);
+ process.exitCode = 1;
+ });
+}
+
+module.exports = {
+ ARTIFACT_EXTENSION_DELAYED_FAILURE_SCENARIO_ID,
+ ARTIFACT_SMOKE_SCENARIO_ID,
+ ARTIFACT_WATCH_SPA_SCENARIO_ID,
+ ARTIFACT_WATCH_SPA_VOTE_SCENARIO_ID,
+ HermeticExtensionArtifactAdapter,
+ HermeticUserscriptArtifactAdapter,
+ SHARED_ARTIFACT_SCENARIO_IDS,
+ SPA_COUNTS,
+ assertLoopbackOrigin,
+ createPageSignalCollector,
+ isArtifactVoteHandshakeValid,
+ isSpaDestinationValid,
+ prepareHermeticExtensionArtifact,
+ readArtifactVoteHandshake,
+ runArtifactWatchRenderScenario,
+ runArtifactWatchSpaScenario,
+ runArtifactWatchSpaVoteScenario,
+ runBothArtifactSmokes,
+ runExtensionDelayedOutgoingFailureScenario,
+ startHermeticApiServer,
+};
diff --git a/Extensions/e2e/hermetic-artifact-smoke.spec.js b/Extensions/e2e/hermetic-artifact-smoke.spec.js
new file mode 100644
index 0000000..9ded76e
--- /dev/null
+++ b/Extensions/e2e/hermetic-artifact-smoke.spec.js
@@ -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"),
+ );
+});
diff --git a/Extensions/e2e/live-runtime-adapter.js b/Extensions/e2e/live-runtime-adapter.js
new file mode 100644
index 0000000..15572da
--- /dev/null
+++ b/Extensions/e2e/live-runtime-adapter.js
@@ -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,
+};
diff --git a/Extensions/e2e/shared-live-scenarios.js b/Extensions/e2e/shared-live-scenarios.js
new file mode 100644
index 0000000..4491913
--- /dev/null
+++ b/Extensions/e2e/shared-live-scenarios.js
@@ -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,
+};
diff --git a/Extensions/e2e/shared-live-scenarios.spec.js b/Extensions/e2e/shared-live-scenarios.spec.js
new file mode 100644
index 0000000..521518b
--- /dev/null
+++ b/Extensions/e2e/shared-live-scenarios.spec.js
@@ -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);
+ });
+});
diff --git a/Extensions/e2e/systematic-coverage.spec.js b/Extensions/e2e/systematic-coverage.spec.js
new file mode 100644
index 0000000..22bc288
--- /dev/null
+++ b/Extensions/e2e/systematic-coverage.spec.js
@@ -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);
+ });
+});
diff --git a/jest.config.js b/jest.config.js
index 8c4841c..506d134 100644
--- a/jest.config.js
+++ b/jest.config.js
@@ -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/", "/Extensions/UserScript/e2e/"],
};
diff --git a/package-lock.json b/package-lock.json
index 557ab8d..ef526d0 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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",
diff --git a/package.json b/package.json
index 2179d79..19d77cc 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/playwright.live-youtube.config.js b/playwright.live-youtube.config.js
new file mode 100644
index 0000000..06b8227
--- /dev/null
+++ b/playwright.live-youtube.config.js
@@ -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",
+});
diff --git a/playwright.userscript.config.js b/playwright.userscript.config.js
new file mode 100644
index 0000000..556f105
--- /dev/null
+++ b/playwright.userscript.config.js
@@ -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",
+});
diff --git a/webpack.config.js b/webpack.config.js
index f0acc9b..d8033e9 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -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,
+ };
+};
diff --git a/webpack.live-build-marker.js b/webpack.live-build-marker.js
new file mode 100644
index 0000000..611cecc
--- /dev/null
+++ b/webpack.live-build-marker.js
@@ -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,
+};
diff --git a/webpack.live-build-marker.spec.js b/webpack.live-build-marker.spec.js
new file mode 100644
index 0000000..9e0e281
--- /dev/null
+++ b/webpack.live-build-marker.spec.js
@@ -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,
+ });
+ });
+});
diff --git a/webpack.userscript.config.js b/webpack.userscript.config.js
new file mode 100644
index 0000000..644d791
--- /dev/null
+++ b/webpack.userscript.config.js
@@ -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,
+ },
+ };
+};