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", - ` -
-
-
-
-
-
- - ${tooltipInnerHTML} - -
-`, - ); - 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 = ` +
+
+ +
1.2K
+
+
+ `; + + 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("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, + }, + }; +};