Prepare 4.0.5 Firefox update and Shorts fixes

Add Firefox data consent, repair contributor sign-in, and introduce optional Hide clutter buttons. Harden video navigation, voting, and action layouts with shared browser regression coverage and reproducible release packaging.
This commit is contained in:
Anarios
2026-09-06 12:51:12 +02:00
parent 3657de9b2c
commit e500088e65
196 changed files with 39706 additions and 2694 deletions
+12 -2
View File
@@ -13,6 +13,8 @@ on:
- ".babelrc" - ".babelrc"
- ".gitattributes" - ".gitattributes"
- ".nvmrc" - ".nvmrc"
- "extension-build-receipt*.js"
- "userscript-build-receipt*.js"
- "jest.config.js" - "jest.config.js"
- "package.json" - "package.json"
- "package-lock.json" - "package-lock.json"
@@ -30,6 +32,8 @@ on:
- ".babelrc" - ".babelrc"
- ".gitattributes" - ".gitattributes"
- ".nvmrc" - ".nvmrc"
- "extension-build-receipt*.js"
- "userscript-build-receipt*.js"
- "jest.config.js" - "jest.config.js"
- "package.json" - "package.json"
- "package-lock.json" - "package-lock.json"
@@ -59,6 +63,9 @@ jobs:
- name: Build extension - name: Build extension
run: npm run build run: npm run build
- name: Check generated extension artifact
run: npm run check:extension-artifact
- name: Build userscript - name: Build userscript
run: npm run build:userscript run: npm run build:userscript
@@ -71,8 +78,11 @@ jobs:
- name: Run generated-artifact browser tests - name: Run generated-artifact browser tests
run: npm run test:e2e:artifacts run: npm run test:e2e:artifacts
- name: Run extension browser tests
run: npm run test:e2e:extension:run
- name: Run userscript browser tests - name: Run userscript browser tests
run: npx playwright test --config playwright.userscript.config.js run: npm run test:e2e:userscript:run
- name: Upload Chrome artifact - name: Upload Chrome artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
@@ -96,7 +106,7 @@ jobs:
if: failure() if: failure()
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: userscript-playwright-failure name: playwright-browser-failures
path: | path: |
./test-results ./test-results
./playwright-report ./playwright-report
+105
View File
@@ -0,0 +1,105 @@
# AMO 4.0.5 Submission Notes
## Files to upload
- Add-on package: `return-youtube-dislike-firefox-4.0.5.zip`
- Source package: `return-youtube-dislike-source-4.0.5.zip`
- Privacy policy: copy `Docs/Privacy Policy` into the AMO listing privacy-policy field.
## Backend deployment prerequisite
Deploy the matching backend entitlement change **before releasing extension 4.0.5** or publishing its privacy policy.
The account flow must request only currently entitled Patreon tier IDs for eligibility, with no membership amount,
charge-date/status, lifetime-payment, or patron-status attributes. Verify that behavior and a fresh reviewer-account
login against the deployed backend. The source changes and these notes do not establish that the live backend has
already been updated.
## Notes to reviewer
Version 4.0.5 addresses rejection reference `a424a6ef-1172-4c6c-8028-22187c194255` and the earlier source-code
review concerns.
The Firefox manifest now uses Firefox's built-in data-collection consent system and requires Firefox 140 or later. It
retains the published add-on ID `{762f9885-5a13-4abd-9c77-433dcd38b8fd}` and declares these required data categories:
- `personallyIdentifyingInfo`: a persistent random installation `userId` is registered with the Return YouTube Dislike
API and is included with vote submissions. Optional sign-in also processes provider account identifiers, name,
profile image, and email when returned by GitHub under this category. Patreon does not request or return email.
- `browsingActivity`: the current YouTube `videoId` identifies the video page being viewed and is sent for dislike
lookups and related analytics.
- `websiteContent`: the current YouTube `videoId` and, when available, visible `likeCount` are sent to the API to obtain
and improve the dislike estimate.
- `websiteActivity`: a user-initiated Like, Dislike, or vote removal is sent as the vote `value` with the `videoId` and
random `userId`.
The optional Patreon/GitHub account feature declares `authenticationInfo`. The matching backend determines Patreon
access from currently entitled tier IDs, without requesting or using membership amounts, charge dates or status,
lifetime payments, or patron status. Firefox requests authentication permission directly from the corresponding
sign-in click. Denying it produces no OAuth, session-verification, or premium Bearer-token traffic. Removing it clears
the account session, tears down active premium analytics, and blocks
later account requests. Delayed login and verification responses cannot restore a revoked session. Core dislike counts
continue to work. Browser sync may synchronize extension settings, identifiers, and account sessions when enabled.
Treating tier IDs as account entitlements covered by account identity and authentication declarations is our data-category
interpretation; Mozilla has not approved this classification. Paid access remains disclosed in the listing. The
`financialAndPaymentInfo` category is omitted because the matching backend no longer requests or processes the financial
and payment attributes used by the earlier implementation. Removing an obsolete financial-data grant alone does not
sign the user out; revoking authentication consent does.
Firefox private-window access is disabled so private browsing data is not retained. The popup no longer makes an
unrelated remote version check or server-status probe, and packaged extension pages no longer request remote fonts.
The supplied source archive contains the lockfile, exact production inputs, and build instructions. From its root,
install Node.js 22.17.0 and npm 10.8.2, then run:
```sh
node scripts/build-firefox-source.mjs
```
The generated comparison directory is `Extensions/combined/dist/firefox`. The build script checks the version,
manifest consent contract, required `menu-fixer.js`, bundle size, absence of source-map references, and source-input
receipt. Dependencies are installed only through npm from the npm registry.
## Functional review path
1. Install in a fresh Firefox 140-or-newer profile and accept the required data categories.
2. Open a public YouTube video and confirm the dislike estimate appears.
3. Open the extension popup. Decline either Patreon or GitHub sign-in consent and confirm the account remains signed
out while ordinary dislike counts continue working.
4. Grant the optional authentication permission and sign in with the supplied reviewer account. Exercise the premium
analytics panel using that account's active premium access.
5. Revoke authentication data under `about:addons` > Return YouTube Dislike > Permissions and data. Confirm
the account session ends and no premium panel appears on subsequent navigation or after restarting Firefox.
## Listing and reviewer access
Add this disclosure to the listing description:
> Return YouTube Dislike sends the current YouTube video ID and visible like count to its API to display dislike
> estimates. Optional vote submissions include a persistent random installation ID and your selected vote. Optional
> Patreon/GitHub sign-in processes account and membership information. Premium analytics has eligibility requirements,
> including paid Patreon membership tiers. Core dislike counts are free. Firefox asks for required data consent during installation or update and
> requests optional account-data consent when you sign in.
Before submission, deploy the backend entitlement change and complimentary-access grant, then verify a fresh login with the dedicated
Patreon test account. Supply that account's login details in AMO's **Notes for Reviewers**, as described in the
[submission guide](https://extensionworkshop.com/documentation/publish/submitting-an-add-on/). Mozilla requires
reviewer access to account-only functionality. Do not place credentials in this file or in the source archive.
Confirm the listing discloses paid functionality and includes the updated privacy policy.
Include these instructions with the login details:
> This is a dedicated Patreon test account with complimentary premium access granted by our backend. No paid
> subscription is needed. In the extension popup, choose Log in with Patreon, accept the optional account-data
> permission prompt, and complete Patreon authorization using the supplied test account. Open a YouTube video and
> its premium analytics. Complimentary access is available through October 5, 2026 (UTC); contact us if the review
> requires an extension.
## Policy references
- [Built-in data consent and data categories](https://extensionworkshop.com/documentation/develop/firefox-builtin-data-consent/)
- [Submission and data-transmission requirements](https://extensionworkshop.com/documentation/publish/add-on-policies/)
- [Source-code submission](https://extensionworkshop.com/documentation/publish/source-code-submission/)
Temporary developer installs silently grant required permissions and do not prove the installation/update consent
prompt. Test that prompt using a packaged install in a disposable Firefox Developer Edition or Nightly profile.
+61 -4
View File
@@ -1,7 +1,64 @@
The only data collected from users is their likes and dislikes made while the extension is installed. # Return YouTube Dislike Privacy Policy
No personal info, account name or watch history is collected or saved. Last updated: September 5, 2026
Users are identified by a random user ID, which is not directly linked to any of their accounts, the only purpose of this user ID is to make voting process possible. This policy describes version 4.0.5 and its matching backend release.
None of the saved data is shared with any third parties. Return YouTube Dislike transmits only the data needed to provide its dislike-count, voting, abuse-prevention, and
optional premium features. It does not sell personal data or use it for advertising.
## Data required for the extension to work
- On registration, the extension generates a persistent random installation identifier and sends it to the Return
YouTube Dislike API. The identifier is not derived from or linked to a YouTube or Google account, but it is a
persistent identifier and is treated as personal data.
- When a YouTube video is opened, the extension sends that video's ID and, when available, its visible like count to
retrieve and improve the dislike estimate.
- When the user submits a Like, Dislike, or vote removal and vote submission is enabled, the extension sends the random
installation identifier, video ID, and selected action.
- API requests necessarily expose the network IP address and request time. The service uses this information for
security, abuse prevention, aggregate analytics, and operation of the API.
The extension does not send a YouTube account name, comments, private YouTube data, or the contents of unrelated web
pages. It does not maintain a separate local list of videos watched.
## Optional account and premium data
Patreon and GitHub sign-in are optional. The account flow starts only after the user selects a sign-in button and, in
Firefox, grants the separate authentication-data permission. The selected provider and the Return YouTube Dislike API
process the OAuth authorization response. The API returns the provider account ID, name, profile image,
account-access status, access tier, and a Return YouTube Dislike session token as applicable to that provider. GitHub
sign-in may also return an email address. Patreon sign-in does not request or return email, account-status fields,
or connected social accounts; the name and profile image identify the signed-in account in the popup. To
determine Patreon access, the matching backend uses currently entitled tier IDs. It does not request or use membership
amounts, charge dates or status, lifetime payments, or patron status. The extension does not receive payment-card or
bank-account details.
Those account details and the session token are stored in browser extension storage and used to authenticate premium
analytics requests. The session token expires after 30 days. Signing out deletes the locally stored account details and
session token. Revoking Firefox's authentication-data permission clears that local account session and prevents further
account and premium requests. Pending responses cannot restore the session; the user must sign in again to resume it.
Patreon and GitHub process sign-in information under their own privacy policies. The extension never receives the
user's Patreon or GitHub password.
## Storage, retention, and sharing
The identifier, settings, and optional account session use browser extension sync storage, which may be synchronized
through the browser's sync service when the user enables it. The random installation identifier remains in extension storage until the extension data is cleared, registration is
reset, or the extension is removed. Confirmed vote records contain the identifier, video ID, vote, timestamp, and the
network address used for abuse controls. Registration and vote records are retained by the service while they are
needed to calculate results, maintain vote integrity, and prevent abuse; no shorter automatic deletion period is
promised.
Data is not sold or shared for advertising. It is processed by the Return YouTube Dislike service and its hosting and
network infrastructure providers, and by Patreon or GitHub only when the user chooses the corresponding sign-in flow.
## User controls
Firefox displays its built-in consent prompt before the required data is transmitted. Refusing the required consent
prevents installation or acceptance of the update. Vote submission can be disabled in the extension settings. Optional account access can be
declined or revoked without disabling ordinary dislike counts. Firefox private-window access is disabled.
Users can clear extension storage or remove the extension to delete locally stored data. Requests concerning service
data can be made through the project's support channels at https://returnyoutubedislike.com/help.
+23 -5
View File
@@ -4,23 +4,41 @@ Read this in other languages: [русский](SECURITY-FAQru.md), [Nederlands](
### Are you tracking my viewing history? ### Are you tracking my viewing history?
No. The extension's code is public and you can see it for yourself. The only information being sent is the video ID, which is required to fetch the dislike count for the videos. There are no additional headers being sent. Over the communication layer, your public IP will be exposed to the server, as well as the time when the request was made. However, none of these are uniquely identifying you in any way. Assuming a zero-trust environment, the best we could get is a dynamic IP. Which, today is yours, tomorrow is your neighbor's. If you're really worried about your IP being traced, you probably already use a VPN. The extension sends the current video ID and, when available, its visible like count to the Return YouTube Dislike API
because the server needs them to return and improve the dislike estimate. The request also exposes standard network
metadata such as the public IP address and request time. The extension does not send your YouTube account name,
comments, private YouTube data, or the contents of unrelated pages, and it does not keep a separate local watch-history
list.
### Can you uniquely identify me if I dislike? ### Can you uniquely identify me if I dislike?
Yes. When you dislike a video, we create a randomly generated unique ID for you that is not tied to your Google account. This is done to prevent botting. But there is no way to tie this random Id to you or your personal YouTube account. The extension creates a persistent random ID for abuse-resistant vote submission. It is not derived from or linked to
your Google or YouTube account, but Mozilla classifies persistent identifiers as personal data. The ID lets the service
associate submitted votes with the same extension installation.
### What information do you have, exactly? ### What information do you have, exactly?
Just the video ID. Not your comments, not your username, not who you've shared the video with, not any additional metadata. Nothing. Just the video ID. For dislike-count requests: the current video ID and, when available, the visible like count. For registration and vote
submission: the random extension user ID, video ID, selected vote, timestamp, and network address used for abuse
controls. Optional premium features use the account ID, name, email address, profile image, membership information, and
service session token returned by a Patreon or GitHub sign-in initiated by the user. For version 4.0.5 and its matching
backend release, Patreon access is determined from currently entitled tier IDs rather than financial information.
### How is my IP stored? ### How is my IP stored?
The backend keeps unhashed IP addresses in volatile memory (RAM) only. These addresses aren't stored on a hard drive, and therefore aren't logged. We hash the IP addresses, and that's stored instead. This is done to prevent database vandalism. The API receives your network address with each request. Confirmed vote records retain the network address used for
abuse prevention and aggregate analytics. It is not sold or used for advertising.
### I heard some discussion over OAuth, and access to my YouTube account! ### I heard some discussion over OAuth, and access to my YouTube account!
This feature will be optional, and very much opt-in. If you are a YouTube creator, and would like to share your dislike stats with us, you can. The way [OAuth](https://en.wikipedia.org/wiki/OAuth#:~:text=but%20without%20giving%20them%20the%20passwords.) was structured, it's actually very secure. You can revoke access to your account at any time, and can give very specific permissions to us. We will not ask for any permissions that aren't required. We'll only ask for permissions to view your video stats. Premium analytics sign-in is optional and starts only after the user chooses Patreon or GitHub login in the extension.
The OAuth flow returns account details and a Return YouTube Dislike session token used to authenticate premium API
requests. In version 4.0.5 and its matching backend release, Patreon eligibility uses currently entitled tier IDs;
the service does not request or use membership amounts, charge dates or status, lifetime payments, or patron status.
Firefox asks for separate authentication-data consent before account traffic begins. Signing out removes
the stored account details and session from the extension. Revoking Firefox's authentication-data permission also clears the
account session and blocks further account traffic until you grant consent and sign in again. Browser sync may
synchronize the installation identifier, settings, and account session when enabled.
### How can I trust this dislike count? ### How can I trust this dislike count?
@@ -30,8 +30,103 @@
// This file is generated by `npm run build:userscript`. // This file is generated by `npm run build:userscript`.
// Edit Extensions/UserScript/src instead of the generated file. // Edit Extensions/UserScript/src instead of the generated file.
/******/ (() => { // webpackBootstrap /******/ (() => { // webpackBootstrap
/******/ "use strict"; /******/ var __webpack_modules__ = ({
/***/ 26:
/***/ ((module) => {
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;
}
module.exports = {
DISLIKED_STATE,
DISLIKE_ACTION,
LIKED_STATE,
LIKE_ACTION,
NEUTRAL_STATE,
applyVoteTransitionCounts,
resolveVoteTransition,
shouldSubmitVote,
};
/***/ })
/******/ });
/************************************************************************/
/******/ // The module cache
/******/ var __webpack_module_cache__ = {};
/******/
/******/ // The require function
/******/ function __webpack_require__(moduleId) {
/******/ // Check if module is in cache
/******/ var cachedModule = __webpack_module_cache__[moduleId];
/******/ if (cachedModule !== undefined) {
/******/ return cachedModule.exports;
/******/ }
/******/ // Create a new module (and put it into the cache)
/******/ var module = __webpack_module_cache__[moduleId] = {
/******/ // no module.id needed
/******/ // no module.loaded needed
/******/ exports: {}
/******/ };
/******/
/******/ // Execute the module function
/******/ __webpack_modules__[moduleId](module, module.exports, __webpack_require__);
/******/
/******/ // Return the exports of the module
/******/ return module.exports;
/******/ }
/******/
/************************************************************************/
var __webpack_exports__ = {}; var __webpack_exports__ = {};
// This entry need to be wrapped in an IIFE because it need to be in strict mode.
(() => {
"use strict";
;// CONCATENATED MODULE: ./Extensions/common/vote-client.js ;// CONCATENATED MODULE: ./Extensions/common/vote-client.js
const DEFAULT_API_BASE_URL = "https://returnyoutubedislikeapi.com"; const DEFAULT_API_BASE_URL = "https://returnyoutubedislikeapi.com";
@@ -352,54 +447,248 @@ function createVoteClient({
;// CONCATENATED MODULE: ./Extensions/common/vote-transition.js ;// CONCATENATED MODULE: ./Extensions/common/delegated-vote-activation.js
const LIKED_STATE = "LIKED_STATE"; function defaultGetActivationTarget(control) {
const DISLIKED_STATE = "DISLIKED_STATE"; if (control?.matches?.("button, tp-yt-paper-button#button")) {
const NEUTRAL_STATE = "NEUTRAL_STATE"; return control;
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 }; return control?.querySelector?.("button, tp-yt-paper-button#button") ?? control ?? null;
} }
function applyVoteTransitionCounts(likes, dislikes, transition) { function isHiddenWithinButtons(activationTarget, buttons) {
if (!transition || !Number.isFinite(transition.likesDelta) || !Number.isFinite(transition.dislikesDelta)) { let current = activationTarget;
throw new TypeError("A valid vote transition is required"); while (current) {
if (
current.hidden === true ||
current.getAttribute?.("aria-hidden") === "true" ||
current.hasAttribute?.("inert") ||
current.style?.display === "none" ||
current.style?.visibility === "hidden"
) {
return true;
}
if (current === buttons) {
return false;
}
current = current.parentElement;
}
return true;
}
function eventActivatesTarget(event, activationTarget) {
const eventPath = typeof event?.composedPath === "function" ? event.composedPath() : [];
if (eventPath.includes(activationTarget)) {
return true;
}
const eventTarget = event?.target;
return Boolean(eventTarget && activationTarget?.contains?.(eventTarget));
}
function resolveDelegatedVoteActivation({
buttons,
dislikeButton,
event,
getActivationTarget = defaultGetActivationTarget,
likeButton,
}) {
if (!event || !buttons?.isConnected) {
return null;
} }
const normalizedLikes = Number.isFinite(likes) ? likes : 0; for (const [action, control] of [
const normalizedDislikes = Number.isFinite(dislikes) ? dislikes : 0; ["dislike", dislikeButton],
["like", likeButton],
]) {
if (!control?.isConnected || !buttons.contains(control)) {
continue;
}
const activationTarget = getActivationTarget(control);
if (
!activationTarget?.isConnected ||
!control.contains(activationTarget) ||
isHiddenWithinButtons(activationTarget, buttons) ||
activationTarget.disabled === true ||
activationTarget.getAttribute?.("aria-disabled") === "true"
) {
continue;
}
if (eventActivatesTarget(event, activationTarget)) {
return { action, activationTarget };
}
}
return null;
}
;// CONCATENATED MODULE: ./Extensions/common/shorts-control-readiness.js
const SHORTS_HYDRATED_SURFACE_STABILITY_MS = 250;
const SHORTS_UNHYDRATED_SURFACE_STABILITY_MS = 1500;
const SHORTS_LINK_SELECTOR = "a[href*='/shorts/']";
const SHORTS_CANONICAL_PLAYER_LINK_SELECTOR = "a.ytp-title-link[href*='/shorts/']";
const SHORTS_ICON_GRAPHIC_SELECTOR = "path, circle, ellipse, line, polygon, polyline, rect";
function getShortsIdentityLinkVideoIds(root, baseUrl = "https://www.youtube.com/") {
const identities = new Set();
if (!root?.querySelectorAll) {
return identities;
}
const links = Array.from(root.querySelectorAll(SHORTS_LINK_SELECTOR));
const canonicalPlayerLinks = links.filter((link) => link.matches(SHORTS_CANONICAL_PLAYER_LINK_SELECTOR));
// The player title link identifies the reel. Other /shorts/ links can be
// unrelated calls to action in the current Short's description/metapanel.
// Choose the canonical subset before parsing so malformed player metadata
// cannot make an unrelated description link look authoritative.
const identityLinks = canonicalPlayerLinks.length > 0 ? canonicalPlayerLinks : links;
for (const link of identityLinks) {
try {
const href = link.getAttribute("href");
if (!href) continue;
const videoId = new URL(href, baseUrl).pathname.match(/^\/shorts\/([^/]+)\/?$/)?.[1];
if (videoId) identities.add(videoId);
} catch {
// Ignore malformed or incomplete links while YouTube hydrates the reel.
}
}
return identities;
}
function getShortsControlSurfaceStabilityMs(isHydrated) {
return isHydrated ? SHORTS_HYDRATED_SURFACE_STABILITY_MS : SHORTS_UNHYDRATED_SURFACE_STABILITY_MS;
}
function isShortsControlSurfaceReadyForMutation({
allowUnhydratedFallback = false,
candidateVideoIds = [],
currentVideoId,
isConnected,
isHydrated,
isRendered,
isStable,
isViewportIntersecting,
visibleCandidateCount,
}) {
const uniqueCandidateVideoIds = new Set(Array.from(candidateVideoIds).filter(Boolean));
const hasExactIdentity = uniqueCandidateVideoIds.size === 1 && uniqueCandidateVideoIds.has(currentVideoId);
const isUnambiguousIdentitylessSurface = uniqueCandidateVideoIds.size === 0 && visibleCandidateCount === 1;
return Boolean(
currentVideoId &&
isConnected &&
(isHydrated || allowUnhydratedFallback) &&
isRendered &&
isStable &&
isViewportIntersecting &&
(hasExactIdentity || isUnambiguousIdentitylessSurface),
);
}
function actionBarHasHydratedData(actionBar) {
return Boolean(actionBar) && (!("data" in actionBar) || Boolean(actionBar.data));
}
function capturePaintedShortsIcon(button) {
const svg = button?.querySelector("svg") ?? null;
if (!svg?.isConnected) return null;
const graphic = Array.from(svg.querySelectorAll(SHORTS_ICON_GRAPHIC_SELECTOR)).find(
(candidate) => candidate.tagName.toLowerCase() !== "path" || (candidate.getAttribute("d") ?? "").trim().length > 0,
);
if (!graphic?.isConnected) return null;
const rect = svg.getBoundingClientRect();
if (!(rect.width > 0 && rect.height > 0)) return null;
return { graphic, svg };
}
function captureShortsNativeControlInventory(
actionBar,
{ syntheticSelector = "[data-ryd-synthetic-shorts-dislike]" } = {},
) {
const nativeChildren = Array.from(actionBar?.children ?? []).filter((child) => !child.matches(syntheticSelector));
const likeButton = actionBar?.querySelector("like-button-view-model") ?? null;
const nativeLikeButton = likeButton?.querySelector("button") ?? null;
const nativeLikeIcon = capturePaintedShortsIcon(nativeLikeButton);
const activationTargets = nativeChildren.map((child) =>
child.querySelector("button, a[href], a[role='button'][tabindex='0'], tp-yt-paper-button#button"),
);
if (
!likeButton?.isConnected ||
!nativeLikeButton?.isConnected ||
!nativeLikeIcon ||
nativeChildren.length < 4 ||
!nativeChildren.includes(likeButton) ||
activationTargets.some((target) => !target?.isConnected)
) {
return null;
}
return { return {
likes: Math.max(0, normalizedLikes + transition.likesDelta), activationTargets,
dislikes: Math.max(0, normalizedDislikes + transition.dislikesDelta), likeButton,
nativeChildren,
nativeLikeButton,
nativeLikeGraphic: nativeLikeIcon.graphic,
nativeLikeSvg: nativeLikeIcon.svg,
}; };
} }
function shouldSubmitVote({ disableVoteSubmission = false, signedOut = false } = {}) { function shortsNativeControlInventoryMatches(left, right) {
return disableVoteSubmission !== true && signedOut !== true; return Boolean(
left &&
right &&
left.likeButton === right.likeButton &&
left.nativeLikeButton === right.nativeLikeButton &&
left.nativeLikeGraphic === right.nativeLikeGraphic &&
left.nativeLikeSvg === right.nativeLikeSvg &&
left.nativeChildren.length === right.nativeChildren.length &&
left.nativeChildren.every((child, index) => child === right.nativeChildren[index]) &&
left.activationTargets.every((target, index) => target === right.activationTargets[index]),
);
} }
function shortsNativeControlInventoryIsReadyForFallback(
inventory,
{ getTopmostElement, isMeaningfullyInViewport, isRendered },
) {
if (
!inventory?.activationTargets?.length ||
typeof getTopmostElement !== "function" ||
typeof isMeaningfullyInViewport !== "function" ||
typeof isRendered !== "function"
) {
return false;
}
return inventory.activationTargets.every((target) => {
if (!target?.isConnected || !isRendered(target) || !isMeaningfullyInViewport(target)) {
return false;
}
const rect = target.getBoundingClientRect();
const topmostElement = getTopmostElement(rect.left + rect.width / 2, rect.top + rect.height / 2);
return Boolean(topmostElement && (topmostElement === target || target.contains(topmostElement)));
});
}
;// CONCATENATED MODULE: ./Extensions/common/vote-transition.js
const {
DISLIKED_STATE,
DISLIKE_ACTION,
LIKED_STATE,
LIKE_ACTION,
NEUTRAL_STATE,
applyVoteTransitionCounts,
resolveVoteTransition,
shouldSubmitVote,
} = __webpack_require__(26);
;// CONCATENATED MODULE: ./Extensions/UserScript/src/gm-credential-store.js ;// CONCATENATED MODULE: ./Extensions/UserScript/src/gm-credential-store.js
@@ -573,6 +862,8 @@ const userscript_version_namespaceObject = "3.2.0";
if (false) {} if (false) {}
const API_BASE_URL = "https://returnyoutubedislikeapi.com"; const API_BASE_URL = "https://returnyoutubedislikeapi.com";
@@ -615,7 +906,6 @@ function getShortVideoIdFromPathname(pathname) {
let isShorts = () => getShortVideoIdFromPathname(location.pathname) !== null; let isShorts = () => getShortVideoIdFromPathname(location.pathname) !== null;
let mobileDislikes = 0; let mobileDislikes = 0;
let suppressNextLikeActivation = false; let suppressNextLikeActivation = false;
const boundLikeButtons = new WeakSet();
const boundDislikeButtons = new WeakSet(); const boundDislikeButtons = new WeakSet();
const boundActivationVideoIds = new WeakMap(); const boundActivationVideoIds = new WeakMap();
const suppressedStaleRefreshTargets = new WeakSet(); const suppressedStaleRefreshTargets = new WeakSet();
@@ -628,6 +918,7 @@ let shortsLifecycleObserver = null;
let shortsLifecycleObserverTarget = null; let shortsLifecycleObserverTarget = null;
let initializationGeneration = 0; let initializationGeneration = 0;
let initializationTimer = null; let initializationTimer = null;
let pendingShortsMutationSurface = null;
let activeCountRequest = null; let activeCountRequest = null;
let countStateVideoId = null; let countStateVideoId = null;
let countStateLoaded = false; let countStateLoaded = false;
@@ -672,6 +963,16 @@ function intersectsViewport(element) {
); );
} }
function hasMeaningfulViewportPresence(element) {
const rect = element?.getBoundingClientRect?.();
if (!rect || rect.width <= 0 || rect.height <= 0) return false;
const height = innerHeight || document.documentElement.clientHeight;
const width = innerWidth || document.documentElement.clientWidth;
const visibleWidth = Math.max(0, Math.min(rect.right, width) - Math.max(rect.left, 0));
const visibleHeight = Math.max(0, Math.min(rect.bottom, height) - Math.max(rect.top, 0));
return (visibleWidth * visibleHeight) / (rect.width * rect.height) >= 0.5;
}
function hasRenderedBox(element) { function hasRenderedBox(element) {
if (!element?.isConnected || element.closest("[hidden], [aria-hidden='true'], [inert]")) { if (!element?.isConnected || element.closest("[hidden], [aria-hidden='true'], [inert]")) {
return false; return false;
@@ -693,26 +994,22 @@ function hasRenderedBox(element) {
function getRendererShortVideoIds(renderer) { function getRendererShortVideoIds(renderer) {
const identities = new Set(); const identities = new Set();
if (!renderer) {
return identities;
}
const attributeVideoId = renderer.getAttribute("video-id"); const attributeVideoId = renderer.getAttribute("video-id");
if (attributeVideoId) { if (attributeVideoId) {
identities.add(attributeVideoId); identities.add(attributeVideoId);
return identities;
} }
for (const link of renderer.querySelectorAll('a[href*="/shorts/"]')) { for (const linkVideoId of getShortsIdentityLinkVideoIds(renderer, location.href)) {
try { identities.add(linkVideoId);
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; return identities;
} }
function rendererMatchesShort(renderer, videoId) { function rendererMatchesShort(renderer, videoId) {
return Boolean(videoId) && getRendererShortVideoIds(renderer).has(videoId); const videoIds = getRendererShortVideoIds(renderer);
return Boolean(videoId) && videoIds.size === 1 && videoIds.has(videoId);
} }
function getControlOwnershipVideoIds(container) { function getControlOwnershipVideoIds(container) {
@@ -995,9 +1292,9 @@ function getActiveDesktopShortsActionBar() {
})) }))
.filter(({ actionBar }) => actionBar); .filter(({ actionBar }) => actionBar);
const matchingCandidate = candidates.find(({ renderer }) => rendererMatchesShort(renderer, videoId)); const matchingCandidates = candidates.filter(({ renderer }) => rendererMatchesShort(renderer, videoId));
if (matchingCandidate) { if (matchingCandidates.length === 1) {
return matchingCandidate.actionBar; return matchingCandidates[0].actionBar;
} }
// During channel/watch -> Shorts SPA transitions, YouTube can render the active // During channel/watch -> Shorts SPA transitions, YouTube can render the active
@@ -1021,6 +1318,47 @@ function getActiveDesktopShortsActionBar() {
return fullyVisibleCandidates.length === 1 ? fullyVisibleCandidates[0].actionBar : null; return fullyVisibleCandidates.length === 1 ? fullyVisibleCandidates[0].actionBar : null;
} }
function getShortsControlSurfaceSignature(buttons) {
return (
Array.from(getRendererShortVideoIds(buttons?.closest("ytd-reel-video-renderer")))
.sort()
.join("|") || "identityless"
);
}
function getVisibleDesktopShortsControlSurfaceCount() {
return Array.from(document.querySelectorAll("reel-action-bar-view-model")).filter(
(candidate) => hasRenderedBox(candidate) && hasMeaningfulViewportPresence(candidate),
).length;
}
function shortsControlSurfaceIsReadyForMutation(
buttons,
currentVideoId,
{ allowUnhydratedFallback = false, isHydrated = false, isStable = false } = {},
) {
return isShortsControlSurfaceReadyForMutation({
allowUnhydratedFallback,
candidateVideoIds: getRendererShortVideoIds(buttons?.closest("ytd-reel-video-renderer")),
currentVideoId,
isConnected: buttons?.isConnected,
isHydrated,
isRendered: hasRenderedBox(buttons),
isStable,
isViewportIntersecting: hasMeaningfulViewportPresence(buttons),
visibleCandidateCount: getVisibleDesktopShortsControlSurfaceCount(),
});
}
function userscript_entry_shortsNativeControlInventoryIsReadyForFallback(inventory) {
return shortsNativeControlInventoryIsReadyForFallback(inventory, {
getTopmostElement:
typeof document.elementFromPoint === "function" ? (x, y) => document.elementFromPoint(x, y) : null,
isMeaningfullyInViewport: hasMeaningfulViewportPresence,
isRendered: hasRenderedBox,
});
}
function getActiveMobileShortsButtons() { function getActiveMobileShortsButtons() {
const videoId = getVideoId(); const videoId = getVideoId();
const candidates = Array.from(document.querySelectorAll("ytm-like-button-renderer")) const candidates = Array.from(document.querySelectorAll("ytm-like-button-renderer"))
@@ -1043,18 +1381,7 @@ function getActiveMobileShortsButtons() {
} }
function getExactShortLinkVideoIds(element) { function getExactShortLinkVideoIds(element) {
const identities = new Set(); return getShortsIdentityLinkVideoIds(element, location.href);
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) { function getMobileShortOwnership(buttons) {
@@ -1186,19 +1513,32 @@ function removeSyntheticShortsDislike(syntheticDislike) {
} }
} }
function ensureSyntheticShortsDislikeButton(buttons) { function ensureSyntheticShortsDislikeButton(
buttons,
{ allowUnhydratedFallback = false, currentVideoId = getVideoId(), isHydrated = false, isStable = false } = {},
) {
if (!isShorts() || isMobile || !buttons) { if (!isShorts() || isMobile || !buttons) {
return; return;
} }
const syntheticDislike = buttons.querySelector(SYNTHETIC_SHORTS_DISLIKE_SELECTOR); const syntheticDislikes = Array.from(buttons.querySelectorAll(SYNTHETIC_SHORTS_DISLIKE_SELECTOR));
const syntheticDislike = syntheticDislikes[0];
const nativeDislike = buttons.querySelector("dislike-button-view-model, #dislike-button"); const nativeDislike = buttons.querySelector("dislike-button-view-model, #dislike-button");
const readyForMutation = shortsControlSurfaceIsReadyForMutation(buttons, currentVideoId, {
allowUnhydratedFallback,
isHydrated: isHydrated && actionBarHasHydratedData(buttons),
isStable,
});
if (nativeDislike) { if (nativeDislike) {
removeSyntheticShortsDislike(syntheticDislike); if (readyForMutation) syntheticDislikes.forEach(removeSyntheticShortsDislike);
return;
}
if (!readyForMutation) {
return; return;
} }
if (syntheticDislike) { if (syntheticDislike) {
const videoId = getVideoId(); syntheticDislikes.slice(1).forEach(removeSyntheticShortsDislike);
const videoId = currentVideoId;
if (videoId && syntheticDislike.getAttribute("data-ryd-video-id") !== videoId) { if (videoId && syntheticDislike.getAttribute("data-ryd-video-id") !== videoId) {
syntheticDislike.setAttribute("data-ryd-video-id", videoId); syntheticDislike.setAttribute("data-ryd-video-id", videoId);
setSyntheticShortsPressed(false, syntheticDislike); setSyntheticShortsPressed(false, syntheticDislike);
@@ -1272,9 +1612,7 @@ function ensureSyntheticShortsDislikeButton(buttons) {
likeButton.insertAdjacentElement("afterend", ownedDislike); likeButton.insertAdjacentElement("afterend", ownedDislike);
} }
function getDislikeButton() { function getDislikeButton(buttons = getButtons()) {
const buttons = getButtons();
ensureSyntheticShortsDislikeButton(buttons);
if (buttons?.tagName === "REEL-ACTION-BAR-VIEW-MODEL") { if (buttons?.tagName === "REEL-ACTION-BAR-VIEW-MODEL") {
return ( return (
buttons.querySelector("dislike-button-view-model, #dislike-button") ?? buttons.querySelector("dislike-button-view-model, #dislike-button") ??
@@ -1303,8 +1641,7 @@ function getDislikeButton() {
} }
} }
function getLikeButton() { function getLikeButton(buttons = getButtons()) {
const buttons = getButtons();
const firstButton = buttons?.children?.[0]; const firstButton = buttons?.children?.[0];
if (!firstButton) { if (!firstButton) {
return null; return null;
@@ -1325,18 +1662,27 @@ function getLikeTextContainer() {
); );
} }
function getDislikeTextContainer() { function getDislikeTextContainer(dislikeButton = getDislikeButton()) {
const dislikeButton = getDislikeButton(); if (!dislikeButton) {
return null;
}
let result = let result =
dislikeButton?.querySelector("#text") ?? dislikeButton.querySelector("#text") ??
dislikeButton?.getElementsByTagName("yt-formatted-string")[0] ?? dislikeButton.getElementsByTagName("yt-formatted-string")[0] ??
dislikeButton?.querySelector("span[role='text']"); dislikeButton.querySelector("span[role='text']");
if (result === null) { if (result == null) {
let textSpan = document.createElement("span"); const activationTarget = getActivationTarget(dislikeButton);
if (!activationTarget?.matches("button, tp-yt-paper-button#button")) {
return null;
}
const textSpan = document.createElement("span");
textSpan.id = "text"; textSpan.id = "text";
textSpan.setAttribute("role", "text");
textSpan.style.marginLeft = "6px"; textSpan.style.marginLeft = "6px";
dislikeButton?.querySelector("button").appendChild(textSpan); activationTarget.appendChild(textSpan);
if (dislikeButton) dislikeButton.querySelector("button").style.width = "auto"; activationTarget.style.width = "auto";
result = textSpan; result = textSpan;
} }
return result; return result;
@@ -1391,6 +1737,9 @@ function createObserver(options, callback) {
observerWrapper.disconnect = function () { observerWrapper.disconnect = function () {
this.observer.disconnect(); this.observer.disconnect();
}; };
observerWrapper.takeRecords = function () {
return this.observer.takeRecords();
};
return observerWrapper; return observerWrapper;
} }
@@ -1850,11 +2199,7 @@ function removeWatchRateBarArtifacts(buttons) {
} }
} }
function clearStaleWatchPresentation(buttons, dislikeButton, videoId) { function clearWatchPresentation(buttons, dislikeButton) {
if (isMobile || isShorts() || !initializedVideoId || initializedVideoId === videoId) {
return;
}
removeWatchRateBarArtifacts(buttons); removeWatchRateBarArtifacts(buttons);
const dislikeText = const dislikeText =
dislikeButton?.querySelector("#text") ?? dislikeButton?.querySelector("#text") ??
@@ -1865,6 +2210,14 @@ function clearStaleWatchPresentation(buttons, dislikeButton, videoId) {
} }
} }
function clearStaleWatchPresentation(buttons, dislikeButton, videoId) {
if (isMobile || isShorts() || !initializedVideoId || initializedVideoId === videoId) {
return;
}
clearWatchPresentation(buttons, dislikeButton);
}
function canRepairWatchRateBar(buttons, videoId) { function canRepairWatchRateBar(buttons, videoId) {
if ( if (
isMobile || isMobile ||
@@ -2022,8 +2375,14 @@ function setState() {
} }
function updateDOMDislikes() { function updateDOMDislikes() {
const videoId = getVideoId();
if (!videoId || !countStateLoaded || countStateVideoId !== videoId) {
return false;
}
setDislikes(numberFormat(dislikesvalue)); setDislikes(numberFormat(dislikesvalue));
createRateBar(likesvalue, dislikesvalue); createRateBar(likesvalue, dislikesvalue);
return true;
} }
function reportVoteFailure(error) { function reportVoteFailure(error) {
@@ -2208,6 +2567,49 @@ function dislikeClicked(event) {
handleVoteActivation(DISLIKE_ACTION); handleVoteActivation(DISLIKE_ACTION);
} }
function handleDelegatedVoteClick(event) {
const buttons = getButtons();
const likeButton = getLikeButton(buttons);
const dislikeButton = getDislikeButton(buttons);
const activation = resolveDelegatedVoteActivation({
buttons,
dislikeButton,
event,
getActivationTarget,
likeButton,
});
if (!activation) {
return;
}
const videoId = getVideoId();
if (!videoId) {
return;
}
const targetHydration = hydratingShortsActivationTargets.get(activation.activationTarget);
const hydration = targetHydration?.videoId === videoId ? targetHydration : null;
if (targetHydration && !hydration) {
hydratingShortsActivationTargets.delete(activation.activationTarget);
}
const targetIsBoundToCurrentVideo = boundActivationVideoIds.get(activation.activationTarget) === videoId;
const currentCountStateIsLoaded = countStateLoaded && countStateVideoId === videoId;
const isUnboundCurrentShortsActivation = isShorts() && !hydration && !targetIsBoundToCurrentVideo;
if (!hydration && !targetIsBoundToCurrentVideo && !currentCountStateIsLoaded && !isUnboundCurrentShortsActivation) {
return;
}
if (isUnboundCurrentShortsActivation) {
previousState = getState();
}
boundActivationVideoIds.set(activation.activationTarget, videoId);
const delegatedEvent = { currentTarget: activation.activationTarget };
if (activation.action === LIKE_ACTION) {
likeClicked(delegatedEvent);
} else {
dislikeClicked(delegatedEvent);
}
}
function refreshDislikesForBoundControl(event) { function refreshDislikesForBoundControl(event) {
if (boundActivationVideoIds.get(event.currentTarget) === getVideoId()) { if (boundActivationVideoIds.get(event.currentTarget) === getVideoId()) {
updateDOMDislikes(); updateDOMDislikes();
@@ -2419,9 +2821,14 @@ function shortsControlsNeedInitialization() {
const buttons = getButtons(); const buttons = getButtons();
const likeButton = getLikeButton(); const likeButton = getLikeButton();
const dislikeButton = getDislikeButton(); const dislikeButton = getDislikeButton();
const syntheticDislikes = buttons?.querySelectorAll?.(SYNTHETIC_SHORTS_DISLIKE_SELECTOR).length ?? 0;
const hasNativeDislike = Boolean(buttons?.querySelector?.("dislike-button-view-model, #dislike-button"));
if (!buttons || !likeButton || !dislikeButton) { if (!buttons || !likeButton || !dislikeButton) {
return true; return true;
} }
if ((hasNativeDislike && syntheticDislikes > 0) || (!hasNativeDislike && syntheticDislikes !== 1)) {
return true;
}
if ( if (
initializedVideoId !== videoId || initializedVideoId !== videoId ||
initializedLikeButton !== likeButton || initializedLikeButton !== likeButton ||
@@ -2573,12 +2980,7 @@ function bindVoteButtonListeners(likeButton, dislikeButton, { enableSynthetic =
const videoId = getVideoId(); const videoId = getVideoId();
boundActivationVideoIds.set(likeActivationTarget, videoId); boundActivationVideoIds.set(likeActivationTarget, videoId);
boundActivationVideoIds.set(dislikeActivationTarget, videoId); boundActivationVideoIds.set(dislikeActivationTarget, videoId);
if (!boundLikeButtons.has(likeActivationTarget)) {
likeActivationTarget.addEventListener("click", likeClicked);
boundLikeButtons.add(likeActivationTarget);
}
if (!boundDislikeButtons.has(dislikeActivationTarget)) { if (!boundDislikeButtons.has(dislikeActivationTarget)) {
dislikeActivationTarget.addEventListener("click", dislikeClicked);
dislikeActivationTarget.addEventListener("focusin", refreshDislikesForBoundControl); dislikeActivationTarget.addEventListener("focusin", refreshDislikesForBoundControl);
dislikeActivationTarget.addEventListener("focusout", refreshDislikesForBoundControl); dislikeActivationTarget.addEventListener("focusout", refreshDislikesForBoundControl);
boundDislikeButtons.add(dislikeActivationTarget); boundDislikeButtons.add(dislikeActivationTarget);
@@ -2589,6 +2991,106 @@ function bindVoteButtonListeners(likeButton, dislikeButton, { enableSynthetic =
} }
} }
function getStableDesktopShortsControls(videoId, generation) {
const buttons = getButtons();
const inventory = captureShortsNativeControlInventory(buttons);
const isHydrated = actionBarHasHydratedData(buttons);
if (
!buttons ||
!inventory ||
(!isHydrated && !userscript_entry_shortsNativeControlInventoryIsReadyForFallback(inventory)) ||
!shortsControlSurfaceIsReadyForMutation(buttons, videoId, {
allowUnhydratedFallback: !isHydrated,
isHydrated,
isStable: true,
})
) {
pendingShortsMutationSurface = null;
return null;
}
const signature = getShortsControlSurfaceSignature(buttons);
const now = performance.now();
if (
pendingShortsMutationSurface?.buttons !== buttons ||
pendingShortsMutationSurface.generation !== generation ||
pendingShortsMutationSurface.videoId !== videoId ||
pendingShortsMutationSurface.signature !== signature ||
!shortsNativeControlInventoryMatches(pendingShortsMutationSurface.inventory, inventory)
) {
pendingShortsMutationSurface = {
buttons,
generation,
inventory,
observedAt: now,
signature,
unhydratedObservedAt: isHydrated ? null : now,
videoId,
};
return null;
}
if (isHydrated) {
pendingShortsMutationSurface.unhydratedObservedAt = null;
} else if (pendingShortsMutationSurface.unhydratedObservedAt == null) {
pendingShortsMutationSurface.unhydratedObservedAt = now;
}
const stabilityObservedAt = isHydrated
? pendingShortsMutationSurface.observedAt
: pendingShortsMutationSurface.unhydratedObservedAt;
if (now - stabilityObservedAt < getShortsControlSurfaceStabilityMs(isHydrated)) {
return null;
}
const allowUnhydratedFallback = !isHydrated;
const preMutationButtons = getButtons();
const preMutationInventory = captureShortsNativeControlInventory(preMutationButtons);
const preMutationIsHydrated = actionBarHasHydratedData(preMutationButtons);
const preMutationAllowsFallback = !preMutationIsHydrated && allowUnhydratedFallback;
if (
preMutationButtons !== buttons ||
!shortsNativeControlInventoryMatches(inventory, preMutationInventory) ||
(preMutationAllowsFallback && !userscript_entry_shortsNativeControlInventoryIsReadyForFallback(preMutationInventory)) ||
getShortsControlSurfaceSignature(preMutationButtons) !== signature ||
!shortsControlSurfaceIsReadyForMutation(preMutationButtons, videoId, {
allowUnhydratedFallback: preMutationAllowsFallback,
isHydrated: preMutationIsHydrated,
isStable: true,
})
) {
pendingShortsMutationSurface = null;
return null;
}
ensureSyntheticShortsDislikeButton(preMutationButtons, {
allowUnhydratedFallback: preMutationAllowsFallback,
currentVideoId: videoId,
isHydrated: preMutationIsHydrated,
isStable: true,
});
const confirmedButtons = getButtons();
const confirmedInventory = captureShortsNativeControlInventory(confirmedButtons);
const confirmedIsHydrated = actionBarHasHydratedData(confirmedButtons);
if (
confirmedButtons !== buttons ||
!shortsNativeControlInventoryMatches(inventory, confirmedInventory) ||
(preMutationAllowsFallback && !userscript_entry_shortsNativeControlInventoryIsReadyForFallback(confirmedInventory)) ||
getShortsControlSurfaceSignature(confirmedButtons) !== signature ||
!shortsControlSurfaceIsReadyForMutation(confirmedButtons, videoId, {
allowUnhydratedFallback: !confirmedIsHydrated && preMutationAllowsFallback,
isHydrated: confirmedIsHydrated,
isStable: true,
})
) {
pendingShortsMutationSurface = null;
return null;
}
pendingShortsMutationSurface = null;
return confirmedButtons;
}
async function initializeCurrentButtons(generation) { async function initializeCurrentButtons(generation) {
const videoId = getVideoId(); const videoId = getVideoId();
if (!videoId) { if (!videoId) {
@@ -2602,15 +3104,31 @@ async function initializeCurrentButtons(generation) {
return false; return false;
} }
const buttons = getButtons(); let buttons = getButtons();
const likeButton = getLikeButton(); const needsStableShortsControls =
const dislikeButton = getDislikeButton(); isShorts() &&
!isMobile &&
buttons?.tagName === "REEL-ACTION-BAR-VIEW-MODEL" &&
(!buttons.querySelector("dislike-button-view-model, #dislike-button") ||
buttons.querySelector(SYNTHETIC_SHORTS_DISLIKE_SELECTOR) ||
!actionBarHasHydratedData(buttons));
if (needsStableShortsControls) {
buttons = getStableDesktopShortsControls(videoId, generation);
if (!buttons) {
return false;
}
}
const likeButton = getLikeButton(buttons);
const dislikeButton = getDislikeButton(buttons);
if (!buttons || !likeButton || !dislikeButton) { if (!buttons || !likeButton || !dislikeButton) {
return false; return false;
} }
if (!isShorts() && !watchControlsAreReadyForVideo(buttons, likeButton, dislikeButton, videoId)) { if (!isShorts() && !watchControlsAreReadyForVideo(buttons, likeButton, dislikeButton, videoId)) {
return false; return false;
} }
if (!isShorts() && !getDislikeTextContainer(dislikeButton)) {
return false;
}
observeShortsLifecycle(buttons); observeShortsLifecycle(buttons);
observeWatchRateBar(buttons, videoId); observeWatchRateBar(buttons, videoId);
@@ -2795,7 +3313,13 @@ function checkPageLifecycle() {
} }
function handleNavigateStart() { function handleNavigateStart() {
pendingShortsMutationSurface = null;
disconnectWatchRateBarObserver(); disconnectWatchRateBarObserver();
if (smartimationObserver) {
smartimationObserver.takeRecords();
smartimationObserver.disconnect();
smartimationObserver.container = null;
}
clearPendingWatchNavigationBoundary(); clearPendingWatchNavigationBoundary();
clearPendingWatchControlObservers(); clearPendingWatchControlObservers();
if (isShorts()) { if (isShorts()) {
@@ -2823,6 +3347,11 @@ function handleNavigateStart() {
return; return;
} }
activeCountRequest = null;
countStateLoaded = false;
countStateEpoch += 1;
clearWatchPresentation(buttons, dislikeButton);
const boundary = { const boundary = {
buttons, buttons,
completedVideoId: null, completedVideoId: null,
@@ -2874,6 +3403,7 @@ function handleNavigateFinish(event) {
(function () { (function () {
"use strict"; "use strict";
void voteClient.ensureRegistered().catch(reportVoteFailure); void voteClient.ensureRegistered().catch(reportVoteFailure);
document.addEventListener("click", handleDelegatedVoteClick, true);
window.addEventListener("yt-navigate-start", handleNavigateStart, true); window.addEventListener("yt-navigate-start", handleNavigateStart, true);
window.addEventListener("yt-navigate-finish", handleNavigateFinish, true); window.addEventListener("yt-navigate-finish", handleNavigateFinish, true);
window.addEventListener("popstate", checkPageLifecycle, true); window.addEventListener("popstate", checkPageLifecycle, true);
@@ -2892,5 +3422,7 @@ if (isMobile) {
}, 1000); }, 1000);
} }
})();
/******/ })() /******/ })()
; ;
@@ -29,6 +29,15 @@
min-width: 96px; min-width: 96px;
} }
/* Model YouTube's overlaid Shorts next control without causing a document scroll on activation. */
#short-next {
margin: 0;
position: fixed;
right: 4px;
top: 4px;
z-index: 1;
}
ytd-watch-flexy, ytd-watch-flexy,
ytd-menu-renderer, ytd-menu-renderer,
segmented-like-dislike-button-view-model, segmented-like-dislike-button-view-model,
@@ -49,6 +58,19 @@
} }
segmented-like-dislike-button-view-model { segmented-like-dislike-button-view-model {
display: block;
min-height: 48px;
width: 320px;
}
segmented-like-dislike-button-view-model > yt-smartimation,
[data-fixture-smartimation-content-shell] {
display: block;
min-height: 48px;
width: 320px;
}
[data-fixture-smartimation-content] {
display: flex; display: flex;
gap: 8px; gap: 8px;
min-height: 48px; min-height: 48px;
@@ -60,10 +82,26 @@
min-width: 96px; min-width: 96px;
} }
[data-fixture-watch-icon] {
height: 20px;
width: 20px;
}
[data-fixture-touch-feedback] {
display: none;
}
ytd-reel-video-renderer { ytd-reel-video-renderer {
display: block; display: block;
min-height: 640px; min-height: 640px;
width: 420px; width: min(420px, 100%);
}
.reel-video-in-sequence-new,
.reel-video-in-sequence-thumbnail {
display: block;
min-height: 1px;
min-width: 1px;
} }
ytd-reel-video-renderer[hidden] { ytd-reel-video-renderer[hidden] {
@@ -155,11 +193,27 @@
(() => { (() => {
const firstVideoId = "__VIDEO_ID__"; const firstVideoId = "__VIDEO_ID__";
const secondVideoId = "__SECOND_VIDEO_ID__"; const secondVideoId = "__SECOND_VIDEO_ID__";
const nativeLikesByVideo = new Map([
[firstVideoId, Number("__VIDEO_LIKES__")],
[secondVideoId, Number("__SECOND_VIDEO_LIKES__")],
]);
const marker = "rydNavigationFixture=1"; const marker = "rydNavigationFixture=1";
const navigation = document.getElementById("fixture-navigation"); const navigation = document.getElementById("fixture-navigation");
const page = document.getElementById("fixture-page"); const page = document.getElementById("fixture-page");
const isMobile = location.hostname === "m.youtube.com"; const isMobile = location.hostname === "m.youtube.com";
const fixtureBaseline = {
renderedDesktopShortsNativeDislikes: null,
renderedMobileShortsNativeDislikes: null,
renderedWatchNativeDislikeTextCount: null,
};
const nativeReactionStats = {
activations: 0,
dislikeActivations: 0,
likeActivations: 0,
};
globalThis.__navigationFixtureBaseline = fixtureBaseline;
let pendingDelayedRender = null; let pendingDelayedRender = null;
let pendingStartedWatchRender = null;
if (!customElements.get("like-button-view-model")) { if (!customElements.get("like-button-view-model")) {
customElements.define( customElements.define(
@@ -178,6 +232,55 @@
return `/@FixtureChannel?${marker}`; return `/@FixtureChannel?${marker}`;
}; };
const nativeLikes = (videoId) => nativeLikesByVideo.get(videoId) ?? 100;
function setNativeLikeCount(root, videoId) {
const count = nativeLikes(videoId);
const like = root?.querySelector('[data-fixture-role="like"]');
like?.querySelector("button")?.setAttribute("aria-label", `${count} likes`);
const text = like?.querySelector("#text, [role='text']");
if (text) text.textContent = String(count);
}
function setNativeReactionPressed(control, pressed) {
control?.classList.toggle("style-default-active", pressed);
control?.classList.toggle("style-text", !pressed);
control?.querySelector("button")?.setAttribute("aria-pressed", String(pressed));
}
function adjustNativeLikeCount(like, delta) {
const text = like?.querySelector("#text, [role='text']");
const count = Number.parseInt(text?.textContent, 10);
if (!text || !Number.isFinite(count)) return;
text.textContent = String(Math.max(0, count + delta));
}
document.addEventListener(
"click",
(event) => {
const nativeButton = event.target.closest("button");
const control = nativeButton?.closest('[data-fixture-role="like"], [data-fixture-role="dislike"]');
if (!control || control.querySelector("button") !== nativeButton) return;
const controls = control.closest('[data-fixture-role="buttons"]');
const role = control.getAttribute("data-fixture-role");
const like = controls?.querySelector('[data-fixture-role="like"]');
const dislike = controls?.querySelector('[data-fixture-role="dislike"]');
const wasPressed = nativeButton.getAttribute("aria-pressed") === "true";
const counterpart = role === "like" ? dislike : like;
const counterpartWasPressed = counterpart?.querySelector("button")?.getAttribute("aria-pressed") === "true";
nativeReactionStats.activations += 1;
nativeReactionStats[`${role}Activations`] += 1;
setNativeReactionPressed(control, !wasPressed);
if (!wasPressed) setNativeReactionPressed(counterpart, false);
if (role === "like") adjustNativeLikeCount(like, wasPressed ? -1 : 1);
if (role === "dislike" && !wasPressed && counterpartWasPressed) adjustNativeLikeCount(like, -1);
},
true,
);
const linkMarkup = (id, label, pageKind, videoId, options = {}) => ` const linkMarkup = (id, label, pageKind, videoId, options = {}) => `
<a <a
id="${id}" id="${id}"
@@ -192,13 +295,13 @@
const decoyControls = isMobile const decoyControls = isMobile
? ` ? `
<div class="slim-video-action-bar-actions" data-fixture-decoy-controls> <div class="slim-video-action-bar-actions" data-fixture-decoy-controls>
<div class="segmented-buttons" data-ryd-role="buttons"> <div class="segmented-buttons" data-fixture-role="buttons">
<like-button-view-model class="style-text" data-ryd-role="like"> <like-button-view-model class="style-text" data-fixture-role="like">
<button type="button" aria-label="Channel preview likes" aria-pressed="false"> <button type="button" aria-label="Channel preview likes" aria-pressed="false">
<span id="text" role="text">100</span> <span id="text" role="text">100</span>
</button> </button>
</like-button-view-model> </like-button-view-model>
<dislike-button-view-model class="style-text" data-ryd-role="dislike"> <dislike-button-view-model class="style-text" data-fixture-role="dislike">
<button type="button" aria-label="Channel preview dislike" aria-pressed="false"> <button type="button" aria-label="Channel preview dislike" aria-pressed="false">
<span id="text" role="text"></span> <span id="text" role="text"></span>
</button> </button>
@@ -210,13 +313,13 @@
<div id="menu-container" style="display: none"></div> <div id="menu-container" style="display: none"></div>
<ytd-menu-renderer class="ytd-watch-metadata" data-fixture-decoy-controls> <ytd-menu-renderer class="ytd-watch-metadata" data-fixture-decoy-controls>
<div id="top-level-buttons-computed"> <div id="top-level-buttons-computed">
<segmented-like-dislike-button-view-model data-ryd-role="buttons"> <segmented-like-dislike-button-view-model data-fixture-role="buttons">
<like-button-view-model class="style-text" data-ryd-role="like"> <like-button-view-model class="style-text" data-fixture-role="like">
<button type="button" aria-label="Channel preview likes" aria-pressed="false"> <button type="button" aria-label="Channel preview likes" aria-pressed="false">
<span id="text" role="text">100</span> <span id="text" role="text">100</span>
</button> </button>
</like-button-view-model> </like-button-view-model>
<dislike-button-view-model class="style-text" data-ryd-role="dislike"> <dislike-button-view-model class="style-text" data-fixture-role="dislike">
<button type="button" aria-label="Channel preview dislike" aria-pressed="false"> <button type="button" aria-label="Channel preview dislike" aria-pressed="false">
<span id="text" role="text"></span> <span id="text" role="text"></span>
</button> </button>
@@ -237,32 +340,57 @@
function watchMarkup(videoId) { function watchMarkup(videoId) {
const nextVideoId = videoId === firstVideoId ? secondVideoId : firstVideoId; const nextVideoId = videoId === firstVideoId ? secondVideoId : firstVideoId;
const likeCount = nativeLikes(videoId);
return ` return `
<section data-fixture-page-kind="watch" data-fixture-video-id="${videoId}"> <section data-fixture-page-kind="watch" data-fixture-video-id="${videoId}">
<div id="menu-container" style="display: none"></div> <ytd-watch-flexy video-id="${videoId}">
<div id="player" loading="false"></div> <div id="menu-container" style="display: none"></div>
<ytd-watch-flexy video-id="${videoId}"></ytd-watch-flexy> <div id="player" loading="false"></div>
<div id="top-row"> <div id="top-row">
<ytd-menu-renderer class="ytd-watch-metadata"> <ytd-menu-renderer class="ytd-watch-metadata">
<div id="top-level-buttons-computed"> <div id="top-level-buttons-computed">
<segmented-like-dislike-button-view-model <segmented-like-dislike-button-view-model
data-ryd-role="buttons" data-fixture-role="buttons"
data-fixture-control-video-id="${videoId}" data-fixture-control-video-id="${videoId}"
> >
<like-button-view-model class="style-text" data-ryd-role="like"> <yt-smartimation>
<button type="button" aria-label="100 likes" aria-pressed="false"> <div id="content" data-fixture-smartimation-content-shell>
<span id="text" role="text">100</span> <div id="wrapper" data-fixture-smartimation-content>
</button> <like-button-view-model class="style-text" data-fixture-role="like">
</like-button-view-model> <toggle-button-view-model>
<dislike-button-view-model class="style-text" data-ryd-role="dislike"> <button-view-model>
<button type="button" aria-label="Dislike this video" aria-pressed="false"> <button type="button" aria-label="${likeCount} likes" aria-pressed="false">
<span id="text" role="text"></span> <svg data-fixture-watch-icon="like" aria-hidden="true" viewBox="0 0 24 24">
</button> <path fill="currentColor" d="M8 21H5V9h3v12Zm2 0V9l4-7 2 1v5h5v5l-3 8h-8Z"></path>
</dislike-button-view-model> </svg>
</segmented-like-dislike-button-view-model> <div class="ytSpecButtonShapeNextButtonTextContent" data-fixture-provided-native-text>
</div> <span id="text" role="text">${likeCount}</span>
</ytd-menu-renderer> </div>
</div> <span data-fixture-touch-feedback aria-hidden="true"></span>
</button>
</button-view-model>
</toggle-button-view-model>
</like-button-view-model>
<dislike-button-view-model class="style-text" data-fixture-role="dislike">
<toggle-button-view-model>
<button-view-model>
<button type="button" aria-label="Dislike this video" aria-pressed="false">
<svg data-fixture-watch-icon="dislike" aria-hidden="true" viewBox="0 0 24 24">
<path fill="currentColor" d="M16 3h3v12h-3V3Zm-2 0v12l-4 7-2-1v-5H3v-5l3-8h8Z"></path>
</svg>
<span data-fixture-touch-feedback aria-hidden="true"></span>
</button>
</button-view-model>
</toggle-button-view-model>
</dislike-button-view-model>
</div>
</div>
</yt-smartimation>
</segmented-like-dislike-button-view-model>
</div>
</ytd-menu-renderer>
</div>
</ytd-watch-flexy>
${linkMarkup("watch-to-short", "Open Short", "shorts", nextVideoId, { controlDelay: 200 })} ${linkMarkup("watch-to-short", "Open Short", "shorts", nextVideoId, { controlDelay: 200 })}
${linkMarkup("watch-related", "Open related video", "watch", nextVideoId)} ${linkMarkup("watch-related", "Open related video", "watch", nextVideoId)}
${linkMarkup("watch-next", "Play next video", "watch", nextVideoId)} ${linkMarkup("watch-next", "Play next video", "watch", nextVideoId)}
@@ -272,38 +400,75 @@
} }
function shortRendererMarkup(videoId, active) { function shortRendererMarkup(videoId, active) {
const likeCount = nativeLikes(videoId);
return ` return `
<div class="reel-video-in-sequence-new" data-fixture-sequence-video-id="${videoId}">
<div class="reel-video-in-sequence-thumbnail"></div>
<ytd-reel-video-renderer video-id="${videoId}" ${active ? "is-active" : "hidden"}> <ytd-reel-video-renderer video-id="${videoId}" ${active ? "is-active" : "hidden"}>
<a href="/shorts/${videoId}" aria-label="Short ${videoId}"></a> <div id="experiment-overlay"><span>Ready</span></div>
<reel-action-bar-view-model data-ryd-role="buttons"> <a class="ytp-title-link" href="/shorts/${videoId}" aria-label="Short ${videoId}"></a>
<reel-action-bar-view-model data-fixture-role="buttons">
<like-button-view-model <like-button-view-model
class="ytLikeButtonViewModelHost ytwReelActionBarViewModelHostDesktopActionButton" class="ytLikeButtonViewModelHost ytwReelActionBarViewModelHostDesktopActionButton"
data-ryd-role="like" data-fixture-role="like"
> >
<label class="ytSpecButtonShapeWithLabelHost"> <label class="ytSpecButtonShapeWithLabelHost">
<button class="ytSpecButtonShapeNextHost" type="button" aria-label="100 likes" aria-pressed="false"> <button class="ytSpecButtonShapeNextHost" type="button" aria-label="${likeCount} likes" aria-pressed="false">
<span class="ytSpecButtonShapeNextIcon" aria-hidden="true"> <span class="ytSpecButtonShapeNextIcon" aria-hidden="true">
<svg viewBox="0 0 24 24"><path d="M12 21"></path></svg> <svg viewBox="0 0 24 24"><path d="M8 21H5V9h3v12Zm2 0V9l4-7 2 1v5h5v5l-3 8h-8Z"></path></svg>
</span> </span>
</button> </button>
<div class="ytSpecButtonShapeWithLabelLabel"><span id="text" role="text">100</span></div> <div class="ytSpecButtonShapeWithLabelLabel"><span id="text" role="text">${likeCount}</span></div>
</label> </label>
</like-button-view-model> </like-button-view-model>
<button-view-model data-fixture-control="comments"> <button-view-model data-fixture-control="comments">
<button type="button" aria-label="View comments"><span role="text">12</span></button> <label class="ytSpecButtonShapeWithLabelHost">
<button type="button" aria-label="View comments">
<span class="ytSpecButtonShapeNextIcon" aria-hidden="true">
<svg viewBox="0 0 24 24"><path d="M3 4h18v14H8l-5 3Z"></path></svg>
</span>
</button>
<div class="ytSpecButtonShapeWithLabelLabel"><span role="text">12</span></div>
</label>
</button-view-model> </button-view-model>
<button-view-model data-fixture-control="share"> <button-view-model data-fixture-control="share">
<button type="button" aria-label="Share">Share</button> <label class="ytSpecButtonShapeWithLabelHost">
<button type="button" aria-label="Share">
<span class="ytSpecButtonShapeNextIcon" aria-hidden="true">
<svg viewBox="0 0 24 24"><path d="M14 4l7 7-7 7v-4H3V8h11Z"></path></svg>
</span>
</button>
<div class="ytSpecButtonShapeWithLabelLabel"><span role="text">Share</span></div>
</label>
</button-view-model> </button-view-model>
<button-view-model data-fixture-control="remix"> <button-view-model data-fixture-control="remix">
<button type="button" aria-label="Remix">Remix</button> <label class="ytSpecButtonShapeWithLabelHost">
<button type="button" aria-label="Remix">
<span class="ytSpecButtonShapeNextIcon" aria-hidden="true">
<svg viewBox="0 0 24 24"><path d="M4 7h10l-2-2 2-2 6 6-6 6-2-2 2-2H4Z"></path></svg>
</span>
</button>
<div class="ytSpecButtonShapeWithLabelLabel"><span role="text">Remix</span></div>
</label>
</button-view-model>
<button-view-model data-fixture-control="sound">
<label class="ytSpecButtonShapeWithLabelHost">
<button type="button" aria-label="Sound">
<span class="ytSpecButtonShapeNextIcon" aria-hidden="true">
<svg viewBox="0 0 24 24"><path d="M4 9v6h4l5 4V5L8 9H4Zm12.5 3a3.5 3.5 0 0 0-2-3.16v6.32a3.5 3.5 0 0 0 2-3.16Z"></path></svg>
</span>
</button>
<div class="ytSpecButtonShapeWithLabelLabel"><span role="text">Sound</span></div>
</label>
</button-view-model> </button-view-model>
</reel-action-bar-view-model> </reel-action-bar-view-model>
</ytd-reel-video-renderer> </ytd-reel-video-renderer>
</div>
`; `;
} }
function mobileShortRendererMarkup(videoId, active) { function mobileShortRendererMarkup(videoId, active) {
const likeCount = nativeLikes(videoId);
return ` return `
<ytm-reel-video-renderer <ytm-reel-video-renderer
data-fixture-mobile-short="${videoId}" data-fixture-mobile-short="${videoId}"
@@ -313,13 +478,13 @@
<a href="/shorts/${videoId}" aria-label="Short ${videoId}"></a> <a href="/shorts/${videoId}" aria-label="Short ${videoId}"></a>
<ytm-reel-player-overlay-renderer> <ytm-reel-player-overlay-renderer>
<div id="like-button"> <div id="like-button">
<ytm-like-button-renderer data-ryd-role="buttons"> <ytm-like-button-renderer data-fixture-role="buttons">
<like-button-view-model class="style-text" data-ryd-role="like"> <like-button-view-model class="style-text" data-fixture-role="like">
<button type="button" aria-label="100 likes" aria-pressed="false"> <button type="button" aria-label="${likeCount} likes" aria-pressed="false">
<span id="text" class="button-renderer-text" role="text">100</span> <span id="text" class="button-renderer-text" role="text">${likeCount}</span>
</button> </button>
</like-button-view-model> </like-button-view-model>
<dislike-button-view-model class="style-text" data-ryd-role="dislike"> <dislike-button-view-model class="style-text" data-fixture-role="dislike">
<button type="button" aria-label="Dislike this video" aria-pressed="false"> <button type="button" aria-label="Dislike this video" aria-pressed="false">
<span id="text" class="button-renderer-text" role="text"></span> <span id="text" class="button-renderer-text" role="text"></span>
</button> </button>
@@ -367,6 +532,24 @@
function render(pageKind, videoId) { function render(pageKind, videoId) {
page.innerHTML = pageMarkup(pageKind, videoId); page.innerHTML = pageMarkup(pageKind, videoId);
fixtureBaseline.renderedWatchNativeDislikeTextCount =
pageKind === "watch"
? page.querySelectorAll(
'[data-fixture-page-kind="watch"] [data-fixture-role="dislike"] .ytSpecButtonShapeNextButtonTextContent',
).length
: null;
fixtureBaseline.renderedDesktopShortsNativeDislikes =
pageKind === "shorts" && !isMobile
? page.querySelectorAll(
"ytd-reel-video-renderer[is-active] > reel-action-bar-view-model > dislike-button-view-model",
).length
: null;
fixtureBaseline.renderedMobileShortsNativeDislikes =
pageKind === "shorts" && isMobile
? page.querySelectorAll(
"ytm-reel-video-renderer[is-active] ytm-like-button-renderer > dislike-button-view-model",
).length
: null;
navigation.innerHTML = navigation.innerHTML =
pageKind === "channel" pageKind === "channel"
? "" ? ""
@@ -475,6 +658,28 @@
dispatchNavigation(); dispatchNavigation();
} }
function navigateWatchAfterStartWithoutFinish(videoId) {
const watchPage = page.querySelector('[data-fixture-page-kind="watch"]');
const watch = watchPage?.querySelector("ytd-watch-flexy");
if (!watchPage || !watch) return;
document.dispatchEvent(new Event("yt-navigate-start", { bubbles: true }));
history.pushState({}, "", hrefFor("watch", videoId));
watchPage.setAttribute("data-fixture-video-id", videoId);
watch.setAttribute("video-id", videoId);
page.dataset.fixtureTransitionPending = "watch-start-without-finish";
pendingStartedWatchRender = () => {
delete page.dataset.fixtureTransitionPending;
render("watch", videoId);
};
}
function finishWatchAfterStartWithoutFinish() {
if (!pendingStartedWatchRender) throw new Error("No start-only Watch navigation is pending");
const finish = pendingStartedWatchRender;
pendingStartedWatchRender = null;
finish();
}
function beginSameNodeWatchNavigation(videoId) { function beginSameNodeWatchNavigation(videoId) {
const watch = page.querySelector("ytd-watch-flexy"); const watch = page.querySelector("ytd-watch-flexy");
if (!watch) return; if (!watch) return;
@@ -488,7 +693,7 @@
} }
function mutateOutgoingWatchDescendant() { function mutateOutgoingWatchDescendant() {
const dislike = page.querySelector('[data-fixture-page-kind="watch"] [data-ryd-role="dislike"]'); const dislike = page.querySelector('[data-fixture-page-kind="watch"] [data-fixture-role="dislike"]');
if (!dislike) return; if (!dislike) return;
const countMarker = document.createElement("span"); const countMarker = document.createElement("span");
countMarker.hidden = true; countMarker.hidden = true;
@@ -502,7 +707,7 @@
} }
function replaceDelayedWatchControl(role) { function replaceDelayedWatchControl(role) {
const control = page.querySelector(`[data-fixture-page-kind="watch"] [data-ryd-role="${role}"]`); const control = page.querySelector(`[data-fixture-page-kind="watch"] [data-fixture-role="${role}"]`);
if (!control) return; if (!control) return;
const replacement = control.cloneNode(true); const replacement = control.cloneNode(true);
replacement.setAttribute("data-fixture-watch-replacement", role); replacement.setAttribute("data-fixture-watch-replacement", role);
@@ -516,7 +721,7 @@
control.replaceWith(replacement); control.replaceWith(replacement);
} }
function replaceCurrentWatchActions({ retainOutgoing = false } = {}) { function replaceCurrentWatchActions({ nativeHydrationDelay = 0, retainOutgoing = false } = {}) {
const section = page.querySelector('[data-fixture-page-kind="watch"]'); const section = page.querySelector('[data-fixture-page-kind="watch"]');
const currentActions = section?.querySelector("#top-level-buttons-computed"); const currentActions = section?.querySelector("#top-level-buttons-computed");
const videoId = section?.dataset.fixtureVideoId; const videoId = section?.dataset.fixtureVideoId;
@@ -526,10 +731,10 @@
replacement.setAttribute("data-fixture-watch-actions-replacement", videoId); replacement.setAttribute("data-fixture-watch-actions-replacement", videoId);
replacement.querySelectorAll(".ryd-tooltip").forEach((tooltip) => tooltip.remove()); replacement.querySelectorAll(".ryd-tooltip").forEach((tooltip) => tooltip.remove());
const controls = replacement.querySelector('[data-ryd-role="buttons"]'); const controls = replacement.querySelector('[data-fixture-role="buttons"]');
controls?.setAttribute("data-fixture-control-video-id", videoId); controls?.setAttribute("data-fixture-control-video-id", videoId);
for (const role of ["like", "dislike"]) { for (const role of ["like", "dislike"]) {
const control = controls?.querySelector(`[data-ryd-role="${role}"]`); const control = controls?.querySelector(`[data-fixture-role="${role}"]`);
control?.classList.remove("style-default-active"); control?.classList.remove("style-default-active");
control?.classList.add("style-text"); control?.classList.add("style-text");
const button = control?.querySelector("button"); const button = control?.querySelector("button");
@@ -537,8 +742,24 @@
button?.removeAttribute("disabled"); button?.removeAttribute("disabled");
button?.setAttribute("aria-disabled", "false"); button?.setAttribute("aria-disabled", "false");
} }
const dislikeCount = controls?.querySelector('[data-ryd-role="dislike"] #text'); controls
if (dislikeCount) dislikeCount.textContent = ""; ?.querySelectorAll(
'[data-fixture-role="dislike"] .ytSpecButtonShapeNextButtonTextContent, [data-fixture-role="dislike"] #text',
)
.forEach((element) => element.remove());
const delayedNativeButtons = [];
if (nativeHydrationDelay > 0) {
for (const role of ["like", "dislike"]) {
const control = controls?.querySelector(`[data-fixture-role="${role}"]`);
const button = control?.querySelector("button");
if (control && button) {
control.setAttribute("data-fixture-native-hydration-pending", role);
delayedNativeButtons.push({ button, control });
button.remove();
}
}
}
currentActions.replaceWith(replacement); currentActions.replaceWith(replacement);
globalThis.__fixtureReplacedWatchActions = currentActions; globalThis.__fixtureReplacedWatchActions = currentActions;
@@ -549,17 +770,28 @@
retained.appendChild(currentActions); retained.appendChild(currentActions);
document.body.appendChild(retained); document.body.appendChild(retained);
} }
if (delayedNativeButtons.length > 0) {
setTimeout(() => {
for (const { button, control } of delayedNativeButtons) {
if (replacement.isConnected && control.isConnected && !control.querySelector("button")) {
control.appendChild(button);
control.removeAttribute("data-fixture-native-hydration-pending");
}
}
}, nativeHydrationDelay);
}
return true; return true;
} }
function refreshReusedWatchControl(role, videoId) { function refreshReusedWatchControl(role, videoId) {
const control = page.querySelector(`[data-fixture-page-kind="watch"] [data-ryd-role="${role}"]`); const control = page.querySelector(`[data-fixture-page-kind="watch"] [data-fixture-role="${role}"]`);
if (!control) return; if (!control) return;
setNativeReactionPressed(control, false);
control.querySelector("button")?.setAttribute("aria-label", `${role} refreshed for ${videoId}`); control.querySelector("button")?.setAttribute("aria-label", `${role} refreshed for ${videoId}`);
} }
function driftWatchControlBeforeNavigation(role, videoId) { function driftWatchControlBeforeNavigation(role, videoId) {
const button = page.querySelector(`[data-fixture-page-kind="watch"] [data-ryd-role="${role}"] button`); const button = page.querySelector(`[data-fixture-page-kind="watch"] [data-fixture-role="${role}"] button`);
if (!button) return; if (!button) return;
button.setAttribute("aria-label", `${role} drift while ${videoId}`); button.setAttribute("aria-label", `${role} drift while ${videoId}`);
button.setAttribute("title", `${role} title drift while ${videoId}`); button.setAttribute("title", `${role} title drift while ${videoId}`);
@@ -571,7 +803,7 @@
if (!overlay) return; if (!overlay) return;
const replacement = overlay.cloneNode(true); const replacement = overlay.cloneNode(true);
replacement.setAttribute("data-fixture-replacement", "true"); replacement.setAttribute("data-fixture-replacement", "true");
const dislike = replacement.querySelector('[data-ryd-role="dislike"]'); const dislike = replacement.querySelector('[data-fixture-role="dislike"]');
dislike?.classList.remove("style-default-active"); dislike?.classList.remove("style-default-active");
dislike?.classList.add("style-text"); dislike?.classList.add("style-text");
dislike?.querySelector("button")?.setAttribute("aria-pressed", "false"); dislike?.querySelector("button")?.setAttribute("aria-pressed", "false");
@@ -624,18 +856,24 @@
}, },
finishDelayedNavigation, finishDelayedNavigation,
finishSameNodeWatchNavigation, finishSameNodeWatchNavigation,
finishWatchAfterStartWithoutFinish,
driftWatchControlBeforeNavigation, driftWatchControlBeforeNavigation,
navigate, navigate,
navigateDelayedAnonymousShort, navigateDelayedAnonymousShort,
navigateDelayedShort, navigateDelayedShort,
navigateDelayedWatch, navigateDelayedWatch,
navigateToShortsDescendant, navigateToShortsDescendant,
navigateWatchAfterStartWithoutFinish,
mutateOutgoingWatchDescendant, mutateOutgoingWatchDescendant,
nativeReactionSnapshot() {
return { ...nativeReactionStats };
},
recycleShort, recycleShort,
replaceActiveMobileOverlay, replaceActiveMobileOverlay,
replaceCurrentWatchActions, replaceCurrentWatchActions,
replaceDelayedWatchControl, replaceDelayedWatchControl,
refreshReusedWatchControl, refreshReusedWatchControl,
setNativeLikeCount,
}; };
render("__INITIAL_PAGE_KIND__", "__VIDEO_ID__"); render("__INITIAL_PAGE_KIND__", "__VIDEO_ID__");
@@ -25,6 +25,9 @@
.slim-video-action-bar-actions, .slim-video-action-bar-actions,
.segmented-buttons, .segmented-buttons,
segmented-like-dislike-button-view-model, segmented-like-dislike-button-view-model,
yt-smartimation,
toggle-button-view-model,
button-view-model,
like-button-view-model, like-button-view-model,
dislike-button-view-model { dislike-button-view-model {
display: block; display: block;
@@ -32,17 +35,33 @@
ytd-menu-renderer.ytd-watch-metadata > div { ytd-menu-renderer.ytd-watch-metadata > div {
display: block; display: block;
width: min(320px, 100%);
min-height: 36px; min-height: 36px;
} }
#top-row { #top-row {
width: min(320px, 100%); width: min(500px, 100%);
}
ytd-menu-renderer.ytd-watch-metadata {
display: flex;
gap: 8px;
width: min(500px, 100%);
}
#top-level-buttons-computed {
position: relative;
width: 192px;
} }
segmented-like-dislike-button-view-model { segmented-like-dislike-button-view-model {
display: block;
gap: 0;
}
[data-fixture-smartimation-content] {
display: flex; display: flex;
gap: 0; gap: 0;
width: 192px;
} }
.segmented-buttons { .segmented-buttons {
@@ -56,8 +75,8 @@
display: none; display: none;
} }
[data-ryd-role="like"], [__FIXTURE_ROLE_ATTRIBUTE__="like"],
[data-ryd-role="dislike"] { [__FIXTURE_ROLE_ATTRIBUTE__="dislike"] {
box-sizing: border-box; box-sizing: border-box;
flex: 0 0 96px; flex: 0 0 96px;
height: 36px; height: 36px;
@@ -66,8 +85,8 @@
width: 96px; width: 96px;
} }
[data-ryd-role="like"] button, [__FIXTURE_ROLE_ATTRIBUTE__="like"] button,
[data-ryd-role="dislike"] button { [__FIXTURE_ROLE_ATTRIBUTE__="dislike"] button {
align-items: center; align-items: center;
background: rgb(39, 39, 39); background: rgb(39, 39, 39);
border: 0; border: 0;
@@ -88,7 +107,7 @@
width: 96px; width: 96px;
} }
[data-ryd-role].style-default-active button { [__FIXTURE_ROLE_ATTRIBUTE__].style-default-active button {
background: rgb(241, 241, 241); background: rgb(241, 241, 241);
color: rgb(15, 15, 15); color: rgb(15, 15, 15);
} }
@@ -102,7 +121,8 @@
width: 20px; width: 20px;
} }
[data-ryd-role] #text { [__FIXTURE_ROLE_ATTRIBUTE__] #text,
[__FIXTURE_ROLE_ATTRIBUTE__] .ytSpecButtonShapeNextButtonTextContent {
display: block; display: block;
font: font:
500 14px/20px Arial, 500 14px/20px Arial,
@@ -110,6 +130,32 @@
margin: 0; margin: 0;
padding: 0; padding: 0;
} }
#flexible-item-buttons {
display: flex;
gap: 8px;
margin: 0;
width: 172px;
}
#flexible-item-buttons button {
background: rgb(39, 39, 39);
border: 0;
border-radius: 18px;
color: rgb(241, 241, 241);
height: 36px;
min-width: 82px;
}
#fixture-comment-action-surfaces #top-level-buttons-computed {
height: 0;
overflow: hidden;
width: 0;
}
[data-fixture-touch-feedback] {
display: none;
}
</style> </style>
</head> </head>
<body> <body>
@@ -117,12 +163,22 @@
<div id="menu-container" style="display: none"></div> <div id="menu-container" style="display: none"></div>
<div id="fixture-account"></div> <div id="fixture-account"></div>
<div id="player" loading="false"></div> <div id="player" loading="false"></div>
<ytd-watch-flexy id="fixture-watch" video-id="__VIDEO_ID__"></ytd-watch-flexy> <ytd-watch-flexy id="fixture-watch" video-id="__VIDEO_ID__">
<div id="top-row" data-fixture-shell="desktop"> <div id="top-row" data-fixture-shell="desktop">
<ytd-menu-renderer class="ytd-watch-metadata"> <ytd-menu-renderer class="ytd-watch-metadata">
<div id="top-level-buttons-computed"></div> <div id="top-level-buttons-computed"></div>
</ytd-menu-renderer> <div id="flexible-item-buttons" data-fixture-unrelated-actions>
</div> <button-view-model
><button type="button"><span role="text">Save</span></button></button-view-model
>
<button-view-model
><button type="button"><span role="text">Download</span></button></button-view-model
>
</div>
</ytd-menu-renderer>
</div>
</ytd-watch-flexy>
<div id="fixture-comment-action-surfaces"></div>
<div class="slim-video-action-bar-actions" data-fixture-shell="mobile"> <div class="slim-video-action-bar-actions" data-fixture-shell="mobile">
<div id="fixture-mobile-buttons-host" class="segmented-buttons"></div> <div id="fixture-mobile-buttons-host" class="segmented-buttons"></div>
</div> </div>
@@ -147,51 +203,67 @@
function desktopButtonMarkup() { function desktopButtonMarkup() {
return ` return `
<segmented-like-dislike-button-view-model data-ryd-role="buttons"> <segmented-like-dislike-button-view-model __FIXTURE_ROLE_ATTRIBUTE__="buttons">
<like-button-view-model class="style-text" data-ryd-role="like"> <yt-smartimation>
<button type="button" aria-label="100 likes" aria-pressed="false"> <div id="content" data-fixture-smartimation-content-shell>
<svg data-fixture-icon="like" aria-hidden="true" viewBox="0 0 24 24"> <div id="wrapper" data-fixture-smartimation-content>
<path fill="currentColor" d="M8 21H5V9h3v12Zm2 0V9l4-7 2 1v5h5v5l-3 8h-8Z"></path> <like-button-view-model class="style-text" __FIXTURE_ROLE_ATTRIBUTE__="like">
</svg> <toggle-button-view-model>
<span id="text" role="text">100</span> <button-view-model>
</button> <button type="button" aria-label="__VIDEO_LIKES__ likes" aria-pressed="false">
</like-button-view-model> <svg data-fixture-icon="like" aria-hidden="true" viewBox="0 0 24 24">
<dislike-button-view-model class="style-text" data-ryd-role="dislike"> <path fill="currentColor" d="M8 21H5V9h3v12Zm2 0V9l4-7 2 1v5h5v5l-3 8h-8Z"></path>
<button type="button" aria-label="Dislike this video" aria-pressed="false"> </svg>
<svg data-fixture-icon="dislike" aria-hidden="true" viewBox="0 0 24 24"> <div class="ytSpecButtonShapeNextButtonTextContent"><span id="text" role="text">__VIDEO_LIKES__</span></div>
<path fill="currentColor" d="M16 3h3v12h-3V3Zm-2 0v12l-4 7-2-1v-5H3v-5l3-8h8Z"></path> <span data-fixture-touch-feedback aria-hidden="true"></span>
</svg> </button>
<span id="text" role="text"></span> </button-view-model>
</button> </toggle-button-view-model>
</dislike-button-view-model> </like-button-view-model>
<dislike-button-view-model class="style-text" __FIXTURE_ROLE_ATTRIBUTE__="dislike">
<toggle-button-view-model>
<button-view-model>
<button type="button" aria-label="Dislike this video" aria-pressed="false">
<svg data-fixture-icon="dislike" aria-hidden="true" viewBox="0 0 24 24">
<path fill="currentColor" d="M16 3h3v12h-3V3Zm-2 0v12l-4 7-2-1v-5H3v-5l3-8h8Z"></path>
</svg>
__DISLIKE_TEXT_MARKUP__
<span data-fixture-touch-feedback aria-hidden="true"></span>
</button>
</button-view-model>
</toggle-button-view-model>
</dislike-button-view-model>
</div>
</div>
</yt-smartimation>
</segmented-like-dislike-button-view-model> </segmented-like-dislike-button-view-model>
`; `;
} }
function mobileButtonMarkup() { function mobileButtonMarkup() {
return ` return `
<like-button-view-model class="style-text" data-ryd-role="like"> <like-button-view-model class="style-text" __FIXTURE_ROLE_ATTRIBUTE__="like">
<button type="button" aria-label="100 likes" aria-pressed="false"> <button type="button" aria-label="__VIDEO_LIKES__ likes" aria-pressed="false">
<svg data-fixture-icon="like" aria-hidden="true" viewBox="0 0 24 24"> <svg data-fixture-icon="like" aria-hidden="true" viewBox="0 0 24 24">
<path fill="currentColor" d="M8 21H5V9h3v12Zm2 0V9l4-7 2 1v5h5v5l-3 8h-8Z"></path> <path fill="currentColor" d="M8 21H5V9h3v12Zm2 0V9l4-7 2 1v5h5v5l-3 8h-8Z"></path>
</svg> </svg>
<span id="text" class="button-renderer-text" role="text">100</span> <div class="ytSpecButtonShapeNextButtonTextContent"><span id="text" class="button-renderer-text" role="text">__VIDEO_LIKES__</span></div>
</button> </button>
</like-button-view-model> </like-button-view-model>
<dislike-button-view-model class="style-text" data-ryd-role="dislike"> <dislike-button-view-model class="style-text" __FIXTURE_ROLE_ATTRIBUTE__="dislike">
<button type="button" aria-label="Dislike this video" aria-pressed="false"> <button type="button" aria-label="Dislike this video" aria-pressed="false">
<svg data-fixture-icon="dislike" aria-hidden="true" viewBox="0 0 24 24"> <svg data-fixture-icon="dislike" aria-hidden="true" viewBox="0 0 24 24">
<path fill="currentColor" d="M16 3h3v12h-3V3Zm-2 0v12l-4 7-2-1v-5H3v-5l3-8h8Z"></path> <path fill="currentColor" d="M16 3h3v12h-3V3Zm-2 0v12l-4 7-2-1v-5H3v-5l3-8h8Z"></path>
</svg> </svg>
<span id="text" class="button-renderer-text" role="text"></span> __DISLIKE_TEXT_MARKUP__
</button> </button>
</dislike-button-view-model> </dislike-button-view-model>
`; `;
} }
function applyNativeState(state) { function applyNativeState(state) {
const like = host.querySelector('[data-ryd-role="like"]'); const like = host.querySelector('[__FIXTURE_ROLE_ATTRIBUTE__="like"]');
const dislike = host.querySelector('[data-ryd-role="dislike"]'); const dislike = host.querySelector('[__FIXTURE_ROLE_ATTRIBUTE__="dislike"]');
if (!like || !dislike) return; if (!like || !dislike) return;
like.classList.toggle("style-default-active", state === "liked"); like.classList.toggle("style-default-active", state === "liked");
@@ -203,6 +275,17 @@
host.dataset.nativeState = state; host.dataset.nativeState = state;
} }
function applyNativeLikeDelta(delta) {
const button = host.querySelector('[__FIXTURE_ROLE_ATTRIBUTE__="like"] button');
const text = button?.querySelector("#text, [role='text']");
if (!button || !text) return;
const current = Number(text.textContent.replace(/[^\d]/g, "")) || 0;
const next = Math.max(0, current + delta);
text.textContent = String(next);
button.setAttribute("aria-label", `${next} likes`);
}
function insertButtons(state = "neutral") { function insertButtons(state = "neutral") {
host.innerHTML = isMobile ? mobileButtonMarkup() : desktopButtonMarkup(); host.innerHTML = isMobile ? mobileButtonMarkup() : desktopButtonMarkup();
applyNativeState(state); applyNativeState(state);
@@ -213,6 +296,28 @@
host.dataset.nativeState = "neutral"; host.dataset.nativeState = "neutral";
} }
function installCommentActionSurfaces() {
const comments = document.getElementById("fixture-comment-action-surfaces");
for (let index = 0; index < 20; index += 1) {
const duplicate = document.createElement("div");
duplicate.id = "top-level-buttons-computed";
duplicate.setAttribute("data-fixture-comment-action-surface", String(index));
duplicate.innerHTML = `
<like-button-view-model data-fixture-comment-like>
<button type="button" aria-label="Like comment" aria-pressed="false">
<span role="text">1</span>
</button>
</like-button-view-model>
<dislike-button-view-model data-fixture-comment-dislike>
<button type="button" aria-label="Dislike comment" aria-pressed="false">
<span data-fixture-touch-feedback aria-hidden="true"></span>
</button>
</dislike-button-view-model>
`;
comments.appendChild(duplicate);
}
}
function navigate(videoId, { state = "neutral", buttons = true } = {}) { function navigate(videoId, { state = "neutral", buttons = true } = {}) {
history.pushState({}, "", `/watch?v=${videoId}`); history.pushState({}, "", `/watch?v=${videoId}`);
watch.setAttribute("video-id", videoId); watch.setAttribute("video-id", videoId);
@@ -225,11 +330,22 @@
host.addEventListener( host.addEventListener(
"click", "click",
(event) => { (event) => {
const like = event.target.closest('[data-ryd-role="like"]'); const like = event.target.closest('[__FIXTURE_ROLE_ATTRIBUTE__="like"]');
const dislike = event.target.closest('[data-ryd-role="dislike"]'); const dislike = event.target.closest('[__FIXTURE_ROLE_ATTRIBUTE__="dislike"]');
const current = host.dataset.nativeState || "neutral"; const current = host.dataset.nativeState || "neutral";
if (like) applyNativeState(current === "liked" ? "neutral" : "liked"); const next = like
if (dislike) applyNativeState(current === "disliked" ? "neutral" : "disliked"); ? current === "liked"
? "neutral"
: "liked"
: dislike
? current === "disliked"
? "neutral"
: "disliked"
: current;
if (next !== current) {
applyNativeLikeDelta(Number(next === "liked") - Number(current === "liked"));
applyNativeState(next);
}
}, },
true, true,
); );
@@ -243,7 +359,11 @@
}; };
setSignedIn(__SIGNED_IN__); setSignedIn(__SIGNED_IN__);
installCommentActionSurfaces();
if (__INITIAL_BUTTONS__) insertButtons("__INITIAL_STATE__"); if (__INITIAL_BUTTONS__) insertButtons("__INITIAL_STATE__");
window.__youtubeFixture.initialDislikeTextContainers = host.querySelectorAll(
'[__FIXTURE_ROLE_ATTRIBUTE__="dislike"] #text, [__FIXTURE_ROLE_ATTRIBUTE__="dislike"] .ytSpecButtonShapeNextButtonTextContent',
).length;
})(); })();
</script> </script>
</body> </body>
+42 -9
View File
@@ -1,10 +1,10 @@
const fs = require("fs"); const fs = require("fs");
const path = require("path"); const path = require("path");
const { isAllowedApiPreflight } = require("../../e2e/hermetic-api-contract");
const { resolveRequestedUserscriptArtifact } = require("../../e2e/verify-userscript-artifact");
const REPOSITORY_ROOT = path.resolve(__dirname, "../../.."); const REPOSITORY_ROOT = path.resolve(__dirname, "../../..");
const GENERATED_USERSCRIPT = const GENERATED_USERSCRIPT = resolveRequestedUserscriptArtifact();
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 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 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 NAVIGATION_FIXTURE = fs.readFileSync(path.join(__dirname, "fixtures", "navigation-page.html"), "utf8");
@@ -45,11 +45,14 @@ function jsonHeaders() {
function createFakeBackend({ countsByVideo = {}, countDelayByVideo = {}, fixture = {} } = {}) { function createFakeBackend({ countsByVideo = {}, countDelayByVideo = {}, fixture = {} } = {}) {
const blockedRequests = []; const blockedRequests = [];
const preflightRequests = [];
const requests = []; const requests = [];
const responsePlans = new Map(); const responsePlans = new Map();
const fixtureOptions = { const fixtureOptions = {
initialButtons: fixture.initialButtons !== false, initialButtons: fixture.initialButtons !== false,
initialState: fixture.initialState || "neutral", initialState: fixture.initialState || "neutral",
nativeDislikeText: fixture.nativeDislikeText !== false,
roleAttribute: fixture.roleAttribute || "data-ryd-role",
signedIn: fixture.signedIn !== false, signedIn: fixture.signedIn !== false,
}; };
@@ -147,6 +150,13 @@ function createFakeBackend({ countsByVideo = {}, countDelayByVideo = {}, fixture
const html = fixtureTemplate const html = fixtureTemplate
.replaceAll("__VIDEO_ID__", videoId) .replaceAll("__VIDEO_ID__", videoId)
.replaceAll("__SECOND_VIDEO_ID__", VIDEO_B) .replaceAll("__SECOND_VIDEO_ID__", VIDEO_B)
.replaceAll("__VIDEO_LIKES__", String(countsByVideo[videoId]?.likes ?? 100))
.replaceAll("__SECOND_VIDEO_LIKES__", String(countsByVideo[VIDEO_B]?.likes ?? 100))
.replaceAll(
"__DISLIKE_TEXT_MARKUP__",
fixtureOptions.nativeDislikeText ? '<span id="text" role="text"></span>' : "",
)
.replaceAll("__FIXTURE_ROLE_ATTRIBUTE__", fixtureOptions.roleAttribute)
.replaceAll("__INITIAL_PAGE_KIND__", isShorts ? "shorts" : url.pathname === "/watch" ? "watch" : "channel") .replaceAll("__INITIAL_PAGE_KIND__", isShorts ? "shorts" : url.pathname === "/watch" ? "watch" : "channel")
.replaceAll( .replaceAll(
"__SHORTS_RENDERER_TAG__", "__SHORTS_RENDERER_TAG__",
@@ -174,6 +184,22 @@ function createFakeBackend({ countsByVideo = {}, countDelayByVideo = {}, fixture
} }
if (request.method() === "OPTIONS") { if (request.method() === "OPTIONS") {
const requestedMethod = request.headers()["access-control-request-method"];
const preflight = {
method: request.method(),
pathname: url.pathname,
requestedMethod: requestedMethod ?? null,
url: url.toString(),
};
if (!isAllowedApiPreflight(url.pathname, requestedMethod)) {
blockedRequests.push({
...preflight,
resourceType: request.resourceType(),
});
await route.abort("blockedbyclient");
return;
}
preflightRequests.push(preflight);
await route.fulfill({ status: 204, headers: jsonHeaders(), body: "" }); await route.fulfill({ status: 204, headers: jsonHeaders(), body: "" });
return; return;
} }
@@ -202,11 +228,14 @@ function createFakeBackend({ countsByVideo = {}, countDelayByVideo = {}, fixture
if (response.delayMs) await delay(response.delayMs); if (response.delayMs) await delay(response.delayMs);
const body = response.body === undefined ? null : response.body; const body = response.body === undefined ? null : response.body;
const responseStatus = response.status || 200;
await route.fulfill({ await route.fulfill({
status: response.status || 200, status: responseStatus,
headers: { ...jsonHeaders(), ...(response.headers || {}) }, headers: { ...jsonHeaders(), ...(response.headers || {}) },
body: typeof body === "string" ? body : JSON.stringify(body), body: typeof body === "string" ? body : JSON.stringify(body),
}); });
record.responseBody = body;
record.responseStatus = responseStatus;
record.respondedAt = Date.now(); record.respondedAt = Date.now();
} }
@@ -215,6 +244,7 @@ function createFakeBackend({ countsByVideo = {}, countDelayByVideo = {}, fixture
defer, defer,
enqueue, enqueue,
handle, handle,
preflightRequests,
requests, requests,
requestsFor, requestsFor,
}; };
@@ -340,17 +370,20 @@ function overrideBooleanOption(source, optionName, value) {
return source.replace(optionPattern, `${optionName}: ${value}`); return source.replace(optionPattern, `${optionName}: ${value}`);
} }
async function injectGeneratedUserscript(page, { coloredThumbs, disableVoteSubmission = false, rateBarEnabled } = {}) { async function injectGeneratedUserscript(
if (!fs.existsSync(GENERATED_USERSCRIPT)) { page,
throw new Error(`Generated userscript is missing: ${GENERATED_USERSCRIPT}`); { artifactPath = GENERATED_USERSCRIPT, coloredThumbs, disableVoteSubmission = false, rateBarEnabled } = {},
) {
if (!fs.existsSync(artifactPath)) {
throw new Error(`Generated userscript is missing: ${artifactPath}`);
} }
if (!disableVoteSubmission && rateBarEnabled === undefined && coloredThumbs === undefined) { if (!disableVoteSubmission && rateBarEnabled === undefined && coloredThumbs === undefined) {
await page.addScriptTag({ path: GENERATED_USERSCRIPT }); await page.addScriptTag({ path: artifactPath });
return; return;
} }
let source = fs.readFileSync(GENERATED_USERSCRIPT, "utf8"); let source = fs.readFileSync(artifactPath, "utf8");
if (disableVoteSubmission) { if (disableVoteSubmission) {
source = overrideBooleanOption(source, "disableVoteSubmission", true); source = overrideBooleanOption(source, "disableVoteSubmission", true);
} }
+185 -61
View File
@@ -1,33 +1,56 @@
# Live YouTube smoke # Live YouTube smoke
This suite attaches over CDP to an already-running Brave profile. It is deliberately separate from `test:all` and CI. This suite attaches over CDP to an already-running Chrome, Brave, or compatible Chromium profile. It is deliberately
separate from `test:all` and CI.
The same page-level checks run in either userscript or extension mode; installation, storage, and protocol failure The same page-level checks run in either userscript or extension mode; installation, storage, and protocol failure
coverage remain in the hermetic suites. coverage remain in the hermetic suites.
## Browser preparation ## Browser preparation
1. Open `brave://inspect/#remote-debugging` in the target Brave profile and enable **Allow remote debugging for this 1. Open the target browser's remote-debugging page (for example, `chrome://inspect/#remote-debugging` or
browser instance**. Set `RYD_CDP_ENDPOINT` to the explicit HTTP or WebSocket endpoint exposed by that Brave instance; `brave://inspect/#remote-debugging`) and enable **Allow remote debugging for this browser instance**. If the page
`http://127.0.0.1:9222` is a common HTTP example, but use the actual port shown by Brave. displays only a server such as `127.0.0.1:60011`, set `RYD_CDP_ENDPOINT` to that exact host and port. Current Chromium
builds can return 404 from `/json/version` in this mode; the launcher reads the full browser WebSocket path from the
matching `DevToolsActivePort` file in the standard Chrome, Brave, Edge, or Chromium user-data root. Explicit
`ws://...` endpoints and legacy `http://127.0.0.1:9222` endpoints remain supported unchanged. Set the endpoint to the
browser alias `chrome`, `brave`, `edge`, or `chromium` to read that browser's standard active-port file directly.
For a custom or portable profile, set either `RYD_CDP_USER_DATA_DIR` to its user-data root or
`RYD_CDP_ACTIVE_PORT_FILE` to the exact file; do not set both overrides. The launcher waits 120 seconds for the
browser's **Allow remote debugging** confirmation. `RYD_CDP_CONNECT_TIMEOUT_MS` can override that wait from 15000 to
300000 milliseconds.
2. Use a dedicated two-item playlist containing two allowlisted public or unlisted test videos. Both must be accessible 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. to the signed-in profile and must have different rendered dislike counts.
3. Enable exactly one runtime: 3. Enable exactly one runtime:
- `userscript`: disable the browser extension and every other Return YouTube Dislike userscript, run - `userscript`: disable the browser extension and every other Return YouTube Dislike userscript. A read-only run
`npm run build:live:userscript`, import requires `npm run build:live:userscript` followed by importing
`test-results/live-build/userscript/Return Youtube Dislike.user.js` into Tampermonkey, and enable it. `test-results/live-build/userscript/Return Youtube Dislike.user.js` into Tampermonkey. A full run performs that
- `extension`: disable every Return YouTube Dislike userscript and any store-installed copy of the extension, run build itself, stops before browser attachment, and requires `INSTALLED` after the freshly generated file has been
`npm run build:live:extension`, then enable or reload only the unpacked extension at reimported.
`Extensions/combined/dist/chrome`. - `extension`: disable every Return YouTube Dislike userscript and any store-installed copy of the extension. A
read-only run requires `npm run build:live:extension` followed by `npm run reload:live:extension`. A full run
performs the fresh build and reload itself. The already-enabled unpacked extension must point at
`Extensions/combined/dist/chrome`. Keep an authenticated ordinary
YouTube page open in the target profile. Before reloading anything, the runner scans every attached browser
context and page, ignores internal and non-YouTube tabs, and verifies the exact public
`RYD_LIVE_EXPECTED_CHANNEL` handle from YouTube's account menu. Exactly one context may match. It reuses that
existing page for the CDP session and the worker's own identity check; it never reads cookies or storage and fails
closed on an absent, unverifiable, or ambiguous profile.
Every live build receives a fresh random build ID even when its semantic version is unchanged. Import or reload the 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. 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 Hard-reload any already-open YouTube tab before ad hoc testing after an unpacked-extension reload. Existing documents
only its own tab. retain their previously injected content script; a rendered count or control in such a tab does not prove that the
newly built bundle is running. The automated suite avoids this ambiguity by creating a fresh tab and checking the
exact live-build marker before every scenario.
4. Keep the browser window open and do not interact with the tab created by the suite. The suite creates, uses, and
closes only its own tab.
Only the explicit live-test builds expose page markers containing their runtime, version, and exact build ID. Normal 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 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 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. absent before every scenario. A stale installed script or extension therefore fails even when it has the same version.
There is no environment override for the build ID. Before connecting to the browser it also verifies a build receipt against every current userscript or extension source
input and against the generated artifact bytes. Editing source after a build, changing generated output, or reusing a
receipt from another build fails closed. There is no environment override for the build ID or receipt.
The read-only smoke still loads real YouTube and allows the installed runtime to read from the production RYD API, 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 including eager registration when the selected runtime has no confirmed identity. It does not mock API responses, but
@@ -41,74 +64,148 @@ start on `RYD_LIVE_WATCH_A`.
```powershell ```powershell
$env:RYD_LIVE_YOUTUBE="1" $env:RYD_LIVE_YOUTUBE="1"
$env:RYD_LIVE_PRODUCTION_API="1" $env:RYD_LIVE_PRODUCTION_API="1"
$env:RYD_CDP_ENDPOINT="http://127.0.0.1:9222" $env:RYD_CDP_ENDPOINT="127.0.0.1:60011" # Copy the current value shown by the browser.
# Optional for a custom or portable profile (choose only one):
# $env:RYD_CDP_USER_DATA_DIR="C:\path\to\User Data"
# $env:RYD_CDP_ACTIVE_PORT_FILE="C:\path\to\User Data\DevToolsActivePort"
# $env:RYD_CDP_CONNECT_TIMEOUT_MS="180000"
$env:RYD_LIVE_RUNTIME="userscript" $env:RYD_LIVE_RUNTIME="userscript"
$env:RYD_LIVE_EXPECTED_CHANNEL="@your-test-channel" $env:RYD_LIVE_EXPECTED_CHANNEL="@your-test-channel"
$env:RYD_LIVE_WATCH_A="AAAAAAAAAAA" $env:RYD_LIVE_WATCH_A="AAAAAAAAAAA"
$env:RYD_LIVE_WATCH_B="BBBBBBBBBBB" $env:RYD_LIVE_WATCH_B="BBBBBBBBBBB"
$env:RYD_LIVE_SHORT="CCCCCCCCCCC" $env:RYD_LIVE_SHORT="CCCCCCCCCCC"
# Optional: use a different allowlisted Short whose exact count visibly changes by one for reaction validation.
# Defaults to RYD_LIVE_SHORT.
# $env:RYD_LIVE_REACTION_SHORT="EEEEEEEEEEE"
$env:RYD_LIVE_PLAYLIST_URL="https://www.youtube.com/watch?v=AAAAAAAAAAA&list=PLAYLIST_ID" $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: # 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_CHANNEL_URL="https://www.youtube.com/@MrBeast"
$env:RYD_LIVE_NAV_SHORT="iKQhN7omLM4" $env:RYD_LIVE_NAV_SHORT="5mU6SRS2Bxo"
$env:RYD_LIVE_NAV_WATCH="Qtl8lJwbd4g" # Exact visible Watch link on the configured channel page.
# Optional; the Shorts stress targets 10 successful Next samples and accepts 10 through 25.
$env:RYD_LIVE_SHORTS_NEXT_HOPS="10"
# Optional; the consecutive watch-sidebar stress defaults to three hops and accepts 1 through 10. # Optional; the consecutive watch-sidebar stress defaults to three hops and accepts 1 through 10.
$env:RYD_LIVE_SIDEBAR_HOPS="3" $env:RYD_LIVE_SIDEBAR_HOPS="3"
npm run test:live:youtube npm run test:live:youtube:read-only
``` ```
The navigation smoke performs a real hard load and reload of `RYD_LIVE_NAV_CHANNEL_URL`, finds an exact visible link to The default channel fixture was last verified in authenticated Chrome on September 4, 2026. Its exact first Short was
`RYD_LIVE_NAV_SHORT`, and clicks that link. It fails rather than falling back to direct navigation when the configured visible on the channel shelf, and ten consecutive visible **Next video** controls reached ten distinct Shorts. Reverify
channel no longer contains the exact card. It proves the channel-to-Short transition reused the same document, verifies the exact channel cards before a later release run because YouTube channel ordering changes over time.
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 The navigation smoke derives the channel's `/shorts` and `/videos` tabs from `RYD_LIVE_NAV_CHANNEL_URL`, performs a
driver waits five seconds for that first click, prints `LIVE_CHECKPOINT shorts-next-control.retrying`, and makes exactly real hard load and reload of the relevant tab, finds the configured exact visible target, and activates that link. If
one more trusted click with the remaining 25-second navigation budget. If the URL still does not advance, the scenario YouTube ignores the first hit-tested pointer activation, the driver re-resolves the same exact target and makes one
fails; it never loops or clicks a reaction control as part of this retry. hit-tested keyboard activation attempt. It fails rather than falling back to direct navigation when the relevant
channel tab no longer contains the exact card. It proves the channel-to-Short transition reused the same document, verifies
that the selected runtime initialized the current Short, then keeps pressing YouTube's visible **Next video** control
until it has audited at least 10 successful, distinct samples. `RYD_LIVE_SHORTS_NEXT_HOPS` is the successful-sample
target, not the raw number of presses; the runner makes at most twice that many Next attempts. The initial transition
and every attempt baseline the driver's request IDs before navigation and reject any stale `/votes` request. A valid
sample must have at least one visible native YouTube action, an HTTP 200 response whose JSON `id` equals the exact
current 11-character video ID, and the authoritative dislike count rendered in one of the runtime's supported localized
number formats. Every valid Next sample must emit exactly one request. During the initial transition, the extension may
instead emit the source-supported sequence of one aggregate request followed by one request enriched with YouTube's
newly available numeric `likeCount`; the second response is authoritative. A repeated aggregate request, repeated
enrichment, reversed order, third request, or invalid `likeCount` still fails as duplicate traffic. Every attempt must
also keep the same document and produce a video ID that has not appeared earlier in the sequence. The current
synthetic dislike control must have no visible stale or duplicate controls and must preserve its count and ownership
through a bounded two-second stability soak. The suite also rechecks the runtime build marker and the complete Shorts
action-stack geometry after every hop. Page-owned requests must belong to the suite tab. Frame-less requests are
accepted only from the selected extension's `ryd.background.js` service worker, and the hop fails as ambiguous if
another browser tab has the destination video open. The channel-to-Short check runs first in the fresh suite tab so
earlier watch or Shorts visits cannot warm the page lifecycle it is
meant to exercise. Cropped evidence for the initial Short and every hop is written under
`test-results/live-youtube/shorts-navigation/` for both runtimes.
After the Shorts URL advances, the driver deliberately leaves playback running while YouTube hydrates the new reel, YouTube itself sometimes leaves a selected reel with no visible native action controls. The runner samples the whole
the production `/votes` response arrives, and the current dislike control renders. It pauses only after those checks, visible Shorts rail across a bounded 20-second observation window. Each synchronous DOM sample also returns the exact
so a newly selected Short may play briefly before the `LIVE_CHECKPOINT playback.paused` message appears. current reel's visible video box and viewport, which the runner uses to periodically pulse the real browser pointer so
an idle auto-hidden rail gets a fair chance to appear. It does not walk retained video locators or wait for animation
frames in this polling path. Every DOM probe and pointer operation has its own two-second watchdog; a stalled browser
operation invalidates and fails the stage instead of producing blank evidence. Native buttons are evaluated individually
instead of requiring their whole action-bar host to fit in the viewport. Before a sample is finally recorded as blank,
the runner captures its evidence, waits two animation frames, and remeasures once; a rail revealed at that boundary
follows the normal strict runtime-control assertions. Only a sample with zero visible native actions for that complete window is recorded
as `shorts-sample.skipped`, including its video ID, observation duration, reason, attempt number, and whether any
`/votes` request was seen. Any request seen during a blank sample is still required to target that exact current ID and
return a valid successful response; stale traffic is never forgiven. If even one meaningful native action is visible,
a missing runtime Dislike or missing `/votes` request remains a hard failure. The run passes only after reaching the
configured successful-sample target and only when valid samples strictly outnumber blank YouTube samples.
YouTube can occasionally focus the visible **Next video** button without acting on its first trusted click. On each
attempt, the live driver waits five seconds for that first click, prints
`LIVE_CHECKPOINT shorts-next-control.retrying`, and makes exactly one more trusted click with the remaining 25-second
navigation budget. If the URL still does not advance, the scenario fails; it never loops indefinitely or clicks a
reaction control as part of this retry.
After each Shorts URL advances, the driver deliberately leaves playback running while YouTube hydrates the new reel,
the production `/votes` response arrives, and the current dislike control and geometry render. It pauses only after all
configured successful samples pass, so newly selected Shorts may play briefly before the `LIVE_CHECKPOINT
playback.paused` message appears.
The watch-sidebar stress starts with the allowlisted `RYD_LIVE_WATCH_A`, then takes the first eligible visible 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 `#related` watch link on each page for the configured number of consecutive SPA hops. Previously visited IDs are
skipped. Every hop waits for the exact production `/votes?videoId=<target>` response and a rendered dislike count, skipped. Every hop applies the same new-request baseline and exact request/HTTP/body-ID oracle, compares the rendered
dislike count with that response,
requires exactly one visible ratio bar for the selected runtime with valid reaction-control geometry, and samples that 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 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 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 `test-results/live-youtube/sidebar-stress/{runtime}-sidebar-hop-{01..N}.png`. The same scenario runs in userscript and
extension mode. extension mode.
The direct Watch, reload, playlist SPA, and cold channel-to-Watch scenarios use that same exact network oracle.
The userscript must issue one request. The extension may issue either one request or the single ordered aggregate-to-
`likeCount` refinement described above, matching the intentional richer-refresh behavior in its shared count client.
After each response they require one current-video ratio bar and fill, reject a visible stale or duplicate bar, compare
the rendered localized count with the final authoritative API response, and repeatedly sample the current video and
unchanged result for a bounded stability interval.
Every non-voting live scenario installs a BrowserContext deny route before it starts and aborts every production 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 `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; 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. 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. 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, This production smoke intentionally exercises at least 10 valid Shorts **Next video** transitions; fully blank native
recommendation queues, ads, and account experiments are not stable enough to make an exact production transition a YouTube rails do not reduce that total. Live autoplay timing, recommendation queues, ads, and account experiments are
reliable gate. The existing two-item playlist smoke continues to cover an explicit watch-page SPA transition. 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 The default channel dataset can drift as its public page changes. Override the channel URL and both target IDs with a
that are deliberately kept together. The channel URL is restricted to a plain HTTPS `youtube.com/@handle` page or its channel whose `/shorts` and `/videos` tabs deliberately retain those exact cards. The channel URL is restricted to a
`featured`, `shorts`, or `videos` tab; the target must be an 11-character video ID. plain HTTPS `youtube.com/@handle` page or its `featured`, `shorts`, or `videos` tab; either way the driver selects the
kind-specific tab. Each target must be an 11-character video ID.
An additional cold channel-to-watch scenario is available only when its exact link is deterministic on the configured The cold channel-to-Watch scenario is required. `RYD_LIVE_NAV_WATCH` must identify an exact visible Watch link on the
channel page. Opt in with an 11-character ID; otherwise that scenario is skipped: configured channel page; an absent or stale target fails the run and cannot count as completed coverage.
`npm run test:live:youtube:read-only` is the deliberately non-voting command. Its completion record is classified as
`read-only` with `releaseReady: false`; it cannot be reported as full validation. For a full run, use
`npm run test:live:youtube` (or its `:full` alias). It builds the selected runtime first, runs every read-only scenario,
then waits for the production-reaction approval described below. Entering `SKIP` fails the command as incomplete.
To keep one approved browser-debugging connection alive across complete retries, opt into the persistent interactive
session before starting the full runner:
```powershell ```powershell
$env:RYD_LIVE_NAV_WATCH="DDDDDDDDDDD" $env:RYD_LIVE_KEEP_CDP_SESSION="1"
npm run test:live:youtube
``` ```
For the interactive Brave run, use `npm run test:live:youtube:interactive`. Before requesting any reaction approval, After each passed or failed attempt it archives the current live evidence under
it runs the read-only scenarios plus responsive visual checks. Enter `SKIP` at the prompt to finish without reactions. `test-results/live-youtube/attempts/`, prints `READY_FOR_LIVE_RERUN_OR_EXIT`, and waits for exactly `RERUN` or `EXIT`.
The visual pass checks the watch-page ratio bar at widths 1280, 768, and 390. In userscript mode it also checks the `RERUN` rebuilds the selected runtime, reloads the unpacked extension when applicable, and executes the complete
modern Shorts synthetic dislike control, its geometry, and its rendered count at those widths. In extension mode it read-only and reaction flow again through the same top-level browser connection instead of making a new attachment.
checks the active reel's native Like/Dislike pair at all three widths: exact action-host, button, and icon sizes; Each rerun still requires a fresh reaction approval token, and a userscript rerun still requires a fresh install
typography; spacing; common reel ownership; ordering; and viewport containment. Cropped evidence images are written acknowledgement. The endpoint, authenticated handle, and runtime cannot change while reusing the connection. `EXIT`
closes the connection and returns the latest attempt's status. Leaving
`RYD_LIVE_KEEP_CDP_SESSION` unset preserves the normal single-attempt behavior.
The visual pass checks the watch-page ratio bar at widths 1280, 768, and 390. For both runtimes it also checks the
modern Shorts synthetic dislike control, its geometry, its rendered count, and its position beside the active reel's
native Like control at those widths: exact action-host, button, and icon sizes; typography; spacing; common reel
ownership; ordering; and viewport containment. Cropped evidence images are written
under `test-results/live-youtube/responsive/`. If YouTube's native Like/Dislike pill is horizontally clipped by its own 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 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. by the RYD bar still fails.
@@ -117,10 +214,23 @@ The interactive runner prints `LIVE_STAGE_START`, `LIVE_STAGE_COMPLETE`, and `LI
In particular, `LIVE_CHECKPOINT playback.paused` means the runner deliberately paused the current YouTube video while 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, 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 account, runtime, control, and production `/votes` waits have their own checkpoints, so the last line identifies the
pending operation. pending operation. Every read-only stage also reports when it starts and completes removal of its production-interaction
deny route. That cleanup and the final test-tab close each have a ten-second watchdog: a stalled Chrome target fails the
attempt and returns control to the persistent runner instead of waiting indefinitely. A cleanup watchdog failure never
permits the runner to continue into production reactions.
If a scenario fails, the runner records page errors, unhandled promise rejections, console errors, and the latest RYD Both live runners treat a page error, unhandled promise rejection, unexpected `console.error`, or error from the
API request outcomes before closing its test tab. It writes a JSON snapshot under selected extension's service worker as a test failure even when the DOM and network assertions otherwise pass. A
benign message can be ignored only by adding an explicit rule that matches its exact signal type, full message, and
full source URL; there are no wildcard or callback suppressions. The Playwright runner uses a fresh page, driver, and
diagnostic guard for each scenario and one worker, so one failed read-only scenario does not suppress the remaining
independent scenarios. Its production-reaction test is gated on every required read-only scenario completing in the
same worker; a failure-driven worker restart therefore cannot accidentally carry a green gate forward. The interactive
runner also completes all independent read-only stages, then returns an aggregate failure and never reaches reaction
approval if any failed.
If a scenario fails, the runner records those browser signals and the latest RYD API request outcomes before closing
its test tab. It writes a JSON snapshot under
`test-results/live-youtube/diagnostics/` and prints its absolute path as `LIVE_FAILURE_SNAPSHOT`. The snapshot includes `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, 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 and recent API paths/statuses. Anonymous identity and proof-related query values are redacted. Preserve this file when
@@ -130,13 +240,21 @@ Use `RYD_LIVE_RUNTIME="extension"` after manually switching the enabled runtime
extension. The expected version defaults to the local userscript candidate version or root package version; set 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. `RYD_LIVE_EXPECTED_VERSION` only when deliberately validating another installed build.
## Optional production reaction matrix ## Full production reaction validation
The reaction test is skipped by default. It covers all six Like/Dislike state transitions on `RYD_LIVE_WATCH_B` and the The full command cannot succeed without this phase. Its first check opens `RYD_LIVE_WATCH_A`, clicks the exact
allowlisted Short, asserts one logical production handshake for every transition, and returns each video to its initial `RYD_LIVE_WATCH_B` playlist link, proves that YouTube kept the same document, and only then clicks Dislike on B. It
records before navigation and fails if navigation itself emits any `/interact/*` request. The reaction and its cleanup
must each send one logical handshake for B's exact video ID, and the cleanup must restore B's initial YouTube reaction
state. This is the production stale-video-ID gate shared by the userscript and extension.
The same approved reaction phase then covers all six Like/Dislike state transitions on `RYD_LIVE_WATCH_B` and the
allowlisted `RYD_LIVE_REACTION_SHORT` (defaulting to `RYD_LIVE_SHORT`), asserts one logical production handshake for
every transition, and returns each video to its initial
reaction state. A handshake contains one to three matching `/interact/vote` puzzle requests followed by exactly one 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, matching successful `/interact/confirmVote`. Every request must receive a 2xx response; confirmation must return the
or other interaction traffic fails the run. It does not retry cleanup blindly if the state cannot be verified. literal JSON value `true`. Any fourth vote request, changed identity/video/value, extra confirmation, or other
interaction traffic fails the run. It does not retry cleanup blindly if the state cannot be verified.
If click dispatch, the post-click state wait, or a handshake fails after a vote attempt while the UI already appears to 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 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 the same anonymous identity. A failed cleanup confirmation or identity mismatch reports the exact video URL for manual
@@ -155,14 +273,20 @@ The exact live-build marker proves that the freshly generated test build is pres
absent. It cannot detect an additional normal build of the same runtime, so the manual single-runtime preparation above absent. It cannot detect an additional normal build of the same runtime, so the manual single-runtime preparation above
is mandatory. is mandatory.
Only after approving those real YouTube and production RYD writes, create a runtime-and-video-specific token immediately Only after the full runner prints `READY_FOR_REACTION_APPROVAL`, create a runtime-and-video-specific token in a second
before the run. It expires after two minutes and is consumed once locally, so the userscript token cannot silently PowerShell window and paste it into the waiting runner. It expires after two minutes and is consumed once locally at the
first post-navigation reaction. That
single approval gates the contiguous post-navigation check and reaction matrix, so the userscript token cannot silently
authorize a later extension run. authorize a later extension run.
```powershell ```powershell
$env:RYD_LIVE_VOTES="$env:RYD_LIVE_RUNTIME`:$env:RYD_LIVE_WATCH_B`:$([DateTimeOffset]::UtcNow.ToUnixTimeSeconds())" $runtime="extension" # Or "userscript", matching the waiting run.
npm run test:live:youtube $watchB="BBBBBBBBBBB"
Remove-Item Env:RYD_LIVE_VOTES "$runtime`:$watchB`:$([DateTimeOffset]::UtcNow.ToUnixTimeSeconds())" | Set-Clipboard
``` ```
After the live run, turn off **Allow remote debugging for this browser instance** or restart the test Brave profile. Paste the copied value into the waiting full-run terminal. The command exits successfully only after both
`post-navigation-vote` and `reaction-matrix` complete, then emits `LIVE_VALIDATION_COMPLETE` with
`productionReactionsCompleted: true` and `releaseReady: true`.
After the live run, turn off **Allow remote debugging for this browser instance** or restart the test browser profile.
@@ -0,0 +1,43 @@
const path = require("node:path");
const {
verifyBuildReceipt,
verifyMirroredJavaScript,
verifyProductionJavaScript,
verifyStandaloneMv3Artifact,
} = require("../../../e2e/verify-extension-artifact");
const { verifyGeneratedUserscriptArtifact } = require("../../../e2e/verify-userscript-artifact");
const REPOSITORY_ROOT = path.resolve(__dirname, "../../../..");
const EXTENSION_DIST_ROOT = path.join(REPOSITORY_ROOT, "Extensions", "combined", "dist");
const EXTENSION_CHROME_ARTIFACT = path.join(EXTENSION_DIST_ROOT, "chrome");
function assertCurrentLiveArtifact(
runtime,
expectedBuildId,
{
verifyExtensionBuildReceipt = () => verifyBuildReceipt(EXTENSION_DIST_ROOT, REPOSITORY_ROOT),
verifyExtensionJavaScript = () => {
verifyMirroredJavaScript(EXTENSION_DIST_ROOT);
verifyProductionJavaScript(EXTENSION_DIST_ROOT);
return verifyStandaloneMv3Artifact(EXTENSION_CHROME_ARTIFACT);
},
verifyUserscript = verifyGeneratedUserscriptArtifact,
} = {},
) {
if (runtime === "extension") {
const receipt = verifyExtensionBuildReceipt();
const artifact = verifyExtensionJavaScript();
return { artifact, buildId: expectedBuildId, receipt, runtime };
}
if (runtime === "userscript") {
const receipt = verifyUserscript({ expectedBuildId, liveTestBuild: true });
return { buildId: expectedBuildId, receipt, runtime };
}
throw new TypeError(`Unsupported live artifact runtime: ${runtime}`);
}
module.exports = {
EXTENSION_CHROME_ARTIFACT,
EXTENSION_DIST_ROOT,
assertCurrentLiveArtifact,
};
@@ -0,0 +1,357 @@
const DEFAULT_ACCOUNT_PROBE_TIMEOUT_MS = 8_000;
const DEFAULT_ACCOUNT_SELECTION_ATTEMPTS = 2;
const DEFAULT_ACCOUNT_SELECTION_RETRY_DELAY_MS = 250;
const YOUTUBE_HOSTNAMES = new Set(["youtube.com", "www.youtube.com", "m.youtube.com"]);
function normalizeChannelHandle(value) {
if (typeof value !== "string" || !/^@[A-Za-z0-9._-]{3,100}$/.test(value.trim())) {
throw new Error("An exact public YouTube @handle is required to select the live browser profile.");
}
return value.trim().toLowerCase();
}
function isCommittedYoutubePage(page) {
if (!page || typeof page.url !== "function" || page.isClosed?.()) return false;
try {
const url = new URL(page.url());
return (
url.protocol === "https:" &&
YOUTUBE_HOSTNAMES.has(url.hostname.toLowerCase()) &&
!url.port &&
!url.username &&
!url.password
);
} catch {
return false;
}
}
function inspectYoutubeSessionDocument() {
const allowedHostnames = ["youtube.com", "www.youtube.com", "m.youtube.com"];
const configuredLoggedIn = (() => {
try {
const value = globalThis.ytcfg?.get?.("LOGGED_IN") ?? globalThis.ytcfg?.data_?.LOGGED_IN;
return typeof value === "boolean" ? value : null;
} catch {
return null;
}
})();
return {
committed:
Boolean(document.documentElement) &&
location.protocol === "https:" &&
allowedHostnames.includes(location.hostname.toLowerCase()) &&
!location.port &&
!location.username &&
!location.password,
configuredLoggedIn,
};
}
function inspectVisibleAccountMenu(expectedHandle) {
const allowedHostnames = ["youtube.com", "www.youtube.com", "m.youtube.com"];
const normalizedExpectedHandle = expectedHandle.toLowerCase();
const isVisible = (element) => {
const rectangle = element.getBoundingClientRect();
const style = getComputedStyle(element);
return rectangle.width > 0 && rectangle.height > 0 && style.display !== "none" && style.visibility !== "hidden";
};
const menuSelectors = [
"ytd-multi-page-menu-renderer",
"ytm-multi-page-menu-renderer",
"tp-yt-iron-dropdown ytd-active-account-header-renderer",
];
const menus = [...document.querySelectorAll(menuSelectors.join(","))].filter(isVisible);
let foundHandle = false;
let matchedExpectedHandle = false;
for (const menu of menus) {
for (const link of menu.querySelectorAll("a[href]")) {
if (!isVisible(link)) continue;
try {
const url = new URL(link.getAttribute("href"), location.origin);
if (url.protocol !== "https:" || !allowedHostnames.includes(url.hostname.toLowerCase())) continue;
const path = decodeURIComponent(url.pathname).replace(/\/$/, "").toLowerCase();
if (!/^\/@[a-z0-9._-]{3,100}$/.test(path)) continue;
foundHandle = true;
if (path === `/${normalizedExpectedHandle}`) matchedExpectedHandle = true;
} catch {}
}
}
if (!matchedExpectedHandle) {
for (const menu of menus) {
for (const header of menu.querySelectorAll(
"ytd-active-account-header-renderer, ytm-active-account-header-renderer",
)) {
if (!isVisible(header)) continue;
const identityElements = [header, ...header.querySelectorAll("#channel-handle, yt-formatted-string, span")];
const tokens = identityElements
.flatMap((element) => (element.textContent ?? "").split(/\s+/))
.map((token) => token.trim().replace(/\/$/, "").toLowerCase())
.filter((token) => /^@[a-z0-9._-]{3,100}$/.test(token));
if (tokens.length > 0) foundHandle = true;
if (tokens.includes(normalizedExpectedHandle)) matchedExpectedHandle = true;
}
}
}
if (matchedExpectedHandle || foundHandle) {
return { matchedExpectedHandle, ready: true };
}
return null;
}
async function firstVisibleAvatar(page) {
const avatars = page.locator("#avatar-btn");
const count = await avatars.count();
for (let index = 0; index < count; index += 1) {
const avatar = avatars.nth(index);
if (await avatar.isVisible()) return avatar;
}
return null;
}
async function readVisibleAccountMenu(page, expectedHandle) {
return page.evaluate(inspectVisibleAccountMenu, expectedHandle);
}
async function waitForVisibleAccountMenu(page, expectedHandle, timeoutMilliseconds) {
const handle = await page.waitForFunction(inspectVisibleAccountMenu, expectedHandle, {
polling: 100,
timeout: timeoutMilliseconds,
});
try {
return await handle.jsonValue();
} finally {
// The result has already been serialized. A concurrent navigation can make
// disposal fail, but must not erase a conclusive account-menu result.
await handle.dispose?.().catch(() => {});
}
}
async function verifyAuthenticatedYoutubePage(
page,
expectedHandle,
{ accountProbeTimeoutMilliseconds = DEFAULT_ACCOUNT_PROBE_TIMEOUT_MS } = {},
) {
const normalizedExpectedHandle = normalizeChannelHandle(expectedHandle);
if (!isCommittedYoutubePage(page)) return false;
// Attached background tabs can be heavily throttled. Bringing only the page
// currently being verified forward makes the account-menu hydration behave
// like an ordinary user interaction without navigating or changing profile
// state.
await page.bringToFront?.();
if (!isCommittedYoutubePage(page)) return false;
const documentState = await page.evaluate(inspectYoutubeSessionDocument);
if (documentState?.committed !== true || documentState.configuredLoggedIn === false) return false;
const existingMenu = await readVisibleAccountMenu(page, normalizedExpectedHandle);
if (existingMenu?.ready) return existingMenu.matchedExpectedHandle === true;
const avatar = await firstVisibleAvatar(page);
if (!avatar) return false;
let openedMenu = false;
try {
await avatar.click({ timeout: accountProbeTimeoutMilliseconds });
openedMenu = true;
const menu = await waitForVisibleAccountMenu(page, normalizedExpectedHandle, accountProbeTimeoutMilliseconds);
return menu?.matchedExpectedHandle === true;
} finally {
if (openedMenu) await page.keyboard.press("Escape").catch(() => {});
}
}
function classifyAccountProbeFailure(error) {
if (error?.name === "TimeoutError") return "timeout";
if (/target (?:page|context|browser).*closed|page has been closed/i.test(error?.message ?? "")) {
return "page-closed";
}
if (
/execution context was destroyed|cannot find context|most likely because of a navigation/i.test(
error?.message ?? "",
)
) {
return "navigation-race";
}
return "probe-error";
}
function wait(milliseconds) {
return new Promise((resolve) => setTimeout(resolve, milliseconds));
}
async function verifyCandidateYoutubePage(
page,
expectedHandle,
verifyPage,
{ accountProbeTimeoutMilliseconds = DEFAULT_ACCOUNT_PROBE_TIMEOUT_MS } = {},
) {
try {
return {
matched: Boolean(
await verifyPage(page, expectedHandle, {
accountProbeTimeoutMilliseconds,
}),
),
resolved: true,
};
} catch (error) {
// A page which closed or left YouTube while it was being inspected no
// longer represents a candidate profile. Do not let that stale tab poison
// the result, but keep every still-committed candidate fail-closed.
if (!isCommittedYoutubePage(page)) return { matched: false, resolved: true };
return {
failureKind: classifyAccountProbeFailure(error),
matched: false,
resolved: false,
};
}
}
async function scanAuthenticatedYoutubeContexts(browser, normalizedExpectedHandle, options) {
const contexts = browser.contexts();
if (!Array.isArray(contexts) || contexts.length === 0) {
throw new Error("The attached Chromium browser has no browser contexts.");
}
const matches = [];
const unresolvedContextIndexes = new Set();
const unresolvedCandidates = [];
let youtubePageCount = 0;
for (let contextIndex = 0; contextIndex < contexts.length; contextIndex += 1) {
const context = contexts[contextIndex];
const pages = typeof context?.pages === "function" ? context.pages() : null;
if (!Array.isArray(pages)) {
throw new Error(`Attached Chromium context ${contextIndex + 1} returned a malformed page inventory.`);
}
const contextMatches = [];
for (let pageIndex = 0; pageIndex < pages.length; pageIndex += 1) {
const sessionPage = pages[pageIndex];
if (!isCommittedYoutubePage(sessionPage)) continue;
youtubePageCount += 1;
const verification = await verifyCandidateYoutubePage(sessionPage, normalizedExpectedHandle, options.verifyPage, {
accountProbeTimeoutMilliseconds: options.accountProbeTimeoutMilliseconds,
});
if (verification.matched) {
contextMatches.push({ context, contextIndex, pageIndex, sessionPage });
// Authentication belongs to the BrowserContext/profile, not to an
// individual tab. One exact match resolves this profile; probing more
// tabs would only add UI churn and transient failure opportunities.
break;
}
if (!verification.resolved) {
unresolvedContextIndexes.add(contextIndex);
unresolvedCandidates.push({ contextIndex, failureKind: verification.failureKind, pageIndex });
}
}
if (contextMatches.length > 0) {
unresolvedContextIndexes.delete(contextIndex);
matches.push(contextMatches[0]);
}
}
return { matches, unresolvedCandidates, unresolvedContextIndexes, youtubePageCount };
}
function isSelectedSessionPageCurrent(selection) {
if (!isCommittedYoutubePage(selection.sessionPage)) return false;
try {
const pages = selection.context.pages();
return Array.isArray(pages) && pages.includes(selection.sessionPage);
} catch {
return false;
}
}
async function selectAuthenticatedYoutubeContext(
browser,
expectedHandle,
{
accountSelectionAttempts = DEFAULT_ACCOUNT_SELECTION_ATTEMPTS,
accountSelectionRetryDelayMilliseconds = DEFAULT_ACCOUNT_SELECTION_RETRY_DELAY_MS,
accountProbeTimeoutMilliseconds = DEFAULT_ACCOUNT_PROBE_TIMEOUT_MS,
verifyPage = verifyAuthenticatedYoutubePage,
waitForRetry = wait,
} = {},
) {
const normalizedExpectedHandle = normalizeChannelHandle(expectedHandle);
if (!browser || typeof browser.contexts !== "function") {
throw new TypeError("An attached Chromium browser is required to select the authenticated profile.");
}
if (!Number.isSafeInteger(accountSelectionAttempts) || accountSelectionAttempts < 1) {
throw new TypeError("The authenticated Chromium selection attempt count must be a positive integer.");
}
let scan;
for (let attempt = 1; attempt <= accountSelectionAttempts; attempt += 1) {
scan = await scanAuthenticatedYoutubeContexts(browser, normalizedExpectedHandle, {
accountProbeTimeoutMilliseconds,
verifyPage,
});
if (scan.matches.length > 1) {
throw new Error(
`More than one attached Chromium context has a signed-in YouTube page for ${normalizedExpectedHandle}; refusing to choose a profile.`,
);
}
if (scan.matches.length === 1 && scan.unresolvedContextIndexes.size === 0) {
const selection = scan.matches[0];
if (isSelectedSessionPageCurrent(selection)) return selection;
scan.unresolvedContextIndexes.add(selection.contextIndex);
scan.unresolvedCandidates.push({
contextIndex: selection.contextIndex,
failureKind: "selected-page-stale",
pageIndex: selection.pageIndex,
});
}
if (attempt < accountSelectionAttempts) await waitForRetry(accountSelectionRetryDelayMilliseconds);
}
if (scan.unresolvedContextIndexes.size > 0) {
const diagnostic = scan.unresolvedCandidates
.filter(({ contextIndex }) => scan.unresolvedContextIndexes.has(contextIndex))
.map(
({ contextIndex, failureKind, pageIndex }) =>
`context ${contextIndex + 1} page ${pageIndex + 1} (${failureKind})`,
)
.join(", ");
throw new Error(
`Could not safely verify every candidate Chromium profile for ${normalizedExpectedHandle}; no extension was changed. Unresolved probes: ${diagnostic}.`,
);
}
if (scan.matches.length === 0) {
if (scan.youtubePageCount === 0) {
throw new Error(
`No pre-existing committed HTTPS YouTube page was found for ${normalizedExpectedHandle}; no extension was changed.`,
);
}
throw new Error(
`No attached Chromium context has a pre-existing signed-in YouTube page for ${normalizedExpectedHandle}; no extension was changed.`,
);
}
throw new Error(`The authenticated Chromium profile for ${normalizedExpectedHandle} became unavailable.`);
}
module.exports = {
DEFAULT_ACCOUNT_SELECTION_ATTEMPTS,
DEFAULT_ACCOUNT_SELECTION_RETRY_DELAY_MS,
DEFAULT_ACCOUNT_PROBE_TIMEOUT_MS,
classifyAccountProbeFailure,
inspectVisibleAccountMenu,
inspectYoutubeSessionDocument,
isCommittedYoutubePage,
normalizeChannelHandle,
scanAuthenticatedYoutubeContexts,
selectAuthenticatedYoutubeContext,
verifyCandidateYoutubePage,
verifyAuthenticatedYoutubePage,
};
@@ -5,15 +5,168 @@ const API_ORIGIN = "https://returnyoutubedislikeapi.com";
const UNHANDLED_REJECTION_PREFIX = "__RYD_LIVE_UNHANDLED_REJECTION__"; const UNHANDLED_REJECTION_PREFIX = "__RYD_LIVE_UNHANDLED_REJECTION__";
const MAX_BROWSER_SIGNALS = 50; const MAX_BROWSER_SIGNALS = 50;
const MAX_API_REQUESTS = 50; const MAX_API_REQUESTS = 50;
const EXTENSION_ID_PATTERN = /^[a-p]{32}$/;
const EXTERNAL_RESOURCE_HOSTS = Object.freeze({
doubleclick: "doubleclick.net",
googlevideo: "googlevideo.com",
});
const EXTERNAL_RESOURCE_FAILURE_PATTERN =
/^Failed to load resource: (?:the server responded with a status of 403(?: \([^)]*\))?|net::ERR_(?:BLOCKED_BY_CLIENT|CONNECTION_CLOSED|CONNECTION_REFUSED|CONNECTION_RESET|FAILED|NAME_NOT_RESOLVED|TIMED_OUT))\.?$/;
const DOUBLECLICK_CORS_PATTERN =
/^Access to (?:fetch|XMLHttpRequest) at ['"]([^'"]+)['"](?: \(redirected from ['"]([^'"]+)['"]\))? from origin ['"](https:\/\/(?:www\.|m\.)?youtube\.com)['"] has been blocked by CORS policy: .+$/s;
const SANDBOXED_ABOUT_BLANK_SCRIPT_MESSAGE =
"Blocked script execution in 'about:blank' because the document's frame is sandboxed and the 'allow-scripts' permission is not set.";
const YOUTUBE_ACCOUNTS_REPORT_ONLY_FRAME_ANCESTORS_MESSAGE =
"Framing 'https://accounts.youtube.com/' violates the following report-only Content Security Policy directive: \"frame-ancestors 'self'\". The violation has been logged, but no further action has been taken.\n";
const YOUTUBE_ROTATE_COOKIES_RATE_LIMIT_PATTERN =
/^Failed to load resource: the server responded with a status of 429(?: \([^)]*\))?\.?$/;
const FATAL_SIGNAL_TYPES = new Set([
"console.error",
"pageerror",
"unhandledrejection",
"extension-service-worker.console.error",
"extension-service-worker.instrumentation-error",
"extension-service-worker.unhandledrejection",
]);
function serializeError(error) { function serializeError(error) {
if (!error) return null; if (!error) return null;
if (typeof error === "string") return { message: error, name: "Error", stack: null }; if (typeof error === "string") return { message: error, name: "Error", stack: null };
return { const serialized = {
message: String(error.message ?? error), message: String(error.message ?? error),
name: String(error.name ?? "Error"), name: String(error.name ?? "Error"),
stack: typeof error.stack === "string" ? error.stack : null, stack: typeof error.stack === "string" ? error.stack : null,
}; };
if (error instanceof AggregateError) serialized.errors = [...error.errors].map(serializeError);
return serialized;
}
function normalizeIgnoredSignalRules(rules = []) {
if (!Array.isArray(rules)) throw new TypeError("ignoredSignalRules must be an array.");
const ids = new Set();
return rules.map((rule) => {
if (!rule || typeof rule !== "object" || Array.isArray(rule)) {
throw new TypeError("Each ignored-signal rule must be an object.");
}
const allowedKeys = new Set(["id", "message", "type", "url"]);
const unknownKeys = Object.keys(rule).filter((key) => !allowedKeys.has(key));
if (unknownKeys.length > 0) {
throw new TypeError(`Ignored-signal rule contains unsupported fields: ${unknownKeys.join(", ")}.`);
}
if (typeof rule.id !== "string" || rule.id.trim() === "") {
throw new TypeError("Each ignored-signal rule requires a non-empty id.");
}
if (ids.has(rule.id)) throw new TypeError(`Ignored-signal rule id ${rule.id} is duplicated.`);
ids.add(rule.id);
if (!FATAL_SIGNAL_TYPES.has(rule.type)) {
throw new TypeError(`Ignored-signal rule ${rule.id} has an unsupported exact type.`);
}
if (typeof rule.message !== "string" || rule.message.length === 0) {
throw new TypeError(`Ignored-signal rule ${rule.id} requires a non-empty exact message.`);
}
if (typeof rule.url !== "string" || rule.url.length === 0) {
throw new TypeError(`Ignored-signal rule ${rule.id} requires a non-empty exact url.`);
}
return Object.freeze({ ...rule });
});
}
function signalMessage(signal) {
return signal.message ?? signal.error?.message ?? "Unknown browser error";
}
function signalUrl(signal) {
return signal.workerUrl ?? signal.location?.url ?? null;
}
function ignoredSignalRuleId(signal, rules) {
const message = signalMessage(signal);
const url = signalUrl(signal);
const rule = rules.find((candidate) => {
if (candidate.type !== signal.type) return false;
return candidate.message === message && candidate.url === url;
});
return rule?.id ?? null;
}
function hostnameCategory(value) {
let hostname;
try {
const url = new URL(value);
if (url.protocol !== "https:" || url.port || url.username || url.password) return null;
hostname = url.hostname.toLowerCase();
} catch {
return null;
}
for (const [category, domain] of Object.entries(EXTERNAL_RESOURCE_HOSTS)) {
if (hostname === domain || hostname.endsWith(`.${domain}`)) return category;
}
return null;
}
function environmentalConsoleWarningId(signal) {
if (signal?.type !== "console.error" || signal.source !== "page") return null;
const message = signalMessage(signal);
const locationUrl = signal.location?.url;
const locationCategory = hostnameCategory(locationUrl);
if (message === SANDBOXED_ABOUT_BLANK_SCRIPT_MESSAGE && locationUrl === "about:blank") {
return "sandboxed-about-blank-script";
}
if (message === YOUTUBE_ACCOUNTS_REPORT_ONLY_FRAME_ANCESTORS_MESSAGE && locationUrl === "") {
return "youtube-accounts-report-only-frame-ancestors";
}
if (EXTERNAL_RESOURCE_FAILURE_PATTERN.test(message)) {
if (locationCategory === "googlevideo") return "googlevideo-resource-failure";
if (locationCategory === "doubleclick") return "doubleclick-resource-failure";
return null;
}
if (YOUTUBE_ROTATE_COOKIES_RATE_LIMIT_PATTERN.test(message)) {
try {
const url = new URL(locationUrl);
if (
url.protocol === "https:" &&
url.hostname.toLowerCase() === "accounts.youtube.com" &&
url.pathname === "/RotateCookies" &&
!url.port &&
!url.username &&
!url.password
) {
return "youtube-rotate-cookies-rate-limit";
}
} catch {}
}
const corsMatch = message.match(DOUBLECLICK_CORS_PATTERN);
if (!corsMatch || hostnameCategory(corsMatch[1]) !== "doubleclick") return null;
const [, , redirectedFrom, origin] = corsMatch;
if (redirectedFrom) {
try {
const redirectUrl = new URL(redirectedFrom);
if (
redirectUrl.origin !== origin ||
!redirectUrl.pathname.startsWith("/pagead/viewthroughconversion/") ||
redirectUrl.username ||
redirectUrl.password
) {
return null;
}
} catch {
return null;
}
}
return "doubleclick-cors";
}
function extensionServiceWorkerUrl(extensionId) {
if (!EXTENSION_ID_PATTERN.test(extensionId ?? "")) {
throw new TypeError("selectedExtensionId must be a valid Chrome extension ID.");
}
return `chrome-extension://${extensionId}/ryd.background.js`;
} }
function installUnhandledRejectionListener(prefix) { function installUnhandledRejectionListener(prefix) {
@@ -34,6 +187,211 @@ function installUnhandledRejectionListener(prefix) {
}); });
} }
class LiveFatalSignalGuard {
constructor(
page,
context,
{
clock = () => new Date(),
ignoredSignalRules = [],
log = console.log,
onSignal = null,
selectedExtensionId = null,
} = {},
) {
this.clock = clock;
this.context = context;
this.ignoredSignalRules = normalizeIgnoredSignalRules(ignoredSignalRules);
this.log = log;
this.onSignal = onSignal;
this.page = page;
this.selectedExtensionWorkerUrl = selectedExtensionId ? extensionServiceWorkerUrl(selectedExtensionId) : null;
this.serviceWorkerListeners = new Map();
this.signals = [];
this.started = false;
this.onPageConsole = this.onPageConsole.bind(this);
this.onPageError = this.onPageError.bind(this);
this.onServiceWorker = this.onServiceWorker.bind(this);
}
now() {
return this.clock().toISOString();
}
recordSignal(type, details) {
const baseSignal = { at: this.now(), type, ...details };
const ignoredBy = ignoredSignalRuleId(baseSignal, this.ignoredSignalRules);
const environmentalWarning = environmentalConsoleWarningId(baseSignal);
const severity = ignoredBy || environmentalWarning ? "warning" : "fatal";
const signal = { ...baseSignal, environmentalWarning, ignoredBy, severity };
this.signals.push(signal);
this.log(
`LIVE_BROWSER_SIGNAL ${type} ${JSON.stringify({ ...details, environmentalWarning, ignoredBy, severity })}`,
);
this.onSignal?.(signal);
return signal;
}
onPageError(error) {
this.recordSignal("pageerror", { error: serializeError(error), source: "page" });
}
readConsoleMessage(message) {
let location = {};
try {
location = message.location?.() ?? {};
} catch {}
return { location, message: message.text() };
}
onPageConsole(consoleMessage) {
if (consoleMessage.type() !== "error") return;
const { location, message } = this.readConsoleMessage(consoleMessage);
if (message.startsWith(UNHANDLED_REJECTION_PREFIX)) {
this.recordSignal("unhandledrejection", {
location,
message: message.slice(UNHANDLED_REJECTION_PREFIX.length),
source: "page",
});
return;
}
this.recordSignal("console.error", { location, message, source: "page" });
}
async attachServiceWorker(worker) {
if (!this.selectedExtensionWorkerUrl || worker.url() !== this.selectedExtensionWorkerUrl) return;
if (this.serviceWorkerListeners.has(worker)) return;
const listener = (consoleMessage) => {
if (consoleMessage.type() !== "error") return;
const { location, message } = this.readConsoleMessage(consoleMessage);
if (message.startsWith(UNHANDLED_REJECTION_PREFIX)) {
this.recordSignal("extension-service-worker.unhandledrejection", {
location,
message: message.slice(UNHANDLED_REJECTION_PREFIX.length),
source: "extension-service-worker",
workerUrl: this.selectedExtensionWorkerUrl,
});
return;
}
this.recordSignal("extension-service-worker.console.error", {
location,
message,
source: "extension-service-worker",
workerUrl: this.selectedExtensionWorkerUrl,
});
};
this.serviceWorkerListeners.set(worker, listener);
worker.on("console", listener);
try {
await worker.evaluate(installUnhandledRejectionListener, UNHANDLED_REJECTION_PREFIX);
} catch (error) {
this.recordSignal("extension-service-worker.instrumentation-error", {
error: serializeError(error),
source: "extension-service-worker",
workerUrl: this.selectedExtensionWorkerUrl,
});
}
}
onServiceWorker(worker) {
void this.attachServiceWorker(worker);
}
async start() {
if (this.started) return;
this.started = true;
this.page.on("console", this.onPageConsole);
this.page.on("pageerror", this.onPageError);
await this.page.addInitScript(installUnhandledRejectionListener, UNHANDLED_REJECTION_PREFIX);
if (!this.page.isClosed()) await this.page.evaluate(installUnhandledRejectionListener, UNHANDLED_REJECTION_PREFIX);
if (this.selectedExtensionWorkerUrl) {
this.context.on("serviceworker", this.onServiceWorker);
await Promise.all(this.context.serviceWorkers().map((worker) => this.attachServiceWorker(worker)));
}
}
stop() {
if (!this.started) return;
this.started = false;
this.page.off("console", this.onPageConsole);
this.page.off("pageerror", this.onPageError);
if (this.selectedExtensionWorkerUrl) this.context.off("serviceworker", this.onServiceWorker);
for (const [worker, listener] of this.serviceWorkerListeners) worker.off("console", listener);
this.serviceWorkerListeners.clear();
}
mark() {
return this.signals.length;
}
fatalSignalsBetween(startIndex = 0, endIndex = this.signals.length) {
return this.signals.slice(startIndex, endIndex).filter((signal) => signal.severity !== "warning");
}
assertNoFatalSignalsBetween(startIndex = 0, endIndex = this.signals.length, label = "live scenario") {
const signals = this.fatalSignalsBetween(startIndex, endIndex);
if (signals.length === 0) return;
const summary = signals.map((signal) => `${signal.type}: ${signalMessage(signal)}`).join(" | ");
const error = new Error(`${label} emitted ${signals.length} fatal browser signal(s): ${summary}`);
error.name = "LiveFatalSignalError";
error.signals = signals;
throw error;
}
}
class LiveReadOnlyGate {
constructor(requiredScenarioIds, { allowedSkippedScenarioIds = [] } = {}) {
if (!Array.isArray(requiredScenarioIds) || requiredScenarioIds.length === 0) {
throw new TypeError("The live read-only gate requires at least one scenario ID.");
}
if (requiredScenarioIds.some((scenarioId) => typeof scenarioId !== "string" || scenarioId.trim() === "")) {
throw new TypeError("Every live read-only gate scenario ID must be a non-empty string.");
}
if (new Set(requiredScenarioIds).size !== requiredScenarioIds.length) {
throw new TypeError("Live read-only gate scenario IDs must be unique.");
}
if (
!Array.isArray(allowedSkippedScenarioIds) ||
allowedSkippedScenarioIds.some((scenarioId) => !requiredScenarioIds.includes(scenarioId))
) {
throw new TypeError("Allowed skipped scenarios must be a subset of the required live read-only scenarios.");
}
this.allowedSkipped = new Set(allowedSkippedScenarioIds);
this.completed = new Set();
this.failed = new Set();
this.requiredScenarioIds = Object.freeze([...requiredScenarioIds]);
}
record(scenarioId, outcome) {
if (!this.requiredScenarioIds.includes(scenarioId)) {
throw new TypeError(`Unknown live read-only scenario: ${scenarioId}`);
}
if (!["failed", "passed", "skipped"].includes(outcome)) {
throw new TypeError(`Unsupported live read-only outcome for ${scenarioId}: ${outcome}`);
}
if (outcome === "failed" || (outcome === "skipped" && !this.allowedSkipped.has(scenarioId))) {
this.completed.delete(scenarioId);
this.failed.add(scenarioId);
return;
}
if (this.failed.has(scenarioId)) return;
this.completed.add(scenarioId);
}
assertPassed() {
const missing = this.requiredScenarioIds.filter((scenarioId) => !this.completed.has(scenarioId));
if (this.failed.size === 0 && missing.length === 0) return;
throw new Error(
`Production reactions are blocked because this worker did not pass every read-only scenario. Missing: ${
missing.join(", ") || "none"
}; failed: ${[...this.failed].join(", ") || "none"}.`,
);
}
}
function readLivePageState() { function readLivePageState() {
const readRect = (element) => { const readRect = (element) => {
const rect = element.getBoundingClientRect(); const rect = element.getBoundingClientRect();
@@ -149,9 +507,11 @@ class LiveRunDiagnostics {
{ {
clock = () => new Date(), clock = () => new Date(),
fileSystem = fs, fileSystem = fs,
ignoredSignalRules = [],
log = console.log, log = console.log,
outputDirectory = path.resolve(__dirname, "../../../../test-results/live-youtube/diagnostics"), outputDirectory = path.resolve(__dirname, "../../../../test-results/live-youtube/diagnostics"),
runtime = null, runtime = null,
selectedExtensionId = null,
} = {}, } = {},
) { ) {
this.browserSignals = []; this.browserSignals = [];
@@ -166,13 +526,19 @@ class LiveRunDiagnostics {
this.recentApiRequests = []; this.recentApiRequests = [];
this.requestRecords = new WeakMap(); this.requestRecords = new WeakMap();
this.runtime = runtime; this.runtime = runtime;
this.fatalSignalCursor = 0;
this.started = false; this.started = false;
this.onConsole = this.onConsole.bind(this);
this.onPageError = this.onPageError.bind(this);
this.onRequest = this.onRequest.bind(this); this.onRequest = this.onRequest.bind(this);
this.onRequestFailed = this.onRequestFailed.bind(this); this.onRequestFailed = this.onRequestFailed.bind(this);
this.onResponse = this.onResponse.bind(this); this.onResponse = this.onResponse.bind(this);
this.fatalSignalGuard = new LiveFatalSignalGuard(page, context, {
clock,
ignoredSignalRules,
log: () => {},
onSignal: (signal) => this.recordBrowserSignal(signal),
selectedExtensionId,
});
} }
now() { now() {
@@ -184,34 +550,16 @@ class LiveRunDiagnostics {
if (collection.length > maximum) collection.splice(0, collection.length - maximum); if (collection.length > maximum) collection.splice(0, collection.length - maximum);
} }
recordBrowserSignal(type, details) { recordBrowserSignal(signal) {
const signal = { const { at, ...details } = signal;
at: this.now(), const enrichedSignal = {
at,
checkpoint: this.currentCheckpoint, checkpoint: this.currentCheckpoint,
stage: this.currentStage, stage: this.currentStage,
type,
...details, ...details,
}; };
this.appendCapped(this.browserSignals, signal, MAX_BROWSER_SIGNALS); this.appendCapped(this.browserSignals, enrichedSignal, MAX_BROWSER_SIGNALS);
this.log(`LIVE_BROWSER_SIGNAL ${type} ${JSON.stringify(details)}`); this.log(`LIVE_BROWSER_SIGNAL ${enrichedSignal.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) { onRequest(request) {
@@ -246,22 +594,16 @@ class LiveRunDiagnostics {
async start() { async start() {
if (this.started) return; if (this.started) return;
this.started = true; this.started = true;
this.page.on("console", this.onConsole); await this.fatalSignalGuard.start();
this.page.on("pageerror", this.onPageError);
this.context.on("request", this.onRequest); this.context.on("request", this.onRequest);
this.context.on("requestfailed", this.onRequestFailed); this.context.on("requestfailed", this.onRequestFailed);
this.context.on("response", this.onResponse); 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() { stop() {
if (!this.started) return; if (!this.started) return;
this.started = false; this.started = false;
this.page.off("console", this.onConsole); this.fatalSignalGuard.stop();
this.page.off("pageerror", this.onPageError);
this.context.off("request", this.onRequest); this.context.off("request", this.onRequest);
this.context.off("requestfailed", this.onRequestFailed); this.context.off("requestfailed", this.onRequestFailed);
this.context.off("response", this.onResponse); this.context.off("response", this.onResponse);
@@ -291,6 +633,13 @@ class LiveRunDiagnostics {
); );
} }
consumeFatalSignals(label) {
const startIndex = this.fatalSignalCursor;
const endIndex = this.fatalSignalGuard.mark();
this.fatalSignalCursor = endIndex;
this.fatalSignalGuard.assertNoFatalSignalsBetween(startIndex, endIndex, label);
}
async snapshot(error) { async snapshot(error) {
let pageState = null; let pageState = null;
let pageStateError = null; let pageStateError = null;
@@ -336,23 +685,71 @@ class LiveRunDiagnostics {
async function runLoggedStage(diagnostics, name, action) { async function runLoggedStage(diagnostics, name, action) {
const startedAt = Date.now(); const startedAt = Date.now();
diagnostics.stageStarted(name); diagnostics.stageStarted(name);
let result;
let failure = null;
try { try {
const result = await action(); result = await action();
diagnostics.stageCompleted(name, startedAt);
return result;
} catch (error) { } catch (error) {
diagnostics.stageFailed(name, startedAt, error); failure = error;
}
await Promise.resolve();
try {
diagnostics.consumeFatalSignals?.(name);
} catch (signalError) {
failure = failure
? new AggregateError([failure, signalError], `${name} failed and emitted fatal browser signals.`)
: signalError;
}
if (failure) {
diagnostics.stageFailed(name, startedAt, failure);
throw failure;
}
diagnostics.stageCompleted(name, startedAt);
return result;
}
async function runIndependentLoggedStages(diagnostics, stages) {
if (!Array.isArray(stages)) throw new TypeError("Independent live stages must be an array.");
const results = [];
const failures = [];
for (const stage of stages) {
if (!stage || typeof stage.name !== "string" || typeof stage.action !== "function") {
throw new TypeError("Each independent live stage requires a name and action function.");
}
try {
results.push({ name: stage.name, result: await runLoggedStage(diagnostics, stage.name, stage.action) });
} catch (error) {
failures.push(error);
results.push({ error: serializeError(error), name: stage.name });
}
}
if (failures.length > 0) {
const error = new AggregateError(
failures,
`${failures.length} of ${stages.length} independent live read-only stages failed.`,
);
error.stageResults = results;
throw error; throw error;
} }
return results;
} }
module.exports = { module.exports = {
API_ORIGIN, API_ORIGIN,
LiveFatalSignalGuard,
LiveReadOnlyGate,
LiveRunDiagnostics, LiveRunDiagnostics,
UNHANDLED_REJECTION_PREFIX, UNHANDLED_REJECTION_PREFIX,
diagnosticApiUrl, diagnosticApiUrl,
environmentalConsoleWarningId,
extensionServiceWorkerUrl,
ignoredSignalRuleId,
installUnhandledRejectionListener, installUnhandledRejectionListener,
normalizeIgnoredSignalRules,
readLivePageState, readLivePageState,
runIndependentLoggedStages,
runLoggedStage, runLoggedStage,
serializeError, serializeError,
}; };
@@ -0,0 +1,297 @@
const path = require("node:path");
const EXTENSION_ID_PATTERN = /^[a-p]{32}$/;
const LIVE_BUILD_ID_PATTERN = /^[a-f0-9]{32}$/;
const BACKGROUND_SCRIPT = "ryd.background.js";
const DEFAULT_RELOAD_TIMEOUT_MS = 15_000;
const EXTENSION_INVENTORY_POLL_INTERVAL_MS = 50;
const LIVE_EXTENSION_PATH = path.resolve(__dirname, "../../../combined/dist/chrome");
const delay = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
function normalizeExtensionPath(value, { pathImpl = path, platform = process.platform } = {}) {
if (typeof value !== "string" || value.trim() === "") return null;
let normalized = pathImpl.resolve(value.trim()).replace(/[\\/]+$/u, "");
if (platform === "win32") normalized = normalized.toLowerCase();
return normalized;
}
function selectEnabledUnpackedExtension(extensions, expectedPath, { requireEnabled = true, ...pathOptions } = {}) {
if (!Array.isArray(extensions)) throw new Error("Chrome returned a malformed unpacked-extension inventory.");
const normalizedExpectedPath = normalizeExtensionPath(expectedPath, pathOptions);
if (!normalizedExpectedPath) throw new Error("An absolute unpacked-extension path is required.");
const matching = extensions.filter(
(extension) => normalizeExtensionPath(extension?.path, pathOptions) === normalizedExpectedPath,
);
if (matching.length === 0) {
throw new Error(`The unpacked extension at ${expectedPath} is not installed in the attached Chrome profile.`);
}
if (matching.length > 1) {
throw new Error(`Chrome reported more than one unpacked extension at ${expectedPath}; refusing to choose one.`);
}
const [extension] = matching;
if (!EXTENSION_ID_PATTERN.test(extension.id ?? "")) {
throw new Error(`Chrome reported an invalid extension ID for ${expectedPath}.`);
}
if (requireEnabled && extension.enabled !== true) {
throw new Error(`The unpacked extension at ${expectedPath} is installed but disabled.`);
}
return extension;
}
function isOrdinaryYoutubePage(page) {
if (!page || typeof page.url !== "function" || page.isClosed?.()) return false;
try {
const url = new URL(page.url());
return url.protocol === "https:" && ["youtube.com", "www.youtube.com", "m.youtube.com"].includes(url.hostname);
} catch {
return false;
}
}
function assertSelectedSessionPage(sessionPage, context) {
if (!isOrdinaryYoutubePage(sessionPage)) {
throw new Error("The selected extension-reload session page must be an open ordinary HTTPS YouTube page.");
}
if (typeof sessionPage.context !== "function" || sessionPage.context() !== context) {
throw new Error("The selected extension-reload session page does not belong to the attached Chrome context.");
}
return sessionPage;
}
function extensionServiceWorkerUrl(extensionId) {
if (!EXTENSION_ID_PATTERN.test(extensionId ?? "")) throw new Error("A valid Chrome extension ID is required.");
return `chrome-extension://${extensionId}/${BACKGROUND_SCRIPT}`;
}
function matchingExtensionWorkers(context, extensionId, excludedWorker = null) {
const expectedUrl = extensionServiceWorkerUrl(extensionId);
return context.serviceWorkers().filter((worker) => worker !== excludedWorker && worker.url() === expectedUrl);
}
async function waitForExtensionWorker(
context,
serviceWorkerSession,
extensionId,
{ delayImpl = delay, excludedWorker = null, now = Date.now, timeoutMilliseconds = DEFAULT_RELOAD_TIMEOUT_MS } = {},
) {
let workers = matchingExtensionWorkers(context, extensionId, excludedWorker);
if (workers.length > 1) throw new Error(`Chrome exposed duplicate service workers for extension ${extensionId}.`);
if (workers.length === 1) return workers[0];
await serviceWorkerSession.send("ServiceWorker.enable");
await serviceWorkerSession.send("ServiceWorker.startWorker", {
scopeURL: `chrome-extension://${extensionId}/`,
});
const deadline = now() + timeoutMilliseconds;
while (now() < deadline) {
workers = matchingExtensionWorkers(context, extensionId, excludedWorker);
if (workers.length > 1) throw new Error(`Chrome exposed duplicate service workers for extension ${extensionId}.`);
if (workers.length === 1) return workers[0];
await delayImpl(Math.min(50, Math.max(1, deadline - now())));
}
throw new Error(`Chrome did not start ${extensionServiceWorkerUrl(extensionId)} within ${timeoutMilliseconds}ms.`);
}
async function readWorkerIdentity(worker) {
return worker.evaluate(async () => {
const runtime = globalThis.chrome?.runtime;
const manifest = runtime?.getManifest?.();
let resourceBuildId = null;
try {
const response = await fetch(runtime.getURL("live-build.json"), { cache: "no-store" });
const marker = response.ok ? await response.json() : null;
resourceBuildId = marker?.buildId ?? null;
} catch {}
return {
backgroundScript: manifest?.background?.service_worker ?? null,
compiledBuildId: globalThis.__RYD_LIVE_EXTENSION_BUILD__?.buildId ?? null,
extensionId: runtime?.id ?? null,
resourceBuildId,
version: manifest?.version ?? null,
};
});
}
function assertWorkerIdentity(identity, extensionId, { expectedBuildId = null, expectedVersion = null } = {}) {
if (!identity || typeof identity !== "object") throw new Error("The extension service worker returned no identity.");
if (identity.extensionId !== extensionId) {
throw new Error(
`The service worker belongs to ${identity.extensionId ?? "an unknown extension"}, not ${extensionId}.`,
);
}
if (identity.backgroundScript !== BACKGROUND_SCRIPT) {
throw new Error(`Extension ${extensionId} does not use the expected ${BACKGROUND_SCRIPT} service worker.`);
}
if (expectedVersion !== null && identity.version !== expectedVersion) {
throw new Error(
`Reloaded extension ${extensionId} has version ${identity.version ?? "<missing>"}, expected ${expectedVersion}.`,
);
}
if (expectedBuildId !== null) {
if (!LIVE_BUILD_ID_PATTERN.test(expectedBuildId)) throw new Error("The expected live-build ID is malformed.");
if (identity.compiledBuildId !== expectedBuildId) {
throw new Error(
`Reloaded extension ${extensionId} is running build ${identity.compiledBuildId ?? "<missing>"}, expected ${expectedBuildId}.`,
);
}
if (identity.resourceBuildId !== expectedBuildId) {
throw new Error(
`Extension ${extensionId} exposes resource build ${identity.resourceBuildId ?? "<missing>"}, expected ${expectedBuildId}.`,
);
}
}
return identity;
}
async function requestWorkerReload(worker, timeoutMilliseconds = DEFAULT_RELOAD_TIMEOUT_MS) {
const closed = worker.waitForEvent("close", { timeout: timeoutMilliseconds });
await worker.evaluate(() => {
const runtime = chrome.runtime;
setTimeout(() => runtime.reload(), 0);
return true;
});
await closed;
}
async function readUnpackedExtensions(browserSession) {
try {
const result = await browserSession.send("Extensions.getExtensions");
return result?.extensions;
} catch (error) {
throw new Error(
"Chrome did not expose Extensions.getExtensions on this CDP connection, so the unpacked extension cannot be identified safely. Reload it manually; no extension was changed.",
{ cause: error },
);
}
}
async function waitForReloadedExtensionEnabled(
browserSession,
expectedExtensionPath,
expectedExtensionId,
{ delayImpl = delay, now = Date.now, pathOptions, timeoutMilliseconds = DEFAULT_RELOAD_TIMEOUT_MS } = {},
) {
const deadline = now() + timeoutMilliseconds;
while (true) {
const inventory = await readUnpackedExtensions(browserSession);
const extension = selectEnabledUnpackedExtension(inventory, expectedExtensionPath, {
...pathOptions,
requireEnabled: false,
});
if (extension.id !== expectedExtensionId) {
throw new Error(
`The unpacked extension ID changed from ${expectedExtensionId} to ${extension.id} during reload.`,
);
}
if (extension.enabled === true) return extension;
const remainingMilliseconds = deadline - now();
if (remainingMilliseconds <= 0) {
throw new Error(
`The unpacked extension at ${expectedExtensionPath} remained disabled for ${timeoutMilliseconds}ms after reload.`,
);
}
await delayImpl(Math.min(EXTENSION_INVENTORY_POLL_INTERVAL_MS, remainingMilliseconds));
}
}
async function reloadLiveExtension({
browserSession,
context,
expectedBuildId,
expectedExtensionPath,
expectedVersion,
pathOptions,
serviceWorkerSession,
timeoutMilliseconds = DEFAULT_RELOAD_TIMEOUT_MS,
waitOptions = {},
}) {
if (!LIVE_BUILD_ID_PATTERN.test(expectedBuildId ?? "")) throw new Error("The expected live-build ID is malformed.");
const beforeInventory = await readUnpackedExtensions(browserSession);
const before = selectEnabledUnpackedExtension(beforeInventory, expectedExtensionPath, pathOptions);
const oldWorker = await waitForExtensionWorker(context, serviceWorkerSession, before.id, {
...waitOptions,
timeoutMilliseconds,
});
assertWorkerIdentity(await readWorkerIdentity(oldWorker), before.id);
await requestWorkerReload(oldWorker, timeoutMilliseconds);
const after = await waitForReloadedExtensionEnabled(browserSession, expectedExtensionPath, before.id, {
...waitOptions,
pathOptions,
timeoutMilliseconds,
});
const newWorker = await waitForExtensionWorker(context, serviceWorkerSession, after.id, {
...waitOptions,
excludedWorker: oldWorker,
timeoutMilliseconds,
});
const identity = assertWorkerIdentity(await readWorkerIdentity(newWorker), after.id, {
expectedBuildId,
expectedVersion,
});
return {
buildId: identity.compiledBuildId,
extensionId: after.id,
previousVersion: before.version ?? null,
version: identity.version,
};
}
async function reloadLiveExtensionInBrowser({
browser,
context,
expectedBuildId,
expectedExtensionPath = LIVE_EXTENSION_PATH,
expectedVersion,
reloadImpl = reloadLiveExtension,
sessionPage,
timeoutMilliseconds = DEFAULT_RELOAD_TIMEOUT_MS,
}) {
assertSelectedSessionPage(sessionPage, context);
const browserSession = await browser.newBrowserCDPSession();
let serviceWorkerSession = null;
try {
serviceWorkerSession = await context.newCDPSession(sessionPage);
return await reloadImpl({
browserSession,
context,
expectedBuildId,
expectedExtensionPath,
expectedVersion,
serviceWorkerSession,
timeoutMilliseconds,
});
} finally {
await serviceWorkerSession?.detach().catch(() => {});
await browserSession.detach().catch(() => {});
}
}
module.exports = {
BACKGROUND_SCRIPT,
DEFAULT_RELOAD_TIMEOUT_MS,
LIVE_EXTENSION_PATH,
assertSelectedSessionPage,
assertWorkerIdentity,
extensionServiceWorkerUrl,
normalizeExtensionPath,
readUnpackedExtensions,
readWorkerIdentity,
reloadLiveExtension,
reloadLiveExtensionInBrowser,
requestWorkerReload,
selectEnabledUnpackedExtension,
waitForReloadedExtensionEnabled,
waitForExtensionWorker,
};
@@ -0,0 +1,94 @@
const { spawnSync } = require("node:child_process");
const path = require("node:path");
const REQUIRED_PRODUCTION_REACTION_SCENARIOS = Object.freeze(["post-navigation-vote", "reaction-matrix"]);
const SUPPORTED_LIVE_RUNTIMES = new Set(["extension", "userscript"]);
const REPOSITORY_ROOT = path.resolve(__dirname, "../../../..");
class LiveValidationIncompleteError extends Error {
constructor(message) {
super(message);
this.code = "LIVE_VALIDATION_INCOMPLETE";
this.name = "LiveValidationIncompleteError";
}
}
function readFullLiveRuntime(environment = process.env) {
const runtime = environment.RYD_LIVE_RUNTIME?.trim();
if (!SUPPORTED_LIVE_RUNTIMES.has(runtime)) {
throw new Error('RYD_LIVE_RUNTIME must be either "userscript" or "extension" before building a full live run.');
}
return runtime;
}
function buildLiveRuntimeArtifact(
runtime,
{
environment = process.env,
nodeExecutable = process.execPath,
platform = process.platform,
repositoryRoot = REPOSITORY_ROOT,
spawnSyncImpl = spawnSync,
} = {},
) {
if (!SUPPORTED_LIVE_RUNTIMES.has(runtime)) throw new TypeError(`Unsupported live build runtime: ${runtime}`);
const script = `build:live:${runtime}`;
const npmExecutable = environment.npm_execpath?.trim();
const command = npmExecutable ? nodeExecutable : platform === "win32" ? "npm.cmd" : "npm";
const args = npmExecutable ? [npmExecutable, "run", script] : ["run", script];
const result = spawnSyncImpl(command, args, {
cwd: repositoryRoot,
env: environment,
...(platform === "win32" && !npmExecutable ? { shell: true } : {}),
stdio: "inherit",
});
if (result.error) throw new Error(`Could not execute npm run ${script}.`, { cause: result.error });
if (result.status !== 0) throw new Error(`npm run ${script} failed with exit code ${result.status}.`);
return { runtime, script };
}
function requireUserscriptInstallAcknowledgement(value) {
if (value?.trim() !== "INSTALLED") {
throw new LiveValidationIncompleteError(
"Full userscript validation is incomplete because the freshly built userscript was not acknowledged as installed.",
);
}
return true;
}
function requireProductionReactionApproval(value) {
if (value?.trim().toUpperCase() === "SKIP") {
throw new LiveValidationIncompleteError(
"Full live validation is incomplete because production reaction scenarios were skipped.",
);
}
return value?.trim() ?? "";
}
function assertFullLiveReactionCompletion(results) {
if (!results || typeof results !== "object") {
throw new LiveValidationIncompleteError("Full live validation has no production reaction results.");
}
const missing = REQUIRED_PRODUCTION_REACTION_SCENARIOS.filter((scenarioId) => results[scenarioId] == null);
if (missing.length > 0) {
throw new LiveValidationIncompleteError(
`Full live validation did not complete production reaction scenarios: ${missing.join(", ")}.`,
);
}
return Object.freeze({
classification: "full",
completedScenarioIds: REQUIRED_PRODUCTION_REACTION_SCENARIOS,
productionReactionsCompleted: true,
releaseReady: true,
});
}
module.exports = {
LiveValidationIncompleteError,
REQUIRED_PRODUCTION_REACTION_SCENARIOS,
assertFullLiveReactionCompletion,
buildLiveRuntimeArtifact,
readFullLiveRuntime,
requireProductionReactionApproval,
requireUserscriptInstallAcknowledgement,
};
@@ -0,0 +1,104 @@
const { runIndependentLoggedStages } = require("./live-diagnostics");
const {
assertInteractiveReadOnlyScenarioCompletions,
assertInteractiveReadOnlyScenarioDefinitions,
} = require("./live-interactive-scenario-contract");
const INTERACTIVE_READ_ONLY_SCENARIOS = Object.freeze([
Object.freeze({ key: "short", name: "read-only.short-render", scenarioId: "shorts-render" }),
Object.freeze({
key: "channelShorts",
name: "read-only.channel-to-shorts-and-ten-plus-valid-next",
scenarioId: "channel-shorts-navigation",
}),
Object.freeze({ key: "channelWatch", name: "read-only.channel-to-watch", scenarioId: "channel-watch-navigation" }),
Object.freeze({ key: "reload", name: "read-only.reload", scenarioId: "reload" }),
Object.freeze({ key: "responsive", name: "read-only.responsive-visual", scenarioId: "responsive-visual" }),
Object.freeze({ key: "spa", name: "read-only.playlist-spa", scenarioId: "spa-navigation" }),
Object.freeze({
key: "watchActionTopology",
name: "read-only.watch-action-topology",
scenarioId: "watch-action-topology",
}),
Object.freeze({
key: "sidebarStress",
name: "read-only.sidebar-stress",
scenarioId: "sidebar-navigation-stress",
}),
Object.freeze({ key: "watch", name: "read-only.watch-render", scenarioId: "watch-render" }),
]);
assertInteractiveReadOnlyScenarioDefinitions(INTERACTIVE_READ_ONLY_SCENARIOS);
function createInteractiveReadOnlyStages({ diagnostics, options, readOnly, runtimeAdapter, scenarioRunner }) {
if (!options || typeof options !== "object") throw new TypeError("Live options are required.");
if (!options.navigation?.watch) {
throw new TypeError("The full interactive read-only gate requires a configured channel Watch target.");
}
if (!readOnly || typeof readOnly !== "object") throw new TypeError("A read-only result object is required.");
if (!runtimeAdapter) throw new TypeError("A live runtime adapter is required.");
if (!scenarioRunner || typeof scenarioRunner.run !== "function") {
throw new TypeError("A shared live scenario runner is required.");
}
return INTERACTIVE_READ_ONLY_SCENARIOS.map(({ key, name, scenarioId }) => ({
action: async () => {
readOnly[key] = await scenarioRunner.run(runtimeAdapter, scenarioId, options);
return readOnly[key];
},
name,
scenarioId,
}));
}
async function runInteractiveReadOnlyAfterRuntimeReload({
initializeRuntime,
options,
reloadExtension,
runStages = runIndependentLoggedStages,
scenarioRunner,
}) {
if (typeof initializeRuntime !== "function") throw new TypeError("A live runtime initializer is required.");
if (typeof runStages !== "function") throw new TypeError("A live stage executor is required.");
let reloadResult = null;
if (options?.runtime === "extension") {
if (typeof reloadExtension !== "function") throw new TypeError("An extension reload function is required.");
reloadResult = await reloadExtension();
}
const initialized = await initializeRuntime(reloadResult);
if (!initialized?.diagnostics || !initialized?.runtimeAdapter) {
throw new TypeError("The live runtime initializer must return diagnostics and a runtime adapter.");
}
const readOnly = {};
const stages = createInteractiveReadOnlyStages({
diagnostics: initialized.diagnostics,
options,
readOnly,
runtimeAdapter: initialized.runtimeAdapter,
scenarioRunner,
});
const completedScenarioIds = [];
const completionTrackedStages = stages.map((stage) => ({
...stage,
action: async () => {
const result = await stage.action();
if (result === undefined) {
throw new Error(`Interactive read-only scenario ${stage.scenarioId} completed without a result.`);
}
completedScenarioIds.push(stage.scenarioId);
return result;
},
}));
await runStages(initialized.diagnostics, completionTrackedStages);
assertInteractiveReadOnlyScenarioCompletions(completedScenarioIds);
return { readOnly, reloadResult };
}
module.exports = {
INTERACTIVE_READ_ONLY_SCENARIOS,
createInteractiveReadOnlyStages,
runInteractiveReadOnlyAfterRuntimeReload,
};
@@ -1,3 +1,5 @@
const fs = require("node:fs");
const path = require("node:path");
const readline = require("node:readline"); const readline = require("node:readline");
const { chromium } = require("@playwright/test"); const { chromium } = require("@playwright/test");
const { const {
@@ -6,133 +8,540 @@ const {
} = require("../../../e2e/live-runtime-adapter"); } = require("../../../e2e/live-runtime-adapter");
const { createSharedLiveScenarioRunner } = require("../../../e2e/shared-live-scenarios"); const { createSharedLiveScenarioRunner } = require("../../../e2e/shared-live-scenarios");
const { consumeLiveVoteApproval, hasFreshVoteApproval, readLiveOptions } = require("../../live/live-options"); const { consumeLiveVoteApproval, hasFreshVoteApproval, readLiveOptions } = require("../../live/live-options");
const { selectAuthenticatedYoutubeContext } = require("./live-authenticated-context");
const { LiveRunDiagnostics, runLoggedStage } = require("./live-diagnostics"); const { LiveRunDiagnostics, runLoggedStage } = require("./live-diagnostics");
const { LiveYoutubeDriver, VoteTrafficRecorder } = require("./live-youtube-driver"); const { reloadLiveExtensionInBrowser } = require("./live-extension-reloader");
const {
assertFullLiveReactionCompletion,
buildLiveRuntimeArtifact,
readFullLiveRuntime,
requireProductionReactionApproval,
requireUserscriptInstallAcknowledgement,
} = require("./live-full-run-contract");
const { runInteractiveReadOnlyAfterRuntimeReload } = require("./live-interactive-readonly");
const { LiveYoutubeDriver, VoteTrafficRecorder, withOperationTimeout } = require("./live-youtube-driver");
const REPOSITORY_DIRECTORY = path.resolve(__dirname, "../../../..");
const LIVE_EVIDENCE_DIRECTORY = path.resolve(__dirname, "../../../../test-results/live-youtube");
const LIVE_EVIDENCE_PARENT_DIRECTORY = path.dirname(LIVE_EVIDENCE_DIRECTORY);
const LIVE_ATTEMPT_PAGE_CLOSE_TIMEOUT_MS = 10_000;
const LIVE_SESSION_COMMANDS = new Set(["EXIT", "RERUN"]);
const scenarioRunner = createSharedLiveScenarioRunner(); const scenarioRunner = createSharedLiveScenarioRunner();
function readApprovalLine() { function createInputLineReader(input = process.stdin) {
const input = readline.createInterface({ input: process.stdin, terminal: false }); const lineInterface = readline.createInterface({ input, terminal: false });
return new Promise((resolve) => { const bufferedLines = [];
input.once("line", (line) => { const waiters = [];
input.close(); let closed = false;
resolve(line.trim());
}); lineInterface.on("line", (line) => {
const value = line.trim();
const waiter = waiters.shift();
if (waiter) waiter(value);
else bufferedLines.push(value);
});
lineInterface.once("close", () => {
closed = true;
for (const waiter of waiters.splice(0)) waiter(null);
});
return {
close: () => lineInterface.close(),
readLine: () => {
if (bufferedLines.length > 0) return Promise.resolve(bufferedLines.shift());
if (closed) return Promise.resolve(null);
return new Promise((resolve) => waiters.push(resolve));
},
};
}
function readPersistentSessionMode(environment = process.env) {
const configured = environment.RYD_LIVE_KEEP_CDP_SESSION?.trim();
if (!configured) return false;
if (configured !== "1") {
throw new Error("RYD_LIVE_KEEP_CDP_SESSION must be 1 or unset.");
}
return true;
}
function normalizeLiveSessionCommand(value) {
if (value === null) return "EXIT";
const command = value?.trim().toUpperCase();
return LIVE_SESSION_COMMANDS.has(command) ? command : null;
}
function assertReusableConnectionOptions(initialOptions, nextOptions) {
const immutableFields = ["cdpEndpoint", "expectedChannel", "runtime"];
for (const field of immutableFields) {
if (nextOptions?.[field] !== initialOptions?.[field]) {
throw new Error(
`Cannot reuse the live browser connection after ${field} changed from ${initialOptions?.[field] ?? "<missing>"} to ${nextOptions?.[field] ?? "<missing>"}. Exit and start a new live runner instead.`,
);
}
}
return nextOptions;
}
function copyDirectoryEntry(source, destination, fsImpl = fs) {
const stats = fsImpl.statSync(source);
if (stats.isDirectory()) {
fsImpl.mkdirSync(destination, { recursive: true });
for (const entry of fsImpl.readdirSync(source)) {
copyDirectoryEntry(path.join(source, entry), path.join(destination, entry), fsImpl);
}
return;
}
fsImpl.mkdirSync(path.dirname(destination), { recursive: true });
fsImpl.copyFileSync(source, destination);
}
function canonicalizeExistingPath(targetPath, fsImpl = fs) {
const resolvedPath = path.resolve(targetPath);
if (!fsImpl.existsSync(resolvedPath)) return resolvedPath;
const realpath = fsImpl.realpathSync?.native ?? fsImpl.realpathSync;
return realpath ? realpath(resolvedPath) : resolvedPath;
}
function pathsAreEqual(left, right) {
const normalize = (value) => {
const normalized = path.normalize(value);
return process.platform === "win32" ? normalized.toLowerCase() : normalized;
};
return normalize(left) === normalize(right);
}
function isAttemptsEvidenceEntry(entry) {
return entry.toLowerCase() === "attempts";
}
function resolveSafeLiveEvidenceDirectory(
evidenceDirectory = LIVE_EVIDENCE_DIRECTORY,
fsImpl = fs,
allowedParentDirectory = LIVE_EVIDENCE_PARENT_DIRECTORY,
) {
const resolvedEvidenceDirectory = canonicalizeExistingPath(evidenceDirectory, fsImpl);
const resolvedRepositoryDirectory = canonicalizeExistingPath(REPOSITORY_DIRECTORY, fsImpl);
const resolvedAllowedParentDirectory = canonicalizeExistingPath(allowedParentDirectory, fsImpl);
const filesystemRoot = path.parse(resolvedEvidenceDirectory).root;
if (
pathsAreEqual(resolvedEvidenceDirectory, filesystemRoot) ||
pathsAreEqual(resolvedEvidenceDirectory, resolvedRepositoryDirectory) ||
!pathsAreEqual(path.dirname(resolvedEvidenceDirectory), resolvedAllowedParentDirectory) ||
!pathsAreEqual(path.basename(resolvedEvidenceDirectory), "live-youtube")
) {
throw new Error(
`Refusing to reset unsafe live evidence directory ${resolvedEvidenceDirectory}. The target must be the dedicated live-youtube directory directly under ${resolvedAllowedParentDirectory}, never a filesystem or repository root.`,
);
}
if (fsImpl.existsSync(resolvedEvidenceDirectory) && !fsImpl.statSync(resolvedEvidenceDirectory).isDirectory()) {
throw new Error(`Refusing to reset live evidence path because it is not a directory: ${resolvedEvidenceDirectory}`);
}
return resolvedEvidenceDirectory;
}
function resetLiveAttemptEvidenceWorkspace(
{ attemptNumber } = {},
{
allowedParentDirectory = LIVE_EVIDENCE_PARENT_DIRECTORY,
evidenceDirectory = LIVE_EVIDENCE_DIRECTORY,
fsImpl = fs,
} = {},
) {
const resolvedEvidenceDirectory = resolveSafeLiveEvidenceDirectory(evidenceDirectory, fsImpl, allowedParentDirectory);
if (!fsImpl.existsSync(resolvedEvidenceDirectory)) {
return { attemptNumber, evidenceDirectory: resolvedEvidenceDirectory, removedEntries: [] };
}
const removedEntries = [];
for (const entry of fsImpl.readdirSync(resolvedEvidenceDirectory).sort()) {
if (isAttemptsEvidenceEntry(entry)) continue;
const entryPath = path.resolve(resolvedEvidenceDirectory, entry);
const relativeEntryPath = path.relative(resolvedEvidenceDirectory, entryPath);
if (
!relativeEntryPath ||
relativeEntryPath === ".." ||
relativeEntryPath.startsWith(`..${path.sep}`) ||
path.isAbsolute(relativeEntryPath)
) {
throw new Error(`Refusing to remove live evidence entry outside the dedicated directory: ${entryPath}`);
}
fsImpl.rmSync(entryPath, { force: true, recursive: true });
removedEntries.push(entry);
}
return { attemptNumber, evidenceDirectory: resolvedEvidenceDirectory, removedEntries };
}
function preserveLiveAttemptEvidence(
{ attemptNumber, outcome },
{ evidenceDirectory = LIVE_EVIDENCE_DIRECTORY, fsImpl = fs, now = () => new Date() } = {},
) {
const timestamp = now().toISOString().replace(/[:.]/gu, "-");
const attemptDirectory = path.join(
evidenceDirectory,
"attempts",
`${String(attemptNumber).padStart(2, "0")}-${timestamp}-${outcome}`,
);
fsImpl.mkdirSync(attemptDirectory, { recursive: true });
if (fsImpl.existsSync(evidenceDirectory)) {
for (const entry of fsImpl.readdirSync(evidenceDirectory)) {
if (isAttemptsEvidenceEntry(entry)) continue;
copyDirectoryEntry(path.join(evidenceDirectory, entry), path.join(attemptDirectory, entry), fsImpl);
}
}
fsImpl.writeFileSync(
path.join(attemptDirectory, "attempt.json"),
`${JSON.stringify({ attemptNumber, capturedAt: now().toISOString(), outcome }, null, 2)}\n`,
);
return attemptDirectory;
}
function createInteractiveLiveDriver({ context, diagnostics, options, page, selectedExtensionId }) {
return new LiveYoutubeDriver(page, context, {
authenticatedHandle: options.expectedChannel,
expectedBuildId: options.expectedBuildId,
reportProgress: (name, details) => diagnostics.checkpoint(name, details),
selectedExtensionId,
}); });
} }
async function main() { function createInteractiveVoteRecorderFactory({ context, driver, options, page, selectedExtensionId }) {
const options = readLiveOptions(); return (videoId) =>
new VoteTrafficRecorder(context, videoId, {
page,
runtime: options.runtime,
selectedExtensionId,
trafficLedger: driver.trafficLedger,
});
}
function createReactionTargetDetails(options) {
return {
reactionShort: options.reactionShort,
reactionWatch: options.watchB,
};
}
function formatReactionApprovalReadiness(options) {
return `READY_FOR_REACTION_APPROVAL runtime=${options.runtime} watch=${options.watchB} reactionShort=${options.reactionShort} readOnlyShort=${options.short}`;
}
async function closeLiveAttemptPage(page, { timeoutMs = LIVE_ATTEMPT_PAGE_CLOSE_TIMEOUT_MS } = {}) {
if (!page || page.isClosed()) return false;
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
throw new TypeError("The live-attempt page-close timeout must be positive.");
}
await withOperationTimeout(
() => page.close(),
timeoutMs,
`Timed out after ${timeoutMs}ms while closing the live-attempt test page.`,
);
return true;
}
async function prepareLiveAttempt({ buildArtifact, environment, output, readLine, readOptions, requestedRuntime }) {
const build = buildArtifact(requestedRuntime);
output.log(`LIVE_ARTIFACT_BUILT ${JSON.stringify(build)}`);
if (requestedRuntime === "userscript") {
output.log(
"LIVE_USERSCRIPT_INSTALL_REQUIRED Reinstall test-results/live-build/userscript/Return Youtube Dislike.user.js, then enter INSTALLED.",
);
requireUserscriptInstallAcknowledgement(await readLine());
}
const options = readOptions(environment);
if (!options) throw new Error("Set RYD_LIVE_YOUTUBE=1 and the documented allowlist variables to opt in."); if (!options) throw new Error("Set RYD_LIVE_YOUTUBE=1 and the documented allowlist variables to opt in.");
if (options.runtime !== requestedRuntime) {
throw new Error(`The built live runtime ${requestedRuntime} does not match configured runtime ${options.runtime}.`);
}
return options;
}
let browser; async function runConnectedLiveAttempt({
browser,
options,
output,
pageCloseTimeoutMs = LIVE_ATTEMPT_PAGE_CLOSE_TIMEOUT_MS,
readLine,
selectedSession,
}) {
let attemptError;
let diagnostics; let diagnostics;
let driver;
let page; let page;
let selectedExtensionId = null;
try { try {
console.log("WAITING_FOR_BRAVE_DEBUG_APPROVAL"); const { context } = selectedSession;
browser = await chromium.connectOverCDP(options.cdpEndpoint, { let runtimeAdapter;
isLocal: true, const { readOnly } = await runInteractiveReadOnlyAfterRuntimeReload({
noDefaults: true, initializeRuntime: async (reloaded) => {
timeout: 120_000, selectedExtensionId = reloaded?.extensionId ?? null;
page = await context.newPage();
diagnostics = new LiveRunDiagnostics(page, context, { runtime: options.runtime, selectedExtensionId });
await diagnostics.start();
driver = createInteractiveLiveDriver({
context,
diagnostics,
options,
page,
selectedExtensionId,
});
const createAdapter =
options.runtime === "extension" ? createExtensionLiveRuntimeAdapter : createUserscriptLiveRuntimeAdapter;
runtimeAdapter = createAdapter({
driver,
expectedBuildId: options.expectedBuildId,
expectedVersion: options.expectedVersion,
});
output.log("CHROMIUM_CONNECTED");
return { diagnostics, runtimeAdapter };
},
options,
reloadExtension: async () => {
const reloaded = await reloadLiveExtensionInBrowser({
browser,
context,
expectedBuildId: options.expectedBuildId,
expectedVersion: options.expectedVersion,
sessionPage: selectedSession.sessionPage,
});
output.log(`LIVE_EXTENSION_RELOADED ${JSON.stringify(reloaded)}`);
return reloaded;
},
scenarioRunner,
}); });
const [context] = browser.contexts(); await driver.assertNoUnclaimedAttributedTraffic("interactive read-only run");
if (!context) throw new Error("The attached Chromium browser has no default context."); output.log(`READ_ONLY_COMPLETE ${JSON.stringify(readOnly)}`);
output.log(formatReactionApprovalReadiness(options));
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", { diagnostics.checkpoint("reaction-approval.waiting", {
instruction: "Enter SKIP to close the live-test tab without production reactions", instruction: "Enter the fresh production-reaction approval token",
}); });
const approval = await readApprovalLine(); const approval = requireProductionReactionApproval(await readLine());
if (approval === "SKIP") { diagnostics.consumeFatalSignals("reaction approval wait");
console.log("REACTION_MATRIX_SKIPPED"); await driver.assertNoUnclaimedAttributedTraffic("reaction approval wait");
return;
}
if (!hasFreshVoteApproval(approval, options.runtime, options.watchB, Date.now())) { 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."); throw new Error("The supplied live reaction approval is missing, expired, or does not match this run.");
} }
const reaction = await runLoggedStage(diagnostics, "reaction-matrix", () => let approvalConsumed = false;
scenarioRunner.run(runtimeAdapter, "reaction-matrix", options, { const reactionServices = {
createRecorder: (videoId) => new VoteTrafficRecorder(context, videoId), createRecorder: createInteractiveVoteRecorderFactory({ context, driver, options, page, selectedExtensionId }),
consumeVoteApproval: async () => { consumeVoteApproval: async () => {
driver.assertCurrentVideo(options.watchB); driver.assertCurrentVideo(options.watchB);
await driver.assertRuntime(options.runtime, options.expectedVersion, options.expectedBuildId); await driver.assertRuntime(options.runtime, options.expectedVersion, options.expectedBuildId);
if (!consumeLiveVoteApproval(approval, options.runtime, options.watchB)) { if (approvalConsumed) return;
throw new Error("The live reaction approval expired or was already used. No reaction was clicked."); if (!consumeLiveVoteApproval(approval, options.runtime, options.watchB)) {
} throw new Error("The live reaction approval expired or was already used. No reaction was clicked.");
}, }
}), approvalConsumed = true;
},
};
const reaction = await runLoggedStage(diagnostics, "production-reactions", async () => ({
"post-navigation-vote": await scenarioRunner.run(
runtimeAdapter,
"post-navigation-vote",
options,
reactionServices,
),
"reaction-matrix": await scenarioRunner.run(runtimeAdapter, "reaction-matrix", options, reactionServices),
}));
await driver.assertNoUnclaimedAttributedTraffic("interactive production reactions");
const completion = assertFullLiveReactionCompletion(reaction);
output.log(`PRODUCTION_REACTIONS_COMPLETE ${JSON.stringify(reaction)}`);
output.log(
`LIVE_VALIDATION_COMPLETE ${JSON.stringify({
...completion,
...createReactionTargetDetails(options),
})}`,
); );
console.log(`REACTION_MATRIX_COMPLETE ${JSON.stringify(reaction)}`); return { classification: completion.classification, reaction, readOnly };
} catch (error) { } catch (error) {
attemptError = error;
if (diagnostics) { if (diagnostics) {
try { try {
const snapshotPath = await diagnostics.persistFailureSnapshot(error); const snapshotPath = await diagnostics.persistFailureSnapshot(error);
console.error(`LIVE_FAILURE_SNAPSHOT ${snapshotPath}`); output.error(`LIVE_FAILURE_SNAPSHOT ${snapshotPath}`);
} catch (snapshotError) { } catch (snapshotError) {
console.error(`LIVE_FAILURE_SNAPSHOT_FAILED ${snapshotError.message}`); output.error(`LIVE_FAILURE_SNAPSHOT_FAILED ${snapshotError.message}`);
} }
} }
throw error;
} finally { } finally {
driver?.stop();
diagnostics?.stop(); diagnostics?.stop();
if (page && !page.isClosed()) await page.close(); let pageCloseError;
if (browser) await browser.close(); try {
if (page && !page.isClosed()) {
output.log(`LIVE_ATTEMPT_PAGE_CLOSE_START ${JSON.stringify({ timeoutMs: pageCloseTimeoutMs })}`);
await closeLiveAttemptPage(page, { timeoutMs: pageCloseTimeoutMs });
output.log("LIVE_ATTEMPT_PAGE_CLOSE_COMPLETE");
}
} catch (error) {
pageCloseError = error;
output.error(`LIVE_ATTEMPT_PAGE_CLOSE_FAILED ${JSON.stringify({ message: error.message })}`);
}
if (attemptError && pageCloseError) {
throw new AggregateError(
[attemptError, pageCloseError],
"The live attempt and its test-page cleanup both failed.",
);
}
if (attemptError) throw attemptError;
if (pageCloseError) throw pageCloseError;
} }
} }
main().catch((error) => { async function runLiveAttemptLoop({
console.error(error); initialOptions,
process.exitCode = 1; output,
}); persistentSession,
prepareNextAttempt,
preserveEvidence,
readLine,
resetEvidenceWorkspace,
runAttempt,
}) {
let attemptNumber = 1;
let options = initialOptions;
while (true) {
const reset = await resetEvidenceWorkspace({ attemptNumber });
output.log(
`LIVE_ATTEMPT_EVIDENCE_RESET ${JSON.stringify({
attemptNumber,
removedEntries: reset?.removedEntries ?? [],
})}`,
);
output.log(
`LIVE_ATTEMPT_START ${JSON.stringify({ attemptNumber, persistentSession, runtime: options?.runtime ?? null })}`,
);
let attemptError = null;
let attemptResult = null;
try {
if (attemptNumber > 1) {
const nextOptions = await prepareNextAttempt();
options = assertReusableConnectionOptions(initialOptions, nextOptions);
}
attemptResult = await runAttempt(options, attemptNumber);
output.log(`LIVE_ATTEMPT_COMPLETE ${JSON.stringify({ attemptNumber, outcome: "passed" })}`);
} catch (error) {
attemptError = error;
output.error(
`LIVE_ATTEMPT_FAILED ${JSON.stringify({ attemptNumber, message: error.message, outcome: "failed" })}`,
);
}
if (!persistentSession) {
if (attemptError) throw attemptError;
return attemptResult;
}
const outcome = attemptError ? "failed" : "passed";
try {
const evidencePath = await preserveEvidence({ attemptNumber, outcome });
output.log(`LIVE_ATTEMPT_EVIDENCE ${JSON.stringify({ attemptNumber, outcome, path: evidencePath })}`);
} catch (evidenceError) {
attemptError = attemptError
? new AggregateError([attemptError, evidenceError], "The live attempt and its evidence snapshot both failed.")
: evidenceError;
output.error(`LIVE_ATTEMPT_EVIDENCE_FAILED ${JSON.stringify({ attemptNumber, message: evidenceError.message })}`);
}
while (true) {
output.log(`READY_FOR_LIVE_RERUN_OR_EXIT attempt=${attemptNumber} outcome=${attemptError ? "failed" : "passed"}`);
const command = normalizeLiveSessionCommand(await readLine());
if (command === "RERUN") {
attemptNumber += 1;
break;
}
if (command === "EXIT") {
if (attemptError) throw attemptError;
return attemptResult;
}
output.error("LIVE_RERUN_COMMAND_REJECTED expected=RERUN_OR_EXIT");
}
}
}
async function main({
buildArtifact = buildLiveRuntimeArtifact,
connectBrowser = (options) =>
chromium.connectOverCDP(options.cdpEndpoint, {
isLocal: true,
noDefaults: true,
timeout: options.cdpConnectTimeoutMilliseconds,
}),
environment = process.env,
executeAttempt = runConnectedLiveAttempt,
output = console,
preserveEvidence = preserveLiveAttemptEvidence,
readLine,
readOptions = readLiveOptions,
resetEvidenceWorkspace = resetLiveAttemptEvidenceWorkspace,
selectContext = selectAuthenticatedYoutubeContext,
} = {}) {
const persistentSession = readPersistentSessionMode(environment);
const requestedRuntime = readFullLiveRuntime(environment);
const ownedLineReader = readLine ? null : createInputLineReader();
const nextLine = readLine ?? ownedLineReader.readLine;
const prepareAttempt = () =>
prepareLiveAttempt({
buildArtifact,
environment,
output,
readLine: nextLine,
readOptions,
requestedRuntime,
});
let browser;
try {
const initialOptions = await prepareAttempt();
output.log("WAITING_FOR_CHROMIUM_DEBUG_APPROVAL");
browser = await connectBrowser(initialOptions);
const selectedSession = await selectContext(browser, initialOptions.expectedChannel);
output.log(
`AUTHENTICATED_CHROMIUM_CONTEXT_SELECTED context=${selectedSession.contextIndex + 1} page=${selectedSession.pageIndex + 1} handle=${initialOptions.expectedChannel}`,
);
return await runLiveAttemptLoop({
initialOptions,
output,
persistentSession,
prepareNextAttempt: prepareAttempt,
preserveEvidence,
readLine: nextLine,
resetEvidenceWorkspace,
runAttempt: (options, attemptNumber) =>
executeAttempt({ browser, options, output, readLine: nextLine, selectedSession, attemptNumber }),
});
} finally {
if (browser) await browser.close();
ownedLineReader?.close();
}
}
if (require.main === module) {
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
}
module.exports = {
assertReusableConnectionOptions,
closeLiveAttemptPage,
createInputLineReader,
createInteractiveLiveDriver,
createInteractiveVoteRecorderFactory,
createReactionTargetDetails,
formatReactionApprovalReadiness,
main,
normalizeLiveSessionCommand,
prepareLiveAttempt,
preserveLiveAttemptEvidence,
readPersistentSessionMode,
resetLiveAttemptEvidenceWorkspace,
resolveSafeLiveEvidenceDirectory,
runConnectedLiveAttempt,
runLiveAttemptLoop,
};
@@ -0,0 +1,73 @@
const REQUIRED_INTERACTIVE_READ_ONLY_SCENARIO_IDS = Object.freeze([
"shorts-render",
"channel-shorts-navigation",
"channel-watch-navigation",
"reload",
"responsive-visual",
"spa-navigation",
"watch-action-topology",
"sidebar-navigation-stress",
"watch-render",
]);
function assertExactScenarioOrder(actualScenarioIds, label) {
if (!Array.isArray(actualScenarioIds)) throw new TypeError(`${label} must be an array.`);
if (actualScenarioIds.some((scenarioId) => typeof scenarioId !== "string" || scenarioId.trim() === "")) {
throw new TypeError(`${label} must contain only non-empty scenario IDs.`);
}
const duplicates = actualScenarioIds.filter((scenarioId, index) => actualScenarioIds.indexOf(scenarioId) !== index);
if (duplicates.length > 0) {
throw new Error(`${label} contains duplicate scenario IDs: ${[...new Set(duplicates)].join(", ")}.`);
}
const actual = new Set(actualScenarioIds);
const required = new Set(REQUIRED_INTERACTIVE_READ_ONLY_SCENARIO_IDS);
const missing = REQUIRED_INTERACTIVE_READ_ONLY_SCENARIO_IDS.filter((scenarioId) => !actual.has(scenarioId));
const unexpected = actualScenarioIds.filter((scenarioId) => !required.has(scenarioId));
if (missing.length > 0 || unexpected.length > 0) {
throw new Error(
`${label} does not match the required interactive read-only set. Missing: ${
missing.join(", ") || "none"
}; unexpected: ${unexpected.join(", ") || "none"}.`,
);
}
const firstMismatch = actualScenarioIds.findIndex(
(scenarioId, index) => scenarioId !== REQUIRED_INTERACTIVE_READ_ONLY_SCENARIO_IDS[index],
);
if (actualScenarioIds.length !== REQUIRED_INTERACTIVE_READ_ONLY_SCENARIO_IDS.length || firstMismatch !== -1) {
throw new Error(`${label} does not follow the required interactive read-only order.`);
}
return true;
}
function assertUniqueDefinitionField(definitions, field) {
const values = definitions.map((definition) => definition?.[field]);
if (values.some((value) => typeof value !== "string" || value.trim() === "")) {
throw new TypeError(`Every interactive read-only scenario requires a non-empty ${field}.`);
}
if (new Set(values).size !== values.length) {
throw new Error(`Interactive read-only scenario ${field} values must be unique.`);
}
}
function assertInteractiveReadOnlyScenarioDefinitions(definitions) {
if (!Array.isArray(definitions)) throw new TypeError("Interactive read-only scenario definitions must be an array.");
assertUniqueDefinitionField(definitions, "key");
assertUniqueDefinitionField(definitions, "name");
return assertExactScenarioOrder(
definitions.map((definition) => definition?.scenarioId),
"Interactive read-only scenario definitions",
);
}
function assertInteractiveReadOnlyScenarioCompletions(completedScenarioIds) {
return assertExactScenarioOrder(completedScenarioIds, "Completed interactive read-only scenarios");
}
module.exports = {
REQUIRED_INTERACTIVE_READ_ONLY_SCENARIO_IDS,
assertInteractiveReadOnlyScenarioCompletions,
assertInteractiveReadOnlyScenarioDefinitions,
};
+587 -66
View File
@@ -1,7 +1,16 @@
const assert = require("node:assert/strict"); const assert = require("node:assert/strict");
const fs = require("node:fs"); const fs = require("node:fs");
const path = require("node:path"); const path = require("node:path");
const { MIN_LIVE_SHORTS_NEXT_HOPS } = require("../../live/live-navigation-constants");
const { WATCH_RATIO_SOAK_DURATION_MS } = require("./live-youtube-driver"); const { WATCH_RATIO_SOAK_DURATION_MS } = require("./live-youtube-driver");
const {
DISLIKED_STATE,
LIKED_STATE,
NEUTRAL_STATE,
applyVoteTransitionCounts,
resolveVoteTransition,
} = require("../../../common/vote-transition-core");
const { assertExpectedWatchCounts } = require("./watch-ratio-audit");
const RESPONSIVE_VIEWPORTS = [ const RESPONSIVE_VIEWPORTS = [
{ height: 720, width: 1280 }, { height: 720, width: 1280 },
@@ -9,16 +18,38 @@ const RESPONSIVE_VIEWPORTS = [
{ height: 844, width: 390 }, { height: 844, width: 390 },
]; ];
const WATCH_ACTION_TOPOLOGY_VIEWPORTS = [
{ height: 900, name: "wide-constrained", width: 1536 },
{ height: 800, name: "stacked", width: 1300 },
{ height: 800, name: "narrow", width: 820 },
];
const REACTION_CYCLES = { const REACTION_CYCLES = {
neutral: ["like", "like", "dislike", "like", "dislike", "dislike"], neutral: ["like", "like", "dislike", "like", "dislike", "dislike"],
liked: ["like", "dislike", "like", "dislike", "dislike", "like"], liked: ["like", "dislike", "like", "dislike", "dislike", "like"],
disliked: ["like", "dislike", "dislike", "like", "like", "dislike"], disliked: ["like", "dislike", "dislike", "like", "like", "dislike"],
}; };
const LIVE_TO_SHARED_REACTION_STATE = {
disliked: DISLIKED_STATE,
liked: LIKED_STATE,
neutral: NEUTRAL_STATE,
};
const SHARED_TO_LIVE_REACTION_STATE = Object.fromEntries(
Object.entries(LIVE_TO_SHARED_REACTION_STATE).map(([liveState, sharedState]) => [sharedState, liveState]),
);
function resolveLiveVoteTransition(state, action) {
const sharedState = LIVE_TO_SHARED_REACTION_STATE[state];
assert.ok(sharedState, `Unsupported live reaction state: ${state}`);
const transition = resolveVoteTransition(sharedState, action);
const nextState = SHARED_TO_LIVE_REACTION_STATE[transition.nextState];
assert.ok(nextState, `Unsupported shared reaction state: ${transition.nextState}`);
return { ...transition, nextState };
}
function nextReactionState(state, action) { function nextReactionState(state, action) {
if (action === "like") return state === "liked" ? "neutral" : "liked"; return resolveLiveVoteTransition(state, action).nextState;
if (action === "dislike") return state === "disliked" ? "neutral" : "disliked";
throw new Error(`Unsupported reaction action: ${action}`);
} }
function reactionValue(state) { function reactionValue(state) {
@@ -35,39 +66,40 @@ async function assertLivePreconditions(driver, options) {
async function runWatchRenderScenario(driver, options) { async function runWatchRenderScenario(driver, options) {
return driver.withNoProductionInteractions(async () => { return driver.withNoProductionInteractions(async () => {
await driver.openPlaylist(options.playlistUrl, options.watchA); const navigation = await driver.openPlaylist(options.playlistUrl, options.watchA);
await assertLivePreconditions(driver, options); await assertLivePreconditions(driver, options);
return driver.waitForDislikeText(); return driver.assertCurrentWatchResult(options.watchA, options.runtime, navigation.body);
}); });
} }
async function runReloadScenario(driver, options) { async function runReloadScenario(driver, options) {
return driver.withNoProductionInteractions(async () => { return driver.withNoProductionInteractions(async () => {
await driver.openPlaylist(options.playlistUrl, options.watchA); const initialNavigation = await driver.openPlaylist(options.playlistUrl, options.watchA);
await assertLivePreconditions(driver, options); await assertLivePreconditions(driver, options);
await driver.waitForDislikeText(); const initial = await driver.assertCurrentWatchResult(options.watchA, options.runtime, initialNavigation.body);
await driver.reload(options.watchA); const reloadNavigation = await driver.reload(options.watchA);
await assertLivePreconditions(driver, options); await assertLivePreconditions(driver, options);
return driver.waitForDislikeText(); const reloaded = await driver.assertCurrentWatchResult(options.watchA, options.runtime, reloadNavigation.body);
return { initial, reloaded };
}); });
} }
async function runSpaNavigationScenario(driver, options) { async function runSpaNavigationScenario(driver, options) {
return driver.withNoProductionInteractions(async () => { return driver.withNoProductionInteractions(async () => {
await driver.openPlaylist(options.playlistUrl, options.watchA); const watchANavigation = await driver.openPlaylist(options.playlistUrl, options.watchA);
await assertLivePreconditions(driver, options); await assertLivePreconditions(driver, options);
const watchACount = await driver.waitForDislikeText(); const watchA = await driver.assertCurrentWatchResult(options.watchA, options.runtime, watchANavigation.body);
await driver.navigateWithinPlaylist(options.watchB); const watchBNavigation = await driver.navigateWithinPlaylist({ excludedVideoIds: [options.watchA] });
const destinationVideoId = watchBNavigation.videoId;
assert.match(destinationVideoId, /^[A-Za-z0-9_-]{11}$/, "Playlist navigation returned an invalid video ID.");
assert.notEqual(destinationVideoId, options.watchA, "Playlist navigation returned the source video.");
await assertLivePreconditions(driver, options); await assertLivePreconditions(driver, options);
const watchBCount = await driver.waitForDislikeText({ differentFrom: watchACount }); const watchB = await driver.assertCurrentWatchResult(destinationVideoId, options.runtime, watchBNavigation.body);
const watchACount = watchA.count;
const watchBCount = watchB.count;
assert.match(watchACount, /\d/); assert.match(watchACount, /\d/);
assert.match(watchBCount, /\d/); assert.match(watchBCount, /\d/);
assert.notEqual( return { destinationVideoId, watchA, watchACount, watchB, watchBCount };
watchBCount,
watchACount,
"Choose allowlisted playlist videos whose rendered dislike counts differ so stale SPA UI can be detected.",
);
return { watchACount, watchBCount };
}); });
} }
@@ -106,26 +138,31 @@ async function runSidebarStressScenario(
outputDirectory, outputDirectory,
`${options.runtime}-sidebar-hop-${String(index + 1).padStart(2, "0")}.png`, `${options.runtime}-sidebar-hop-${String(index + 1).padStart(2, "0")}.png`,
); );
const readinessStartedAt = Date.now();
const visual = await driver.captureWatchRatioVisual(options.runtime, screenshotPath, { const visual = await driver.captureWatchRatioVisual(options.runtime, screenshotPath, {
expectedCounts: body,
expectedVideoId: videoId,
presenceTimeoutMs: readyTimeoutMs, presenceTimeoutMs: readyTimeoutMs,
}); });
const readyLatencyMs = Date.now() - readinessStartedAt; const readyLatencyMs = visual.presenceLatencyMs;
assert.ok( assert.ok(
readyLatencyMs <= readyTimeoutMs, Number.isFinite(readyLatencyMs) && readyLatencyMs <= readyTimeoutMs,
`Sidebar hop ${index + 1} ratio bar took ${readyLatencyMs}ms; the budget is ${readyTimeoutMs}ms.`, `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.`); assert.match(visual.count, /\d/, `Sidebar hop ${index + 1} did not render a dislike count.`);
const countAudit = await driver.assertRenderedDislikeCount(visual.count, body.dislikes, options.runtime);
const soak = await driver.soakWatchRatioVisual(options.runtime, { const soak = await driver.soakWatchRatioVisual(options.runtime, {
durationMs: soakDurationMs, durationMs: soakDurationMs,
expectedCount: visual.count, expectedCount: visual.count,
expectedCounts: body,
videoId, videoId,
}); });
visitedVideoIds.push(videoId); visitedVideoIds.push(videoId);
hops.push({ hops.push({
apiDislikes: body.dislikes, apiDislikes: body.dislikes,
apiLikes: body.likes,
count: visual.count, count: visual.count,
countAudit,
readyLatencyMs, readyLatencyMs,
readyTimeoutMs, readyTimeoutMs,
screenshotPath, screenshotPath,
@@ -138,43 +175,402 @@ async function runSidebarStressScenario(
}); });
} }
async function runShortsRenderScenario(driver, options) { async function runWatchActionTopologyScenario(
driver,
options,
{
makeDirectory = (directory) => fs.mkdirSync(directory, { recursive: true }),
outputDirectory = path.resolve(__dirname, "../../../../test-results/live-youtube/watch-actions"),
} = {},
) {
makeDirectory(outputDirectory);
return driver.withNoProductionInteractions(async () => { return driver.withNoProductionInteractions(async () => {
await driver.openShort(options.short); const originalViewport = await driver.readViewportSize();
await assertLivePreconditions(driver, options); const coldLayouts = [];
return driver.waitForDislikeText(); let baseline;
try {
for (const viewport of WATCH_ACTION_TOPOLOGY_VIEWPORTS) {
await driver.setViewportSize(viewport);
const navigation = await driver.openWatch(options.watchA);
await assertLivePreconditions(driver, options);
const count = await driver.waitForDislikeText();
await driver.assertRenderedDislikeCount(count, navigation.body.dislikes, options.runtime);
const topology = await driver.captureWatchActionTopologyVisual(
options.runtime,
path.join(outputDirectory, `${options.runtime}-cold-${viewport.name}-${viewport.width}.png`),
{
expectedCounts: navigation.body,
expectedInventorySignatures: baseline?.inventorySignatures ?? null,
minimumTopLevelOptionalActions: baseline ? 0 : 1,
},
);
if (!baseline) {
baseline = topology;
}
coldLayouts.push(topology);
}
const coldLayoutRecords = coldLayouts.map((topology, index) => ({
topology,
viewport: WATCH_ACTION_TOPOLOGY_VIEWPORTS[index],
}));
const expansionTransitions = coldLayoutRecords
.flatMap((source) => coldLayoutRecords.map((destination) => ({ destination, source })))
.filter(
({ destination, source }) =>
destination.topology.topLevelOptionalSignatures.length >
source.topology.topLevelOptionalSignatures.length &&
source.topology.topLevelOptionalSignatures.every((signature) =>
destination.topology.topLevelOptionalSignatures.includes(signature),
),
)
.sort(
(left, right) =>
right.destination.topology.topLevelOptionalSignatures.length -
left.destination.topology.topLevelOptionalSignatures.length ||
left.source.topology.topLevelOptionalSignatures.length -
right.source.topology.topLevelOptionalSignatures.length,
);
const { destination: resizeDestination, source: resizeSource } = expansionTransitions[0] ?? {};
assert.ok(
resizeDestination,
"The configured cold Watch widths did not expose a layout with a strict superset of top-level actions.",
);
await driver.setViewportSize(resizeSource.viewport);
const resizeNavigation = await driver.openWatch(options.watchA);
await assertLivePreconditions(driver, options);
const resizeCount = await driver.waitForDislikeText();
await driver.assertRenderedDislikeCount(resizeCount, resizeNavigation.body.dislikes, options.runtime);
const resizeRoundTripSource = await driver.captureWatchActionTopologyVisual(
options.runtime,
path.join(
outputDirectory,
`${options.runtime}-resize-source-${resizeSource.viewport.name}-${resizeSource.viewport.width}.png`,
),
{
expectedCounts: resizeNavigation.body,
expectedInventorySignatures: baseline.inventorySignatures,
expectedTopLevelOptionalSignatures: resizeSource.topology.topLevelOptionalSignatures,
minimumTopLevelOptionalActions: resizeSource.topology.topLevelOptionalSignatures.length,
},
);
await driver.setViewportSize(resizeDestination.viewport);
await driver.waitForDislikeText();
const resizeRoundTripDestination = await driver.captureWatchActionTopologyVisual(
options.runtime,
path.join(
outputDirectory,
`${options.runtime}-resize-destination-${resizeDestination.viewport.name}-${resizeDestination.viewport.width}.png`,
),
{
expectedCounts: resizeNavigation.body,
expectedInventorySignatures: baseline.inventorySignatures,
expectedTopLevelOptionalSignatures: resizeDestination.topology.topLevelOptionalSignatures,
minimumTopLevelOptionalActions: resizeDestination.topology.topLevelOptionalSignatures.length,
},
);
await driver.setViewportSize(WATCH_ACTION_TOPOLOGY_VIEWPORTS[0]);
const { body: sidebarBody, videoId: sidebarVideoId } = await driver.navigateToRelatedWatch([options.watchA]);
await assertLivePreconditions(driver, options);
const sidebarCount = await driver.waitForDislikeText();
await driver.assertRenderedDislikeCount(sidebarCount, sidebarBody.dislikes, options.runtime);
const sidebar = await driver.captureWatchActionTopologyVisual(
options.runtime,
path.join(outputDirectory, `${options.runtime}-sidebar-${sidebarVideoId}.png`),
{ expectedCounts: sidebarBody, minimumTopLevelOptionalActions: 1 },
);
return {
coldLayouts,
outputDirectory,
resizeRoundTrip: {
destination: resizeRoundTripDestination,
destinationViewport: resizeDestination.viewport,
source: resizeRoundTripSource,
sourceViewport: resizeSource.viewport,
},
sidebar,
sidebarVideoId,
};
} finally {
await driver.setViewportSize(originalViewport);
}
}); });
} }
async function runChannelShortsNavigationScenario(driver, options) { async function runShortsRenderScenario(
return driver.withNoProductionInteractions(async () => { driver,
await driver.navigateFromColdChannelToShort(options.navigation.channelUrl, options.navigation.short); options,
await assertLivePreconditions(driver, options); {
const initial = await driver.assertCurrentShortsControl(options.navigation.short, options.runtime); makeDirectory = (directory) => fs.mkdirSync(directory, { recursive: true }),
outputDirectory = path.resolve(__dirname, "../../../../test-results/live-youtube/specific-short"),
} = {},
) {
const captureShortsVisual = {
"native-pair": (screenshotPath) => driver.captureNativeShortsVisual(options.short, screenshotPath),
"strict-synthetic": (screenshotPath) => driver.captureSyntheticShortsVisual(options.short, screenshotPath),
}[options.capabilities?.shortsVisualModel];
assert.equal(
typeof captureShortsVisual,
"function",
`Unsupported live Shorts visual model: ${options.capabilities?.shortsVisualModel ?? "missing"}.`,
);
makeDirectory(outputDirectory);
const nextVideoId = await driver.navigateToNextShort(options.navigation.short); return driver.withNoProductionInteractions(async () => {
await driver.assertRuntime(options.runtime, options.expectedVersion, options.expectedBuildId); const validate = async (navigation, phase) => {
const next = await driver.assertCurrentShortsControl(nextVideoId, options.runtime); await assertLivePreconditions(driver, options);
await driver.pausePlayback(); assert.equal(navigation?.videoId, options.short, `${phase} targeted a stale Short.`);
assert.notEqual( assert.equal(navigation?.status, 200, `${phase} did not receive HTTP 200 for ${options.short}.`);
next.videoId, assert.ok(
initial.videoId, Number.isSafeInteger(navigation?.body?.dislikes) && navigation.body.dislikes >= 0,
"The Shorts Next video scenario did not change the current video ID.", `${phase} has no valid production dislike count for ${options.short}.`,
);
const control = await driver.assertCurrentShortsControl(options.short, options.runtime, {
expectedDislikes: navigation.body.dislikes,
});
const visual = await captureShortsVisual(
path.join(outputDirectory, `${options.runtime}-${phase.toLowerCase().replace(/[^a-z0-9]+/g, "-")}.png`),
);
const stability = await driver.soakCurrentShortsControl(options.short, options.runtime, navigation.body.dislikes);
return { control, navigation, stability, visual };
};
const direct = await validate(await driver.openShort(options.short), "cold-direct-load");
const reloaded = await validate(await driver.reload(options.short), "reload");
return { direct, outputDirectory, reloaded, videoId: options.short };
});
}
async function runChannelShortsNavigationScenario(
driver,
options,
{
makeDirectory = (directory) => fs.mkdirSync(directory, { recursive: true }),
outputDirectory = path.resolve(__dirname, "../../../../test-results/live-youtube/shorts-navigation"),
writeFile = (filePath, contents) => fs.writeFileSync(filePath, contents, "utf8"),
} = {},
) {
const hopCount = options.navigation?.shortsNextHops;
assert.ok(
Number.isSafeInteger(hopCount) && hopCount >= MIN_LIVE_SHORTS_NEXT_HOPS,
`The Shorts navigation smoke requires at least ${MIN_LIVE_SHORTS_NEXT_HOPS} successful Next samples.`,
);
const captureShortsVisual = {
"native-pair": (videoId, screenshotPath) => driver.captureNativeShortsVisual(videoId, screenshotPath),
"strict-synthetic": (videoId, screenshotPath) => driver.captureSyntheticShortsVisual(videoId, screenshotPath),
}[options.capabilities?.shortsVisualModel];
assert.equal(
typeof captureShortsVisual,
"function",
`Unsupported live Shorts visual model: ${options.capabilities?.shortsVisualModel ?? "missing"}.`,
);
makeDirectory(outputDirectory);
return driver.withNoProductionInteractions(async () => {
const initialNavigation = await driver.navigateFromColdChannelToShort(
options.navigation.channelUrl,
options.navigation.short,
); );
return { initial, next }; await assertLivePreconditions(driver, options);
const visitedVideoIds = [options.navigation.short];
const hops = [];
const skipped = [];
let previousVideoId = options.navigation.short;
const recordBlankSample = async (navigation, source, attemptNumber) => {
const evidenceName = `${options.runtime}-shorts-blank-${source}-${String(attemptNumber).padStart(2, "0")}-${
navigation.videoId
}`;
const screenshotPath = path.join(outputDirectory, `${evidenceName}.png`);
const inventoryPath = path.join(outputDirectory, `${evidenceName}.json`);
const diagnostics = await driver.captureBlankShortsDiagnostics(navigation.videoId, screenshotPath);
if (diagnostics.nativeControlsAfterEvidence.status !== "blank") {
navigation.nativeControls = diagnostics.nativeControlsAfterEvidence;
driver.reportProgress("shorts-sample.recovered-after-evidence", {
attemptNumber,
source,
status: diagnostics.nativeControlsAfterEvidence.status,
videoId: navigation.videoId,
});
return null;
}
const record = {
attemptNumber,
durationMs: navigation.nativeControls.observedForMs,
inventoryPath,
reason: navigation.nativeControls.reason,
screenshotPath,
source,
videoId: navigation.videoId,
votesRequestObserved: navigation.request !== null,
};
writeFile(
inventoryPath,
`${JSON.stringify(
{
diagnostics,
nativeControls: navigation.nativeControls,
sample: record,
},
null,
2,
)}\n`,
);
skipped.push(record);
driver.reportBlankShortsSample(record);
return record;
};
const assertReadyNavigation = (navigation, label) => {
assert.equal(
navigation?.nativeControls?.status,
"present",
`${label} did not provide a native Shorts control classification.`,
);
assert.equal(navigation.status, 200, `${label} did not receive HTTP 200 for ${navigation.videoId}.`);
assert.equal(navigation.request?.method, "GET", `${label} did not issue a GET /votes request.`);
assert.equal(
navigation.request?.videoId,
navigation.videoId,
`${label} requested votes for stale video ${navigation.request?.videoId} instead of ${navigation.videoId}.`,
);
assert.ok(
Number.isSafeInteger(navigation.body?.dislikes) && navigation.body.dislikes >= 0,
`${label} has no valid production dislike count.`,
);
};
let initial = null;
let initialStability = null;
let initialVisual = null;
if (initialNavigation.nativeControls?.status === "blank") {
await recordBlankSample(initialNavigation, "channel", 0);
}
if (initialNavigation.nativeControls?.status !== "blank") {
assertReadyNavigation(initialNavigation, "The initial channel-to-Short transition");
initial = await driver.assertCurrentShortsControl(options.navigation.short, options.runtime, {
expectedDislikes: initialNavigation.body.dislikes,
});
initialStability = await driver.soakCurrentShortsControl(
options.navigation.short,
options.runtime,
initialNavigation.body.dislikes,
);
initialVisual = await captureShortsVisual(
options.navigation.short,
path.join(outputDirectory, `${options.runtime}-shorts-hop-00-${options.navigation.short}.png`),
);
}
const maximumNextAttempts = hopCount * 2;
const expectedValidSamples = hopCount + (initial === null ? 0 : 1);
const maximumBlankSamples = expectedValidSamples - 1;
let attemptedNextHops = 0;
while (hops.length < hopCount && attemptedNextHops < maximumNextAttempts && skipped.length <= maximumBlankSamples) {
attemptedNextHops += 1;
const navigation = await driver.navigateToNextShort(previousVideoId);
const { videoId } = navigation;
assert.match(
videoId,
/^[A-Za-z0-9_-]{11}$/,
`Shorts Next attempt ${attemptedNextHops} produced an invalid video ID.`,
);
assert.ok(
!visitedVideoIds.includes(videoId),
`Shorts Next attempt ${attemptedNextHops} revisited an earlier video ${videoId}.`,
);
visitedVideoIds.push(videoId);
previousVideoId = videoId;
if (navigation.nativeControls?.status === "blank") {
const skippedNavigation = await recordBlankSample(navigation, "next", attemptedNextHops);
if (skippedNavigation !== null) continue;
}
const hopNumber = hops.length + 1;
const label = `Shorts successful Next sample ${hopNumber} (attempt ${attemptedNextHops})`;
assertReadyNavigation(navigation, label);
const { body, request, status } = navigation;
await driver.assertRuntime(options.runtime, options.expectedVersion, options.expectedBuildId);
const control = await driver.assertCurrentShortsControl(videoId, options.runtime, {
expectedDislikes: body.dislikes,
});
assert.equal(control.videoId, videoId, `${label} returned a stale control owner.`);
assert.equal(control.syntheticVideoId, videoId, `${label} retained a stale synthetic ID.`);
assert.equal(
control.activeActionBarSyntheticControls,
1,
`${label} has duplicate controls in its active action stack.`,
);
assert.equal(control.currentReelSyntheticControls, 1, `${label} has duplicate controls in its current reel.`);
assert.equal(control.visibleDocumentSyntheticControls, 1, `${label} has duplicate visible controls.`);
assert.equal(control.visibleStaleSyntheticControls, 0, `${label} has a visible stale-video control.`);
assert.match(control.count, /\p{Number}/u, `${label} did not render its dislike count.`);
const visual = await captureShortsVisual(
videoId,
path.join(
outputDirectory,
`${options.runtime}-shorts-hop-${String(hopNumber).padStart(2, "0")}-${videoId}.png`,
),
);
const stability = await driver.soakCurrentShortsControl(videoId, options.runtime, body.dislikes);
hops.push({
attemptNumber: attemptedNextHops,
body,
control,
nativeControls: navigation.nativeControls,
request,
stability,
status,
videoId,
visual,
});
}
assert.equal(
hops.length,
hopCount,
`The Shorts navigation smoke required ${hopCount} successful Next samples, but found ${hops.length} valid and ${skipped.length} blank samples across ${attemptedNextHops} Next attempts (limit ${maximumNextAttempts}).`,
);
const validSampleCount = hops.length + (initial === null ? 0 : 1);
assert.ok(
validSampleCount > skipped.length,
`The Shorts navigation smoke found too many broken YouTube samples: ${validSampleCount} valid versus ${skipped.length} blank.`,
);
await driver.pausePlayback();
return {
attemptedNextHops,
hops,
initial,
initialNavigation,
initialStability,
initialVisual,
maximumNextAttempts,
outputDirectory,
skipped,
successfulNextSamples: hops.length,
validSampleCount,
visitedVideoIds,
};
}); });
} }
async function runChannelWatchNavigationScenario(driver, options) { async function runChannelWatchNavigationScenario(driver, options) {
if (!options.navigation.watch) { if (!options.navigation.watch) {
throw new Error("RYD_LIVE_NAV_WATCH is required for the optional channel-to-watch scenario."); throw new Error("RYD_LIVE_NAV_WATCH is required for the channel-to-watch scenario.");
} }
return driver.withNoProductionInteractions(async () => { return driver.withNoProductionInteractions(async () => {
await driver.navigateFromColdChannelToWatch(options.navigation.channelUrl, options.navigation.watch); const navigation = await driver.navigateFromColdChannelToWatch(
options.navigation.channelUrl,
options.navigation.watch,
);
await assertLivePreconditions(driver, options); await assertLivePreconditions(driver, options);
const count = await driver.waitForDislikeText(); return driver.assertCurrentWatchResult(options.navigation.watch, options.runtime, navigation.body);
return { count, videoId: options.navigation.watch };
}); });
} }
@@ -186,6 +582,16 @@ async function runResponsiveVisualScenario(
outputDirectory = path.resolve(__dirname, "../../../../test-results/live-youtube/responsive"), outputDirectory = path.resolve(__dirname, "../../../../test-results/live-youtube/responsive"),
} = {}, } = {},
) { ) {
const shortsVisualModel = options.capabilities?.shortsVisualModel;
const captureShortsVisual = {
"native-pair": (screenshotPath) => driver.captureNativeShortsVisual(options.short, screenshotPath),
"strict-synthetic": (screenshotPath) => driver.captureSyntheticShortsVisual(options.short, screenshotPath),
}[shortsVisualModel];
assert.equal(
typeof captureShortsVisual,
"function",
`Unsupported live Shorts visual model: ${shortsVisualModel ?? "missing"}.`,
);
makeDirectory(outputDirectory); makeDirectory(outputDirectory);
return driver.withNoProductionInteractions(async () => { return driver.withNoProductionInteractions(async () => {
const originalViewport = await driver.readViewportSize(); const originalViewport = await driver.readViewportSize();
@@ -194,34 +600,38 @@ async function runResponsiveVisualScenario(
try { try {
await driver.setViewportSize(RESPONSIVE_VIEWPORTS[0]); await driver.setViewportSize(RESPONSIVE_VIEWPORTS[0]);
await driver.openWatch(options.watchA); const watchNavigation = await driver.openWatch(options.watchA);
await assertLivePreconditions(driver, options); await assertLivePreconditions(driver, options);
for (let index = 0; index < RESPONSIVE_VIEWPORTS.length; index += 1) { for (let index = 0; index < RESPONSIVE_VIEWPORTS.length; index += 1) {
const viewport = RESPONSIVE_VIEWPORTS[index]; const viewport = RESPONSIVE_VIEWPORTS[index];
if (index > 0) await driver.setViewportSize(viewport); if (index > 0) await driver.setViewportSize(viewport);
await driver.waitForDislikeText(); const count = await driver.waitForDislikeText();
await driver.assertRenderedDislikeCount(count, watchNavigation.body.dislikes, options.runtime);
watch.push( watch.push(
await driver.captureWatchRatioVisual( await driver.captureWatchRatioVisual(
options.runtime, options.runtime,
path.join(outputDirectory, `${options.runtime}-watch-ratio-${viewport.width}.png`), path.join(outputDirectory, `${options.runtime}-watch-ratio-${viewport.width}.png`),
{ expectedCounts: watchNavigation.body, expectedVideoId: options.watchA },
), ),
); );
} }
await driver.setViewportSize(RESPONSIVE_VIEWPORTS[0]); await driver.setViewportSize(RESPONSIVE_VIEWPORTS[0]);
await driver.openShort(options.short); const shortNavigation = await driver.openShort(options.short);
await assertLivePreconditions(driver, options); await assertLivePreconditions(driver, options);
for (let index = 0; index < RESPONSIVE_VIEWPORTS.length; index += 1) { for (let index = 0; index < RESPONSIVE_VIEWPORTS.length; index += 1) {
const viewport = RESPONSIVE_VIEWPORTS[index]; const viewport = RESPONSIVE_VIEWPORTS[index];
if (index > 0) await driver.setViewportSize(viewport); if (index > 0) await driver.setViewportSize(viewport);
await driver.assertCurrentShortsControl(options.short, options.runtime); const control = await driver.assertCurrentShortsControl(options.short, options.runtime, {
await driver.waitForDislikeText(); expectedDislikes: shortNavigation.body.dislikes,
});
const count = await driver.waitForDislikeText();
await driver.assertRenderedDislikeCount(count, shortNavigation.body.dislikes, options.runtime);
const screenshotPath = path.join(outputDirectory, `${options.runtime}-shorts-control-${viewport.width}.png`); const screenshotPath = path.join(outputDirectory, `${options.runtime}-shorts-control-${viewport.width}.png`);
shorts.push( shorts.push({
options.runtime === "userscript" control,
? await driver.captureSyntheticShortsVisual(options.short, screenshotPath) visual: await captureShortsVisual(screenshotPath),
: await driver.captureNativeShortsVisual(options.short, screenshotPath), });
);
} }
} finally { } finally {
await driver.setViewportSize(originalViewport); await driver.setViewportSize(originalViewport);
@@ -333,24 +743,40 @@ async function runReactionCycle(
options, options,
{ beforeFirstAction, captureReactionVisual = null, isShort = false, videoId }, { beforeFirstAction, captureReactionVisual = null, isShort = false, videoId },
) { ) {
if (isShort) await driver.openShort(videoId); const navigation = isShort ? await driver.openShort(videoId) : await driver.openWatch(videoId);
else await driver.openWatch(videoId); assertExpectedWatchCounts(navigation?.body);
await assertLivePreconditions(driver, options); await assertLivePreconditions(driver, options);
await driver.waitForDislikeText(); const baselineCount = await driver.waitForDislikeText();
await driver.assertRenderedDislikeCount(baselineCount, navigation.body.dislikes, options.runtime);
const initialState = await driver.readReactionState(); const initialState = await driver.readReactionState();
const actions = REACTION_CYCLES[initialState]; const actions = REACTION_CYCLES[initialState];
assert.ok(actions, `Unsupported initial YouTube reaction state: ${initialState}`); assert.ok(actions, `Unsupported initial YouTube reaction state: ${initialState}`);
let projectedState = initialState;
let projectedCounts = { dislikes: navigation.body.dislikes, likes: navigation.body.likes };
const observableDislikeChanges = [];
for (const action of actions) {
const transition = resolveLiveVoteTransition(projectedState, action);
const nextCounts = applyVoteTransitionCounts(projectedCounts.likes, projectedCounts.dislikes, transition);
if (transition.dislikesDelta !== 0) {
observableDislikeChanges.push({ after: nextCounts.dislikes, before: projectedCounts.dislikes });
}
projectedCounts = nextCounts;
projectedState = transition.nextState;
}
await driver.assertDislikeCountChangesObservable(observableDislikeChanges, options.runtime);
let authorized = false; let authorized = false;
let completed = false; let completed = false;
let expectedUserId; let expectedUserId;
let failedAttempt; let failedAttempt;
let currentState = initialState; let currentState = initialState;
const initialCounts = { dislikes: navigation.body.dislikes, likes: navigation.body.likes };
let expectedCounts = { ...initialCounts };
const evidencePaths = []; const evidencePaths = [];
const captureEvidence = async (index) => { const captureEvidence = async (index) => {
if (!captureReactionVisual) return; if (!captureReactionVisual) return;
const screenshotPath = await captureReactionVisual({ index, state: currentState }); const screenshotPath = await captureReactionVisual({ counts: { ...expectedCounts }, index, state: currentState });
assert.equal(typeof screenshotPath, "string", "The reaction visual capture did not return an evidence path."); assert.equal(typeof screenshotPath, "string", "The reaction visual capture did not return an evidence path.");
evidencePaths.push(screenshotPath); evidencePaths.push(screenshotPath);
}; };
@@ -363,9 +789,11 @@ async function runReactionCycle(
for (let index = 0; index < actions.length; index += 1) { for (let index = 0; index < actions.length; index += 1) {
const action = actions[index]; const action = actions[index];
const expectedState = nextReactionState(currentState, action); const transition = resolveLiveVoteTransition(currentState, action);
const expectedState = transition.nextState;
const nextCounts = applyVoteTransitionCounts(expectedCounts.likes, expectedCounts.dislikes, transition);
const mark = recorder.mark(); const mark = recorder.mark();
const value = reactionValue(expectedState); const value = transition.value;
failedAttempt = { mark, userId: undefined, value }; failedAttempt = { mark, userId: undefined, value };
await driver.clickAction(videoId, action); await driver.clickAction(videoId, action);
await driver.waitForReactionState(expectedState); await driver.waitForReactionState(expectedState);
@@ -375,13 +803,15 @@ async function runReactionCycle(
expectedUserId = userId; expectedUserId = userId;
failedAttempt = undefined; failedAttempt = undefined;
currentState = expectedState; currentState = expectedState;
await driver.waitForDislikeText(); expectedCounts = nextCounts;
const renderedCount = await driver.waitForDislikeText();
await driver.assertRenderedDislikeCount(renderedCount, expectedCounts.dislikes, options.runtime);
await captureEvidence(index + 1); await captureEvidence(index + 1);
} }
assert.equal(currentState, initialState, "The six-transition cycle did not return to its initial state."); assert.equal(currentState, initialState, "The six-transition cycle did not return to its initial state.");
completed = true; completed = true;
return { evidencePaths, initialState, userId: expectedUserId }; return { evidencePaths, finalCounts: expectedCounts, initialCounts, initialState, userId: expectedUserId };
} finally { } finally {
if (authorized && !completed) { if (authorized && !completed) {
if (failedAttempt && !failedAttempt.userId && typeof recorder.voteUserId === "function") { if (failedAttempt && !failedAttempt.userId && typeof recorder.voteUserId === "function") {
@@ -416,16 +846,19 @@ async function runProductionReactionMatrixScenario(
const outputDirectory = const outputDirectory =
visualOptions.outputDirectory ?? visualOptions.outputDirectory ??
path.resolve(__dirname, "../../../../test-results/live-youtube/reactions", options.runtime); path.resolve(__dirname, "../../../../test-results/live-youtube/reactions", options.runtime);
const reactionShort = options.reactionShort ?? options.short;
makeDirectory(outputDirectory); makeDirectory(outputDirectory);
const captureFor = const captureFor =
(kind, videoId, isShort) => (kind, videoId, isShort) =>
async ({ index, state }) => { async ({ counts, index, state }) => {
const screenshotPath = path.join(outputDirectory, `${kind}-${index}-${state}.png`); const screenshotPath = path.join(outputDirectory, `${kind}-${index}-${state}.png`);
const evidence = await driver.captureReactionStateVisual({ const evidence = await driver.captureReactionStateVisual({
expectedCounts: counts,
expectedState: state, expectedState: state,
isShort, isShort,
runtime: options.runtime, runtime: options.runtime,
screenshotPath, screenshotPath,
shortsVisualModel: options.capabilities?.shortsVisualModel,
videoId, videoId,
}); });
assert.equal( assert.equal(
@@ -448,12 +881,12 @@ async function runProductionReactionMatrixScenario(
watchRecorder.stop(); watchRecorder.stop();
} }
const shortRecorder = createRecorder(options.short); const shortRecorder = createRecorder(reactionShort);
try { try {
const shortResult = await runReactionCycle(driver, shortRecorder, options, { const shortResult = await runReactionCycle(driver, shortRecorder, options, {
captureReactionVisual: captureFor("short", options.short, true), captureReactionVisual: captureFor("short", reactionShort, true),
isShort: true, isShort: true,
videoId: options.short, videoId: reactionShort,
}); });
assert.equal( assert.equal(
shortResult.userId, shortResult.userId,
@@ -471,6 +904,91 @@ async function runProductionReactionMatrixScenario(
} }
} }
async function runPostNavigationVoteScenario(driver, options, createRecorder, consumeVoteApproval) {
const recorder = createRecorder(options.watchB);
try {
await driver.openPlaylist(options.playlistUrl, options.watchA);
await assertLivePreconditions(driver, options);
driver.assertCurrentVideo(options.watchA);
const watchACount = await driver.waitForDislikeText();
const navigationMark = recorder.mark();
await driver.navigateWithinPlaylist(options.watchB);
await assertLivePreconditions(driver, options);
driver.assertCurrentVideo(options.watchB);
const watchBCount = await driver.waitForDislikeText({ differentFrom: watchACount });
assert.notEqual(
watchBCount,
watchACount,
"Choose allowlisted playlist videos whose rendered dislike counts differ so stale SPA UI can be detected.",
);
assert.equal(
recorder.mark(),
navigationMark,
"SPA navigation emitted unexpected production interaction traffic before any reaction click.",
);
const initialState = await driver.readReactionState();
const action = "dislike";
const selectedState = nextReactionState(initialState, action);
const selectedValue = reactionValue(selectedState);
const voteMark = navigationMark;
let authorized = false;
let failedAttempt = { mark: voteMark, userId: undefined, value: selectedValue };
let userId;
try {
driver.assertCurrentVideo(options.watchB);
await assertLivePreconditions(driver, options);
await consumeVoteApproval();
authorized = true;
await driver.clickAction(options.watchB, action);
await driver.waitForReactionState(selectedState);
userId = await recorder.waitForHandshake(selectedValue, voteMark);
failedAttempt = undefined;
} finally {
if (authorized) {
if (failedAttempt && !failedAttempt.userId && typeof recorder.voteUserId === "function") {
try {
failedAttempt.userId = recorder.voteUserId(failedAttempt.value, failedAttempt.mark);
} catch {
failedAttempt.userId = undefined;
}
}
await restoreReactionState(
driver,
recorder,
options,
options.watchB,
initialState,
userId,
false,
failedAttempt,
);
}
}
driver.assertCurrentVideo(options.watchB);
assert.equal(
await driver.readReactionState(),
initialState,
"The post-navigation vote scenario did not restore the destination video's initial reaction state.",
);
await driver.waitForDislikeText();
return {
action,
initialState,
selectedState,
userId,
videoId: options.watchB,
watchACount,
watchBCount,
};
} finally {
recorder.stop();
}
}
async function restoreNeutralStateUnchecked(driver, recorder, options, dislikeMark, expectedUserId) { async function restoreNeutralStateUnchecked(driver, recorder, options, dislikeMark, expectedUserId) {
let state; let state;
try { try {
@@ -554,8 +1072,10 @@ async function runReversibleVoteScenario(driver, recorder, options, consumeVoteA
module.exports = { module.exports = {
RESPONSIVE_VIEWPORTS, RESPONSIVE_VIEWPORTS,
WATCH_ACTION_TOPOLOGY_VIEWPORTS,
runChannelShortsNavigationScenario, runChannelShortsNavigationScenario,
runChannelWatchNavigationScenario, runChannelWatchNavigationScenario,
runPostNavigationVoteScenario,
runProductionReactionMatrixScenario, runProductionReactionMatrixScenario,
runReactionCycle, runReactionCycle,
runReloadScenario, runReloadScenario,
@@ -565,4 +1085,5 @@ module.exports = {
runShortsRenderScenario, runShortsRenderScenario,
runSpaNavigationScenario, runSpaNavigationScenario,
runWatchRenderScenario, runWatchRenderScenario,
runWatchActionTopologyScenario,
}; };
File diff suppressed because it is too large Load Diff
@@ -4,39 +4,79 @@ const {
createUserscriptLiveRuntimeAdapter, createUserscriptLiveRuntimeAdapter,
} = require("../../../e2e/live-runtime-adapter"); } = require("../../../e2e/live-runtime-adapter");
const { createSharedLiveScenarioRunner } = require("../../../e2e/shared-live-scenarios"); const { createSharedLiveScenarioRunner } = require("../../../e2e/shared-live-scenarios");
const { consumeLiveVoteApproval, hasFreshVoteApproval, readLiveOptions } = require("../../live/live-options"); const { readLiveOptions } = require("../../live/live-options");
const { LiveYoutubeDriver, VoteTrafficRecorder } = require("./live-youtube-driver"); const { selectAuthenticatedYoutubeContext } = require("./live-authenticated-context");
const { LiveReadOnlyGate, LiveRunDiagnostics, runLoggedStage } = require("./live-diagnostics");
const { reloadLiveExtensionInBrowser } = require("./live-extension-reloader");
const { REQUIRED_INTERACTIVE_READ_ONLY_SCENARIO_IDS } = require("./live-interactive-scenario-contract");
const { LiveYoutubeDriver } = require("./live-youtube-driver");
const options = readLiveOptions(); const options = readLiveOptions();
const scenarioRunner = createSharedLiveScenarioRunner(); const scenarioRunner = createSharedLiveScenarioRunner();
const REQUIRED_READ_ONLY_SCENARIOS = REQUIRED_INTERACTIVE_READ_ONLY_SCENARIO_IDS;
test.describe("live YouTube RYD smoke", () => { if (options === null) {
test.skip(options === null, "Set RYD_LIVE_YOUTUBE=1 and the documented allowlist variables to opt in."); throw new Error(
test.describe.configure({ mode: "serial" }); "No live YouTube scenarios ran. Set RYD_LIVE_YOUTUBE=1 and every documented runtime/allowlist variable.",
);
}
test.describe("live YouTube RYD read-only smoke", () => {
let browser; let browser;
let context; let context;
let diagnostics;
let page; let page;
let driver; let driver;
let runtimeAdapter; let runtimeAdapter;
let selectedExtensionId = null;
let sessionPage;
let currentReadOnlyScenarioId = null;
const readOnlyGate = new LiveReadOnlyGate(REQUIRED_READ_ONLY_SCENARIOS);
test.beforeAll(async () => { test.beforeAll(async ({}, testInfo) => {
testInfo.setTimeout(options.cdpConnectTimeoutMilliseconds + 15_000);
try { try {
browser = await chromium.connectOverCDP(options.cdpEndpoint, { browser = await chromium.connectOverCDP(options.cdpEndpoint, {
isLocal: true, isLocal: true,
noDefaults: true, noDefaults: true,
timeout: 15_000, timeout: options.cdpConnectTimeoutMilliseconds,
}); });
} catch (error) { } catch (error) {
throw new Error( throw new Error(
`Could not attach to the running Brave/Chromium profile. Enable remote debugging at brave://inspect/#remote-debugging and set RYD_CDP_ENDPOINT to its explicit CDP endpoint, then retry. ${error.message}`, `Could not attach to the running Chrome/Chromium profile. Enable remote debugging in the target browser and set RYD_CDP_ENDPOINT to its WebSocket, HTTP, host:port, or browser-alias endpoint, then retry. ${error.message}`,
); );
} }
[context] = browser.contexts(); const selectedSession = await selectAuthenticatedYoutubeContext(browser, options.expectedChannel);
if (!context) throw new Error("The attached Brave/Chromium browser has no default context."); context = selectedSession.context;
sessionPage = selectedSession.sessionPage;
console.log(
`AUTHENTICATED_CHROMIUM_CONTEXT_SELECTED context=${selectedSession.contextIndex + 1} page=${selectedSession.pageIndex + 1} handle=${options.expectedChannel}`,
);
if (options.runtime === "extension") {
const reloaded = await reloadLiveExtensionInBrowser({
browser,
context,
expectedBuildId: options.expectedBuildId,
expectedVersion: options.expectedVersion,
sessionPage,
});
selectedExtensionId = reloaded.extensionId;
console.log(`LIVE_EXTENSION_RELOADED ${JSON.stringify(reloaded)}`);
}
});
test.beforeEach(async () => {
currentReadOnlyScenarioId = null;
page = await context.newPage(); page = await context.newPage();
driver = new LiveYoutubeDriver(page, context); diagnostics = new LiveRunDiagnostics(page, context, { runtime: options.runtime, selectedExtensionId });
await diagnostics.start();
driver = new LiveYoutubeDriver(page, context, {
authenticatedHandle: options.expectedChannel,
expectedBuildId: options.expectedBuildId,
reportProgress: (name, details) => diagnostics.checkpoint(name, details),
selectedExtensionId,
});
const createAdapter = const createAdapter =
options.runtime === "extension" ? createExtensionLiveRuntimeAdapter : createUserscriptLiveRuntimeAdapter; options.runtime === "extension" ? createExtensionLiveRuntimeAdapter : createUserscriptLiveRuntimeAdapter;
runtimeAdapter = createAdapter({ runtimeAdapter = createAdapter({
@@ -46,68 +86,119 @@ test.describe("live YouTube RYD smoke", () => {
}); });
}); });
test.afterEach(async ({}, testInfo) => {
if (!diagnostics) return;
let lateSignalError = null;
let snapshotError = null;
try {
if (driver?.trafficLedger) {
await driver.assertNoUnclaimedAttributedTraffic(`Playwright scenario ${testInfo.title}`);
}
} catch (error) {
lateSignalError = error;
}
try {
diagnostics.consumeFatalSignals(`after ${testInfo.title}`);
} catch (error) {
lateSignalError = lateSignalError
? new AggregateError([lateSignalError, error], "Late traffic and browser diagnostics both failed.")
: error;
}
try {
if (lateSignalError || testInfo.status !== testInfo.expectedStatus) {
const error =
lateSignalError ?? testInfo.errors.at(-1) ?? new Error(`${testInfo.title} failed without an attached error.`);
const snapshotPath = await diagnostics.persistFailureSnapshot(error);
await testInfo.attach("live-failure-snapshot", { contentType: "application/json", path: snapshotPath });
}
} catch (error) {
snapshotError = error;
} finally {
if (currentReadOnlyScenarioId) {
const completed = !lateSignalError && !snapshotError && testInfo.status === "passed";
readOnlyGate.record(currentReadOnlyScenarioId, completed ? testInfo.status : "failed");
}
driver?.stop();
diagnostics.stop();
if (page && !page.isClosed()) await page.close();
diagnostics = null;
driver = null;
page = null;
runtimeAdapter = null;
}
if (lateSignalError && snapshotError) {
throw new AggregateError(
[lateSignalError, snapshotError],
"A late fatal browser signal and snapshot failure occurred.",
);
}
if (lateSignalError) throw lateSignalError;
if (snapshotError) throw snapshotError;
});
test.afterAll(async () => { test.afterAll(async () => {
if (page && !page.isClosed()) await page.close(); try {
if (browser) await browser.close(); readOnlyGate.assertPassed();
console.log(
`LIVE_VALIDATION_READ_ONLY_COMPLETE ${JSON.stringify({
classification: "read-only",
productionReactionsCompleted: false,
releaseReady: false,
})}`,
);
} finally {
if (browser) await browser.close();
}
}); });
test(`${options?.runtime || "runtime"}: initializes after cold channel-to-Short and Next-video SPA navigation`, async () => { const runScenario = (scenarioId, services) =>
test.setTimeout(150_000); runLoggedStage(diagnostics, `playwright.${scenarioId}`, () =>
await scenarioRunner.run(runtimeAdapter, "channel-shorts-navigation", options); scenarioRunner.run(runtimeAdapter, scenarioId, options, services),
});
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);
const runReadOnlyScenario = (scenarioId) => {
currentReadOnlyScenarioId = scenarioId;
return runScenario(scenarioId);
};
test(`${options?.runtime || "runtime"}: renders on an allowlisted Short`, async () => {
await runReadOnlyScenario("shorts-render");
});
test(`${options?.runtime || "runtime"}: audits ten-plus successful Shorts Next-video SPA samples`, async () => {
test.setTimeout(120_000 + options.navigation.shortsNextHops * 2 * 45_000);
await runReadOnlyScenario("channel-shorts-navigation");
});
test(`${options?.runtime || "runtime"}: initializes after cold channel-to-watch SPA navigation`, async () => {
test.setTimeout(120_000);
await runReadOnlyScenario("channel-watch-navigation");
}); });
test(`${options?.runtime || "runtime"}: renders on a signed-in watch page`, async () => { test(`${options?.runtime || "runtime"}: renders on a signed-in watch page`, async () => {
await scenarioRunner.run(runtimeAdapter, "watch-render", options); await runReadOnlyScenario("watch-render");
}); });
test(`${options?.runtime || "runtime"}: reinitializes after reload`, async () => { test(`${options?.runtime || "runtime"}: reinitializes after reload`, async () => {
await scenarioRunner.run(runtimeAdapter, "reload", options); await runReadOnlyScenario("reload");
}); });
test(`${options?.runtime || "runtime"}: reinitializes after real playlist SPA navigation`, async () => { test(`${options?.runtime || "runtime"}: reinitializes after real playlist SPA navigation`, async () => {
await scenarioRunner.run(runtimeAdapter, "spa-navigation", options); await runReadOnlyScenario("spa-navigation");
});
test(`${options?.runtime || "runtime"}: preserves the complete Watch action topology`, async () => {
test.setTimeout(240_000);
await runReadOnlyScenario("watch-action-topology");
}); });
test(`${options?.runtime || "runtime"}: survives consecutive real sidebar SPA navigations`, async () => { test(`${options?.runtime || "runtime"}: survives consecutive real sidebar SPA navigations`, async () => {
test.setTimeout(60_000 + options.sidebar.hopCount * 75_000); test.setTimeout(60_000 + options.sidebar.hopCount * 75_000);
await scenarioRunner.run(runtimeAdapter, "sidebar-navigation-stress", options); await runReadOnlyScenario("sidebar-navigation-stress");
});
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(`${options?.runtime || "runtime"}: preserves reaction UI geometry across responsive widths`, async () => {
test.setTimeout(180_000); test.setTimeout(180_000);
await scenarioRunner.run(runtimeAdapter, "responsive-visual", options); await runReadOnlyScenario("responsive-visual");
});
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.");
}
},
});
}); });
}); });
@@ -0,0 +1,63 @@
const fs = require("node:fs");
const path = require("node:path");
const { chromium } = require("@playwright/test");
const { resolveCdpEndpoint } = require("../../live/live-cdp-endpoint");
const { selectAuthenticatedYoutubeContext } = require("./live-authenticated-context");
const { LIVE_EXTENSION_PATH, reloadLiveExtensionInBrowser } = require("./live-extension-reloader");
const extensionPath = LIVE_EXTENSION_PATH;
function readJson(filePath) {
return JSON.parse(fs.readFileSync(filePath, "utf8"));
}
function connectTimeoutMilliseconds(environment) {
const configured = Number(environment.RYD_CDP_CONNECT_TIMEOUT_MS || 120_000);
if (!Number.isSafeInteger(configured) || configured < 15_000 || configured > 300_000) {
throw new Error("RYD_CDP_CONNECT_TIMEOUT_MS must be an integer from 15000 through 300000.");
}
return configured;
}
async function main() {
const expectedBuildId = readJson(path.join(extensionPath, "live-build.json")).buildId;
const expectedVersion = readJson(path.join(extensionPath, "manifest.json")).version;
const timeoutMilliseconds = connectTimeoutMilliseconds(process.env);
const cdpEndpoint = resolveCdpEndpoint(process.env.RYD_CDP_ENDPOINT);
let browser;
try {
browser = await chromium.connectOverCDP(cdpEndpoint, {
isLocal: true,
noDefaults: true,
timeout: timeoutMilliseconds,
});
const selectedSession = await selectAuthenticatedYoutubeContext(browser, process.env.RYD_LIVE_EXPECTED_CHANNEL);
const { context, sessionPage } = selectedSession;
console.log(
`AUTHENTICATED_CHROMIUM_CONTEXT_SELECTED context=${selectedSession.contextIndex + 1} page=${selectedSession.pageIndex + 1} handle=${process.env.RYD_LIVE_EXPECTED_CHANNEL}`,
);
const result = await reloadLiveExtensionInBrowser({
browser,
context,
expectedBuildId,
expectedExtensionPath: extensionPath,
expectedVersion,
sessionPage,
timeoutMilliseconds,
});
console.log(`LIVE_EXTENSION_RELOADED ${JSON.stringify(result)}`);
} finally {
if (browser) await browser.close();
}
}
if (require.main === module) {
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
}
module.exports = { connectTimeoutMilliseconds, main };
@@ -0,0 +1,236 @@
const assert = require("node:assert/strict");
const WATCH_NEGATIVE_TRACK_MINIMUM_CONTRAST = 3;
const WATCH_RATIO_GEOMETRY_TOLERANCE = 0.015;
const WATCH_RATIO_INLINE_TOLERANCE = 0.000_1;
function normalizeTooltipText(value) {
return String(value ?? "")
.normalize("NFKC")
.replace(/[\u200e\u200f\u202a-\u202e\u2066-\u2069]/giu, "")
.replace(/\s+/gu, " ")
.trim();
}
function formattedWatchTooltipCandidates(expectedCounts, locale) {
assertExpectedWatchCounts(expectedCounts);
const formatter = new Intl.NumberFormat(locale || undefined);
const total = expectedCounts.likes + expectedCounts.dislikes;
const widthPercent = total > 0 ? (expectedCounts.likes / total) * 100 : 50;
const likePercentageValue = Number.parseFloat(widthPercent.toFixed(1));
const dislikePercentageValue = 100 - likePercentageValue;
const likes = formatter.format(expectedCounts.likes);
const dislikes = formatter.format(expectedCounts.dislikes);
const likePercentage = formatter.format(likePercentageValue);
const dislikePercentage = formatter.format(dislikePercentageValue);
const countPair = `${likes} / ${dislikes}`;
return [
{ form: "counts", text: countPair },
{ form: "counts-with-like-percentage", text: `${countPair} - ${likePercentage}%` },
{ form: "counts-with-dislike-percentage", text: `${countPair} - ${dislikePercentage}%` },
{ form: "both-percentages", text: `${likePercentage}% / ${dislikePercentage}%` },
{ form: "like-percentage", text: `${likePercentage}%` },
{ form: "dislike-percentage", text: `${dislikePercentage}%` },
];
}
function clampColorChannel(value) {
return Math.min(255, Math.max(0, value));
}
function parseAlpha(value) {
if (value === undefined) return 1;
const parsed = value.endsWith("%") ? Number.parseFloat(value) / 100 : Number.parseFloat(value);
return Number.isFinite(parsed) ? Math.min(1, Math.max(0, parsed)) : null;
}
function parseRgbChannel(value) {
const parsed = value.endsWith("%") ? (Number.parseFloat(value) / 100) * 255 : Number.parseFloat(value);
return Number.isFinite(parsed) ? clampColorChannel(parsed) : null;
}
function parseCssColor(value) {
const color = String(value ?? "")
.trim()
.toLowerCase();
if (!color || color === "transparent") return color === "transparent" ? { a: 0, b: 0, g: 0, r: 0 } : null;
const hex = color.match(/^#([a-f0-9]{3,8})$/iu)?.[1];
if (hex) {
const expanded = hex.length === 3 || hex.length === 4 ? [...hex].map((digit) => digit + digit).join("") : hex;
if (expanded.length !== 6 && expanded.length !== 8) return null;
return {
a: expanded.length === 8 ? Number.parseInt(expanded.slice(6, 8), 16) / 255 : 1,
b: Number.parseInt(expanded.slice(4, 6), 16),
g: Number.parseInt(expanded.slice(2, 4), 16),
r: Number.parseInt(expanded.slice(0, 2), 16),
};
}
const rgb = color.match(/^rgba?\((.*)\)$/iu)?.[1];
if (rgb) {
const commaParts = rgb.includes(",") ? rgb.split(",").map((part) => part.trim()) : null;
const legacyAlpha = commaParts?.length === 4 ? commaParts.pop() : undefined;
const normalized = (commaParts ?? [rgb]).join(" ").replace(/\s*\/\s*/gu, " / ");
const [channelsPart, modernAlpha] = normalized.split(/\s+\/\s+/u);
const channels = channelsPart.trim().split(/\s+/u);
if (channels.length !== 3) return null;
const [r, g, b] = channels.map(parseRgbChannel);
const a = parseAlpha(modernAlpha ?? legacyAlpha);
return [r, g, b, a].every((channel) => channel !== null) ? { a, b, g, r } : null;
}
const srgb = color.match(/^color\(srgb\s+(.+)\)$/iu)?.[1];
if (srgb) {
const [channelsPart, alphaPart] = srgb.split(/\s+\/\s+/u);
const channels = channelsPart.trim().split(/\s+/u).map(Number.parseFloat);
const a = parseAlpha(alphaPart);
if (channels.length !== 3 || !channels.every(Number.isFinite) || a === null) return null;
return {
a,
b: clampColorChannel(channels[2] * 255),
g: clampColorChannel(channels[1] * 255),
r: clampColorChannel(channels[0] * 255),
};
}
return null;
}
function compositeColor(foreground, background) {
const alpha = foreground.a + background.a * (1 - foreground.a);
if (alpha === 0) return { a: 0, b: 0, g: 0, r: 0 };
const channel = (key) =>
(foreground[key] * foreground.a + background[key] * background.a * (1 - foreground.a)) / alpha;
return { a: alpha, b: channel("b"), g: channel("g"), r: channel("r") };
}
function relativeLuminance(color) {
const linear = (channel) => {
const value = channel / 255;
return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
};
return linear(color.r) * 0.2126 + linear(color.g) * 0.7152 + linear(color.b) * 0.0722;
}
function colorContrastRatio(first, second) {
const firstLuminance = relativeLuminance(first);
const secondLuminance = relativeLuminance(second);
const lighter = Math.max(firstLuminance, secondLuminance);
const darker = Math.min(firstLuminance, secondLuminance);
return (lighter + 0.05) / (darker + 0.05);
}
function assertExpectedWatchCounts(expectedCounts) {
assert.ok(expectedCounts && typeof expectedCounts === "object", "Exact Watch API counts are required.");
for (const key of ["likes", "dislikes"]) {
assert.ok(
Number.isSafeInteger(expectedCounts[key]) && expectedCounts[key] >= 0,
`The Watch API ${key} count must be a non-negative integer.`,
);
}
return expectedCounts;
}
function assertWatchRatioData(
measurement,
{
expectedCounts = null,
expectedTooltipCandidates = [],
minimumNegativeTrackContrast = WATCH_NEGATIVE_TRACK_MINIMUM_CONTRAST,
runtime,
videoId,
},
) {
assert.ok(measurement?.appearance, "The Watch ratio-bar appearance audit is missing.");
assert.equal(
measurement.appearance.wrapperVideoId,
videoId,
`The ${runtime} Watch ratio bar is owned by ${measurement.appearance.wrapperVideoId ?? "no video"}, not ${videoId}.`,
);
const pageBackground = parseCssColor(measurement.appearance.pageBackgroundColor);
const negativeTrack = parseCssColor(measurement.appearance.negativeTrackColor);
assert.ok(
pageBackground,
`Cannot resolve the Watch page background color: ${measurement.appearance.pageBackgroundColor}.`,
);
assert.ok(
negativeTrack,
`Cannot resolve the Watch negative-track color: ${measurement.appearance.negativeTrackColor}.`,
);
assert.equal(
measurement.appearance.negativeTrackBackgroundImage ?? "none",
"none",
"The Watch negative track uses a background image whose effective contrast cannot be audited.",
);
assert.ok(pageBackground.a >= 0.99, "The resolved Watch page background is not opaque enough for a contrast audit.");
const negativeTrackOpacity = Number(measurement.appearance.negativeTrackOpacity ?? 1);
assert.ok(
Number.isFinite(negativeTrackOpacity) && negativeTrackOpacity >= 0 && negativeTrackOpacity <= 1,
`The Watch negative-track effective opacity is invalid: ${measurement.appearance.negativeTrackOpacity}.`,
);
negativeTrack.a *= negativeTrackOpacity;
const effectiveNegativeTrack = compositeColor(negativeTrack, pageBackground);
const negativeTrackContrast = colorContrastRatio(effectiveNegativeTrack, pageBackground);
assert.ok(
negativeTrack.a > 0 && negativeTrackContrast >= minimumNegativeTrackContrast,
`The Watch negative track is effectively invisible against the page background: contrast ${negativeTrackContrast.toFixed(2)}:1 is below ${minimumNegativeTrackContrast}:1.`,
);
const result = { negativeTrackContrast, wrapperVideoId: measurement.appearance.wrapperVideoId };
if (expectedCounts === null) return result;
assertExpectedWatchCounts(expectedCounts);
const total = expectedCounts.likes + expectedCounts.dislikes;
const expectedRatio = total > 0 ? expectedCounts.likes / total : 0.5;
const inlineRatio = Number.parseFloat(measurement.appearance.inlineFillWidth) / 100;
assert.ok(Number.isFinite(inlineRatio), "The Watch ratio fill has no numeric inline percentage width.");
assert.ok(
Math.abs(inlineRatio - expectedRatio) <= WATCH_RATIO_INLINE_TOLERANCE,
`The Watch ratio fill inline width represents ${(inlineRatio * 100).toFixed(3)}%, expected ${(expectedRatio * 100).toFixed(3)}% from exact API counts.`,
);
const { bar, container } = measurement.geometry ?? {};
assert.ok(container?.width > 0 && bar?.width >= 0, "The Watch ratio geometry cannot represent an exact ratio.");
const renderedRatio = bar.width / container.width;
const pixelTolerance = 1 / container.width;
assert.ok(
Math.abs(renderedRatio - expectedRatio) <= Math.max(WATCH_RATIO_GEOMETRY_TOLERANCE, pixelTolerance),
`The rendered Watch ratio is ${(renderedRatio * 100).toFixed(2)}%, expected ${(expectedRatio * 100).toFixed(2)}% from exact API counts.`,
);
const normalizedTooltip = normalizeTooltipText(measurement.appearance.tooltipText);
const normalizedCandidates = expectedTooltipCandidates.map((candidate) => ({
form: typeof candidate === "string" ? "unspecified" : candidate.form,
text: normalizeTooltipText(typeof candidate === "string" ? candidate : candidate.text),
}));
assert.ok(normalizedCandidates.length > 0, "Exact Watch tooltip expectations are required with API counts.");
const tooltipMatch = normalizedCandidates.find((candidate) => candidate.text === normalizedTooltip);
assert.ok(
tooltipMatch,
`The Watch ratio tooltip ${JSON.stringify(normalizedTooltip)} does not exactly match the API counts and configured percentage form.`,
);
return {
...result,
expectedRatio,
inlineRatio,
renderedRatio,
tooltipForm: tooltipMatch.form,
tooltipText: normalizedTooltip,
};
}
module.exports = {
WATCH_NEGATIVE_TRACK_MINIMUM_CONTRAST,
WATCH_RATIO_GEOMETRY_TOLERANCE,
WATCH_RATIO_INLINE_TOLERANCE,
assertExpectedWatchCounts,
assertWatchRatioData,
colorContrastRatio,
formattedWatchTooltipCandidates,
normalizeTooltipText,
parseCssColor,
};
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,77 @@
const { test, expect } = require("@playwright/test");
const {
CREDENTIAL_KEY,
VIDEO_A,
createFakeBackend,
injectGeneratedUserscript,
installGmEnvironment,
installHermeticRoutes,
openWatchFixture,
} = require("./harness");
const EXISTING_CREDENTIALS = {
registrationConfirmed: true,
userId: "ExistingUserscriptCredential00000001",
};
test("the first synchronous click on a replaced current control votes once while a hidden stale control is ignored", async ({
context,
page,
}) => {
const consoleErrors = [];
const pageErrors = [];
page.on("console", (message) => {
if (message.type() === "error") consoleErrors.push(message.text());
});
page.on("pageerror", (error) => pageErrors.push(error.message));
await context.addInitScript(() => {
globalThis.__unhandledRejections = [];
addEventListener("unhandledrejection", (event) => {
globalThis.__unhandledRejections.push(String(event.reason));
});
});
const backend = createFakeBackend();
await installGmEnvironment(context, { [CREDENTIAL_KEY]: EXISTING_CREDENTIALS });
await installHermeticRoutes(context, backend);
await openWatchFixture(page, VIDEO_A);
await injectGeneratedUserscript(page);
const currentDislike = page.locator('[data-ryd-role="buttons"] [data-ryd-role="dislike"]');
await expect(currentDislike.locator("#text")).toHaveText("25");
await expect(page.locator("#return-youtube-dislike-bar-container")).toBeVisible();
await page.evaluate(() => {
const current = document.querySelector('[data-ryd-role="buttons"] [data-ryd-role="dislike"]');
const stale = current.cloneNode(true);
stale.hidden = true;
stale.setAttribute("data-fixture-stale-dislike", "true");
document.body.append(stale);
stale.querySelector("button").click();
});
await page.waitForTimeout(100);
expect(backend.requestsFor("POST", "/interact/vote")).toHaveLength(0);
expect(backend.requestsFor("POST", "/interact/confirmVote")).toHaveLength(0);
await expect(currentDislike.locator("#text")).toHaveText("25");
await page.evaluate(() => {
const current = document.querySelector('[data-ryd-role="buttons"] [data-ryd-role="dislike"]');
const replacement = current.cloneNode(true);
replacement.setAttribute("data-fixture-replaced-current-dislike", "true");
current.replaceWith(replacement);
replacement.querySelector("button").click();
});
await expect.poll(() => backend.requestsFor("POST", "/interact/confirmVote").length).toBe(1);
await expect(page.locator('[data-fixture-replaced-current-dislike="true"] #text')).toHaveText("26");
await page.waitForTimeout(650);
expect(backend.requestsFor("POST", "/interact/vote").map(({ body }) => body)).toEqual([
{ userId: EXISTING_CREDENTIALS.userId, value: -1, videoId: VIDEO_A },
]);
expect(backend.requestsFor("POST", "/interact/confirmVote")).toHaveLength(1);
expect(backend.blockedRequests).toEqual([]);
expect(consoleErrors).toEqual([]);
expect(pageErrors).toEqual([]);
expect(await page.evaluate(() => globalThis.__unhandledRejections)).toEqual([]);
});
@@ -49,8 +49,8 @@ async function expectWatchInitialized(page, videoId, expectedDislikes = COUNTS[v
const expectedCount = String(expectedDislikes); const expectedCount = String(expectedDislikes);
await expect(page).toHaveURL((url) => url.pathname === "/watch" && url.searchParams.get("v") === videoId); 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(`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-fixture-role="buttons"]')).toHaveCount(1);
await expect(page.locator('[data-fixture-page-kind="watch"] [data-ryd-role="dislike"] #text')).toHaveText( await expect(page.locator('[data-fixture-page-kind="watch"] [data-fixture-role="dislike"] #text')).toHaveText(
expectedCount, expectedCount,
); );
await expect(page.locator("#return-youtube-dislike-bar-container")).toHaveCount(1); await expect(page.locator("#return-youtube-dislike-bar-container")).toHaveCount(1);
@@ -66,7 +66,7 @@ async function expectShortInitialized(page, videoId, expectedDislikes = COUNTS[v
await expect(page.locator("ytd-shorts")).toHaveCount(1); await expect(page.locator("ytd-shorts")).toHaveCount(1);
await expect(activeRenderer).toHaveCount(1); await expect(activeRenderer).toHaveCount(1);
await expect( await expect(
page.locator('ytd-reel-video-renderer[is-active] reel-action-bar-view-model[data-ryd-role="buttons"]'), page.locator('ytd-reel-video-renderer[is-active] reel-action-bar-view-model[data-fixture-role="buttons"]'),
).toHaveCount(1); ).toHaveCount(1);
const syntheticDislike = activeRenderer.locator("[data-ryd-synthetic-shorts-dislike]"); const syntheticDislike = activeRenderer.locator("[data-ryd-synthetic-shorts-dislike]");
await expect(syntheticDislike).toHaveCount(1); await expect(syntheticDislike).toHaveCount(1);
@@ -85,9 +85,9 @@ async function expectMobileShortInitialized(page, videoId, expectedDislikes = CO
await expect(activeShort).toHaveCount(1); await expect(activeShort).toHaveCount(1);
await expect(activeShort).toBeVisible(); await expect(activeShort).toBeVisible();
await expect(page.locator("[data-fixture-mobile-short][is-active]:visible")).toHaveCount(1); 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('ytm-like-button-renderer[data-fixture-role="buttons"]')).toHaveCount(1);
await expect(activeShort.locator('[data-ryd-role="dislike"] #text')).toHaveText(String(expectedDislikes)); await expect(activeShort.locator('[data-fixture-role="dislike"] #text')).toHaveText(String(expectedDislikes));
await expect(activeShort.locator('[data-ryd-role="dislike"] button')).toBeEnabled(); await expect(activeShort.locator('[data-fixture-role="dislike"] button')).toBeEnabled();
await expect(page.locator("[data-ryd-synthetic-shorts-dislike]")).toHaveCount(0); await expect(page.locator("[data-ryd-synthetic-shorts-dislike]")).toHaveCount(0);
await expect(page.locator("#return-youtube-dislike-bar-container")).toHaveCount(0); await expect(page.locator("#return-youtube-dislike-bar-container")).toHaveCount(0);
} }
@@ -95,7 +95,9 @@ async function expectMobileShortInitialized(page, videoId, expectedDislikes = CO
async function expectOneActivation(page, backend, videoId) { async function expectOneActivation(page, backend, videoId) {
const voteCount = backend.requestsFor("POST", "/interact/vote").length; const voteCount = backend.requestsFor("POST", "/interact/vote").length;
const confirmationCount = backend.requestsFor("POST", "/interact/confirmVote").length; const confirmationCount = backend.requestsFor("POST", "/interact/confirmVote").length;
await page.locator('[data-ryd-role="dislike"]:visible button').click(); await page
.locator('[data-fixture-role="dislike"]:visible button, [data-ryd-synthetic-shorts-dislike]:visible button')
.click();
await expect.poll(() => backend.requestsFor("POST", "/interact/confirmVote").length).toBe(confirmationCount + 1); await expect.poll(() => backend.requestsFor("POST", "/interact/confirmVote").length).toBe(confirmationCount + 1);
await page.waitForTimeout(600); await page.waitForTimeout(600);
@@ -125,7 +127,7 @@ test("cold channel reload then immediate Short link initializes delayed Shorts c
await page.locator("#channel-short").click(); await page.locator("#channel-short").click();
await expect(page).toHaveURL((url) => url.pathname === `/shorts/${VIDEO_A}`); await expect(page).toHaveURL((url) => url.pathname === `/shorts/${VIDEO_A}`);
const decoyDislikeCount = page.locator('[data-fixture-decoy-controls] [data-ryd-role="dislike"] #text'); const decoyDislikeCount = page.locator('[data-fixture-decoy-controls] [data-fixture-role="dislike"] #text');
await page.waitForTimeout(250); await page.waitForTimeout(250);
await expect(decoyDislikeCount).toHaveText(""); await expect(decoyDislikeCount).toHaveText("");
await expect(page.locator("[data-ryd-synthetic-shorts-dislike]")).toHaveCount(0); await expect(page.locator("[data-ryd-synthetic-shorts-dislike]")).toHaveCount(0);
@@ -320,9 +322,9 @@ test("watch to Short link switches page kind, identity, and controls", async ({
await page.locator("#watch-to-short").click(); await page.locator("#watch-to-short").click();
await expect(page).toHaveURL((url) => url.pathname === `/shorts/${VIDEO_B}`); await expect(page).toHaveURL((url) => url.pathname === `/shorts/${VIDEO_B}`);
await expect(page.locator('[data-fixture-page-kind="watch"] [data-ryd-role="dislike"] #text')).toHaveText("11"); await expect(page.locator('[data-fixture-page-kind="watch"] [data-fixture-role="dislike"] #text')).toHaveText("11");
await page.waitForTimeout(50); await page.waitForTimeout(50);
await expect(page.locator('[data-fixture-page-kind="watch"] [data-ryd-role="dislike"] #text')).toHaveText("11"); await expect(page.locator('[data-fixture-page-kind="watch"] [data-fixture-role="dislike"] #text')).toHaveText("11");
await expect(page.locator("[data-ryd-synthetic-shorts-dislike]")).toHaveCount(0); await expect(page.locator("[data-ryd-synthetic-shorts-dislike]")).toHaveCount(0);
await expectShortInitialized(page, VIDEO_B); await expectShortInitialized(page, VIDEO_B);
@@ -415,8 +417,8 @@ test("delayed mobile Short navigation never binds the outgoing renderer to the t
await expect(outgoingRenderer.locator(`a[href="/shorts/${VIDEO_B}"]`)).toHaveCount(1); await expect(outgoingRenderer.locator(`a[href="/shorts/${VIDEO_B}"]`)).toHaveCount(1);
await expect(page.locator(`ytm-reel-video-renderer[video-id="${VIDEO_B}"]`)).toHaveCount(0); await expect(page.locator(`ytm-reel-video-renderer[video-id="${VIDEO_B}"]`)).toHaveCount(0);
await page.waitForTimeout(600); await page.waitForTimeout(600);
await expect(outgoingRenderer.locator('[data-ryd-role="dislike"] #text')).toHaveText("11"); await expect(outgoingRenderer.locator('[data-fixture-role="dislike"] #text')).toHaveText("11");
await outgoingRenderer.locator('[data-ryd-role="dislike"] button').click(); await outgoingRenderer.locator('[data-fixture-role="dislike"] button').click();
await page.waitForTimeout(100); await page.waitForTimeout(100);
expect( expect(
harness.backend.requestsFor("GET", "/votes").filter((request) => request.query.videoId === VIDEO_B), harness.backend.requestsFor("GET", "/votes").filter((request) => request.query.videoId === VIDEO_B),
@@ -561,7 +563,7 @@ test("watch SPA navigation refreshes one aligned ratio bar with the target video
await expect(wrapper).toBeVisible(); await expect(wrapper).toBeVisible();
await expect(container).toBeVisible(); await expect(container).toBeVisible();
await expect(bar).toBeVisible(); await expect(bar).toBeVisible();
await expect(currentControls.locator('[data-ryd-role="dislike"] #text')).toHaveText("22"); await expect(currentControls.locator('[data-fixture-role="dislike"] #text')).toHaveText("22");
await expect(tooltip).toContainText("200 / 22"); await expect(tooltip).toContainText("200 / 22");
await expect(tooltip).toBeHidden(); await expect(tooltip).toBeHidden();
@@ -578,8 +580,8 @@ test("watch SPA navigation refreshes one aligned ratio bar with the target video
}; };
}; };
const controls = reactionRegion.querySelector("[data-fixture-control-video-id]"); const controls = reactionRegion.querySelector("[data-fixture-control-video-id]");
const like = controls.querySelector('[data-ryd-role="like"] button'); const like = controls.querySelector('[data-fixture-role="like"] button');
const dislike = controls.querySelector('[data-ryd-role="dislike"] button'); const dislike = controls.querySelector('[data-fixture-role="dislike"] button');
const ratioWrapper = reactionRegion.querySelector(":scope > .ryd-tooltip"); const ratioWrapper = reactionRegion.querySelector(":scope > .ryd-tooltip");
const ratioContainer = ratioWrapper.querySelector("#return-youtube-dislike-bar-container"); const ratioContainer = ratioWrapper.querySelector("#return-youtube-dislike-bar-container");
const ratioFill = ratioContainer.querySelector("#return-youtube-dislike-bar"); const ratioFill = ratioContainer.querySelector("#return-youtube-dislike-bar");
@@ -651,7 +653,7 @@ test("a pruned current watch ratio bar is restored once with the current video's
await expect(wrapper).toBeVisible(); await expect(wrapper).toBeVisible();
await expect(container).toBeVisible(); await expect(container).toBeVisible();
await expect(bar).toBeVisible(); await expect(bar).toBeVisible();
await expect(currentControls.locator('[data-ryd-role="dislike"] #text')).toHaveText("22"); await expect(currentControls.locator('[data-fixture-role="dislike"] #text')).toHaveText("22");
await expect(tooltip).toContainText("200 / 22"); await expect(tooltip).toContainText("200 / 22");
await expect(tooltip).toBeHidden(); await expect(tooltip).toBeHidden();
expect(await wrapper.evaluate((restoredWrapper) => restoredWrapper !== globalThis.__fixturePrunedWatchBar)).toBe( expect(await wrapper.evaluate((restoredWrapper) => restoredWrapper !== globalThis.__fixturePrunedWatchBar)).toBe(
@@ -787,8 +789,8 @@ test("delayed watch navigation never binds outgoing controls to the target video
await expect(outgoingControls.locator('[data-fixture-irrelevant-watch-count-mutation="true"]')).toHaveCount(1); await expect(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 expect(page.locator('[data-fixture-irrelevant-watch-tooltip-mutation="true"]')).toHaveCount(1);
await page.waitForTimeout(600); await page.waitForTimeout(600);
await expect(outgoingControls.locator('[data-ryd-role="dislike"] #text')).toHaveText("11"); await expect(outgoingControls.locator('[data-fixture-role="dislike"] #text')).toHaveText("11");
await outgoingControls.locator('[data-ryd-role="dislike"] button').click(); await outgoingControls.locator('[data-fixture-role="dislike"] button').click();
await page.waitForTimeout(100); await page.waitForTimeout(100);
expect( expect(
harness.backend.requestsFor("GET", "/votes").filter((request) => request.query.videoId === VIDEO_B), harness.backend.requestsFor("GET", "/votes").filter((request) => request.query.videoId === VIDEO_B),
@@ -796,7 +798,7 @@ test("delayed watch navigation never binds outgoing controls to the target video
expect(harness.backend.requestsFor("POST", "/interact/vote")).toHaveLength(0); expect(harness.backend.requestsFor("POST", "/interact/vote")).toHaveLength(0);
expect(harness.backend.requestsFor("POST", "/interact/confirmVote")).toHaveLength(0); expect(harness.backend.requestsFor("POST", "/interact/confirmVote")).toHaveLength(0);
const outgoingDislike = outgoingControls.locator('[data-ryd-role="dislike"]'); const outgoingDislike = outgoingControls.locator('[data-fixture-role="dislike"]');
await page.evaluate(() => window.__navigationFixture.replaceDelayedWatchControl("like")); await page.evaluate(() => window.__navigationFixture.replaceDelayedWatchControl("like"));
await expect(outgoingControls.locator('[data-fixture-watch-replacement="like"]')).toHaveCount(1); await expect(outgoingControls.locator('[data-fixture-watch-replacement="like"]')).toHaveCount(1);
await expect(outgoingDislike).toBeVisible(); await expect(outgoingDislike).toBeVisible();
@@ -826,8 +828,8 @@ test("same-node watch reuse requires independent native refresh on both activati
const harness = await launchNavigationHarness({ context, page }, { pageKind: "watch", videoId: VIDEO_A }); const harness = await launchNavigationHarness({ context, page }, { pageKind: "watch", videoId: VIDEO_A });
await expectWatchInitialized(page, VIDEO_A); await expectWatchInitialized(page, VIDEO_A);
const reusedControls = page.locator(`[data-fixture-control-video-id="${VIDEO_A}"]`); const reusedControls = page.locator(`[data-fixture-control-video-id="${VIDEO_A}"]`);
const reusedLikeButton = reusedControls.locator('[data-ryd-role="like"] button'); const reusedLikeButton = reusedControls.locator('[data-fixture-role="like"] button');
const reusedDislikeButton = reusedControls.locator('[data-ryd-role="dislike"] button'); const reusedDislikeButton = reusedControls.locator('[data-fixture-role="dislike"] button');
await reusedLikeButton.evaluate((button) => button.setAttribute("data-fixture-same-node", "like")); await reusedLikeButton.evaluate((button) => button.setAttribute("data-fixture-same-node", "like"));
await reusedDislikeButton.evaluate((button) => button.setAttribute("data-fixture-same-node", "dislike")); await reusedDislikeButton.evaluate((button) => button.setAttribute("data-fixture-same-node", "dislike"));
@@ -836,7 +838,7 @@ test("same-node watch reuse requires independent native refresh on both activati
await expect(page).toHaveURL((url) => url.pathname === "/watch" && url.searchParams.get("v") === VIDEO_B); await expect(page).toHaveURL((url) => url.pathname === "/watch" && url.searchParams.get("v") === VIDEO_B);
await page.waitForTimeout(600); await page.waitForTimeout(600);
await expect(reusedControls.locator('[data-ryd-role="dislike"] #text')).toHaveText("11"); await expect(reusedControls.locator('[data-fixture-role="dislike"] #text')).toHaveText("11");
await reusedDislikeButton.click(); await reusedDislikeButton.click();
await page.waitForTimeout(100); await page.waitForTimeout(100);
expect( expect(
@@ -875,8 +877,8 @@ test("pre-navigation native drift cannot satisfy same-node watch refresh for the
const harness = await launchNavigationHarness({ context, page }, { pageKind: "watch", videoId: VIDEO_A }); const harness = await launchNavigationHarness({ context, page }, { pageKind: "watch", videoId: VIDEO_A });
await expectWatchInitialized(page, VIDEO_A); await expectWatchInitialized(page, VIDEO_A);
const reusedControls = page.locator(`[data-fixture-control-video-id="${VIDEO_A}"]`); const reusedControls = page.locator(`[data-fixture-control-video-id="${VIDEO_A}"]`);
const reusedLikeButton = reusedControls.locator('[data-ryd-role="like"] button'); const reusedLikeButton = reusedControls.locator('[data-fixture-role="like"] button');
const reusedDislikeButton = reusedControls.locator('[data-ryd-role="dislike"] button'); const reusedDislikeButton = reusedControls.locator('[data-fixture-role="dislike"] button');
await reusedLikeButton.evaluate((button) => button.setAttribute("data-fixture-pre-navigation-node", "like")); 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 reusedDislikeButton.evaluate((button) => button.setAttribute("data-fixture-pre-navigation-node", "dislike"));
@@ -896,9 +898,10 @@ test("pre-navigation native drift cannot satisfy same-node watch refresh for the
await expect(page).toHaveURL((url) => url.pathname === "/watch" && url.searchParams.get("v") === VIDEO_B); await expect(page).toHaveURL((url) => url.pathname === "/watch" && url.searchParams.get("v") === VIDEO_B);
await page.waitForTimeout(600); await page.waitForTimeout(600);
await expect(reusedControls.locator('[data-ryd-role="dislike"] #text')).toHaveText("11"); await expect(reusedControls.locator('[data-fixture-role="dislike"] #text')).toHaveText("11");
await expect(page.locator("#ryd-dislike-tooltip")).toContainText("100 / 11"); await expect(page.locator("#ryd-dislike-tooltip")).toContainText("100 / 11");
await reusedDislikeButton.click(); await reusedDislikeButton.click();
await expect(reusedDislikeButton).toHaveAttribute("aria-pressed", "true");
await page.waitForTimeout(100); await page.waitForTimeout(100);
expect( expect(
harness.backend.requestsFor("GET", "/votes").filter((request) => request.query.videoId === VIDEO_B), harness.backend.requestsFor("GET", "/votes").filter((request) => request.query.videoId === VIDEO_B),
@@ -915,6 +918,7 @@ test("pre-navigation native drift cannot satisfy same-node watch refresh for the
await page.evaluate((videoId) => window.__navigationFixture.refreshReusedWatchControl("dislike", videoId), VIDEO_B); await page.evaluate((videoId) => window.__navigationFixture.refreshReusedWatchControl("dislike", videoId), VIDEO_B);
await expect(reusedDislikeButton).toHaveAttribute("aria-label", `dislike refreshed for ${VIDEO_B}`); await expect(reusedDislikeButton).toHaveAttribute("aria-label", `dislike refreshed for ${VIDEO_B}`);
await expect(reusedDislikeButton).toHaveAttribute("aria-pressed", "false");
await expect(reusedDislikeButton).toHaveAttribute("data-fixture-pre-navigation-node", "dislike"); await expect(reusedDislikeButton).toHaveAttribute("data-fixture-pre-navigation-node", "dislike");
await expectWatchInitialized(page, VIDEO_B); await expectWatchInitialized(page, VIDEO_B);
await expectOneActivation(page, harness.backend, VIDEO_B); await expectOneActivation(page, harness.backend, VIDEO_B);
@@ -933,8 +937,8 @@ test("navigation-start snapshots same-node watch refreshes that occur before nav
const harness = await launchNavigationHarness({ context, page }, { pageKind: "watch", videoId: VIDEO_A }); const harness = await launchNavigationHarness({ context, page }, { pageKind: "watch", videoId: VIDEO_A });
await expectWatchInitialized(page, VIDEO_A); await expectWatchInitialized(page, VIDEO_A);
const reusedControls = page.locator(`[data-fixture-control-video-id="${VIDEO_A}"]`); const reusedControls = page.locator(`[data-fixture-control-video-id="${VIDEO_A}"]`);
const reusedLikeButton = reusedControls.locator('[data-ryd-role="like"] button'); const reusedLikeButton = reusedControls.locator('[data-fixture-role="like"] button');
const reusedDislikeButton = reusedControls.locator('[data-ryd-role="dislike"] button'); const reusedDislikeButton = reusedControls.locator('[data-fixture-role="dislike"] button');
await reusedLikeButton.evaluate((button) => button.setAttribute("data-fixture-pre-finish-node", "like")); 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 reusedDislikeButton.evaluate((button) => button.setAttribute("data-fixture-pre-finish-node", "dislike"));
@@ -982,8 +986,8 @@ test("navigation-start combines a replaced Like target with a reused Dislike ref
const harness = await launchNavigationHarness({ context, page }, { pageKind: "watch", videoId: VIDEO_A }); const harness = await launchNavigationHarness({ context, page }, { pageKind: "watch", videoId: VIDEO_A });
await expectWatchInitialized(page, VIDEO_A); await expectWatchInitialized(page, VIDEO_A);
const controls = page.locator(`[data-fixture-control-video-id="${VIDEO_A}"]`); const controls = page.locator(`[data-fixture-control-video-id="${VIDEO_A}"]`);
const originalLikeButton = controls.locator('[data-ryd-role="like"] button'); const originalLikeButton = controls.locator('[data-fixture-role="like"] button');
const reusedDislikeButton = controls.locator('[data-ryd-role="dislike"] button'); const reusedDislikeButton = controls.locator('[data-fixture-role="dislike"] button');
await originalLikeButton.evaluate((button) => { await originalLikeButton.evaluate((button) => {
window.__fixtureOriginalMixedLikeTarget = button; window.__fixtureOriginalMixedLikeTarget = button;
}); });
@@ -1037,7 +1041,7 @@ test("mobile channel to Short ignores stale channel controls until the target mo
await page.locator("#channel-short").click(); await page.locator("#channel-short").click();
await expect(page).toHaveURL((url) => url.pathname === `/shorts/${VIDEO_A}`); await expect(page).toHaveURL((url) => url.pathname === `/shorts/${VIDEO_A}`);
const decoyDislikeCount = page.locator('[data-fixture-decoy-controls] [data-ryd-role="dislike"] #text'); const decoyDislikeCount = page.locator('[data-fixture-decoy-controls] [data-fixture-role="dislike"] #text');
await page.waitForTimeout(250); await page.waitForTimeout(250);
await expect(decoyDislikeCount).toHaveText(""); await expect(decoyDislikeCount).toHaveText("");
expect(harness.backend.requestsFor("GET", "/votes")).toHaveLength(0); expect(harness.backend.requestsFor("GET", "/votes")).toHaveLength(0);
@@ -6,62 +6,72 @@ const {
installGmEnvironment, installGmEnvironment,
installHermeticRoutes, installHermeticRoutes,
openNavigationFixture, openNavigationFixture,
readGmValue,
} = require("./harness"); } = require("./harness");
const { USERSCRIPT_MATRIX_RUNTIME, installNavigationMatrixFixture } = require("./navigation-matrix");
const { const {
NAVIGATION_MATRIX, createNavigationRuntimeContractAdapter,
USERSCRIPT_MATRIX_RUNTIME, registerNavigationRuntimeContractScenarios,
installNavigationMatrixFixture, runNavigationRuntimeContract,
runNavigationMatrixScenario, } = require("../../e2e/navigation-runtime-contract");
} = require("./navigation-matrix");
const EXISTING_CREDENTIALS = { const EXISTING_CREDENTIALS = {
registrationConfirmed: true, registrationConfirmed: true,
userId: "ExistingUserscriptCredential000000000001", userId: "ExistingUserscriptCredential00000001",
}; };
registerNavigationRuntimeContractScenarios({
for (const scenario of NAVIGATION_MATRIX) { runtimeName: "userscript",
test(`userscript navigation matrix: ${scenario.id}`, async ({ context, page }) => { register: ({ scenario, title }) => {
await page.setViewportSize(scenario.viewport); test(title, async ({ context, page }) => {
const consoleErrors = []; await page.setViewportSize(scenario.viewport);
const pageErrors = []; const consoleErrors = [];
page.on("console", (message) => { const pageErrors = [];
if (message.type() === "error") consoleErrors.push(message.text()); 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));
}); });
}); page.on("pageerror", (error) => pageErrors.push(error.message));
const backend = createFakeBackend({ await context.addInitScript(() => {
countsByVideo: { globalThis.__unhandledRejections = [];
[scenario.destination.videoId]: scenario.destination.counts, addEventListener("unhandledrejection", (event) => {
[scenario.origin.videoId]: scenario.origin.counts, const reason = event.reason;
}, globalThis.__unhandledRejections.push(reason instanceof Error ? reason.message : String(reason));
}); });
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({ const backend = createFakeBackend({
backend, countsByVideo: {
page, [scenario.destination.videoId]: scenario.destination.counts,
runtime: USERSCRIPT_MATRIX_RUNTIME, [scenario.origin.videoId]: scenario.origin.counts,
scenario, },
}); });
await installGmEnvironment(context, { [CREDENTIAL_KEY]: EXISTING_CREDENTIALS });
await installNavigationMatrixFixture(context, scenario);
await installHermeticRoutes(context, backend);
await openNavigationFixture(page, {
pageKind: scenario.origin.kind,
videoId: scenario.origin.videoId,
});
await injectGeneratedUserscript(page);
expect(backend.blockedRequests).toEqual([]); const adapter = createNavigationRuntimeContractAdapter({
expect(consoleErrors).toEqual([]); backend,
expect(pageErrors).toEqual([]); expectedCredentials: EXISTING_CREDENTIALS,
expect(await page.evaluate(() => globalThis.__unhandledRejections)).toEqual([]); matrixRuntime: USERSCRIPT_MATRIX_RUNTIME,
}); page,
} readCredentials: () => readGmValue(page, CREDENTIAL_KEY),
readInteractionRecords: () => backend.requests,
runtimeName: "userscript",
});
await runNavigationRuntimeContract({
adapter,
scenario,
});
expect(backend.blockedRequests).toEqual([]);
expect(consoleErrors).toEqual([]);
expect(pageErrors).toEqual([]);
expect(await page.evaluate(() => globalThis.__unhandledRejections)).toEqual([]);
});
},
});
@@ -0,0 +1,43 @@
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { test, expect } = require("@playwright/test");
const { VIDEO_A } = require("./harness");
const { HermeticUserscriptArtifactAdapter } = require("../../e2e/hermetic-artifact-smoke");
const PRODUCTION_API_ORIGIN = "https://returnyoutubedislikeapi.com";
function removeOwnedOracleDirectory(directory) {
const resolved = path.resolve(directory);
expect(path.dirname(resolved)).toBe(path.resolve(os.tmpdir()));
expect(path.basename(resolved)).toMatch(/^ryd-userscript-render-oracle-/);
fs.rmSync(resolved, { force: true, recursive: true });
}
test("the render oracle rejects a generated userscript whose runtime body does nothing", async () => {
const oracleRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ryd-userscript-render-oracle-"));
const brokenArtifact = path.join(oracleRoot, "Return Youtube Dislike.user.js");
fs.writeFileSync(brokenArtifact, `void ${JSON.stringify(PRODUCTION_API_ORIGIN)};`);
const adapter = new HermeticUserscriptArtifactAdapter({ artifactPath: brokenArtifact });
try {
await adapter.start();
adapter.page.setDefaultTimeout(1_500);
await adapter.openWatch(VIDEO_A);
await expect(adapter.waitForWatchResult(VIDEO_A)).rejects.toThrow(/Timeout|did not render/i);
await expect(adapter.page.locator("#return-youtube-dislike-bar-container")).toHaveCount(0);
await expect(adapter.page.locator("#return-youtube-dislike-bar")).toHaveCount(0);
await expect(
adapter.page.locator(
"dislike-button-view-model #text, " +
"dislike-button-view-model [role='text'], " +
"dislike-button-view-model .ytSpecButtonShapeNextButtonTextContent",
),
).toHaveCount(0);
} finally {
await adapter.close().catch(() => {});
removeOwnedOracleDirectory(oracleRoot);
}
});
@@ -0,0 +1,25 @@
const { test } = require("@playwright/test");
const { HermeticUserscriptArtifactAdapter, SPA_COUNTS } = require("../../e2e/hermetic-artifact-smoke");
const {
registerRuntimeResilienceContractScenarios,
runRuntimeResilienceContract,
} = require("../../e2e/runtime-resilience-contract");
registerRuntimeResilienceContractScenarios({
runtimeName: "userscript",
register: ({ scenario, title }) => {
test(title, async () => {
await runRuntimeResilienceContract({
scenario,
createAdapter: ({ gateMode }) =>
new HermeticUserscriptArtifactAdapter({
backendOptions: {
countsByVideo: SPA_COUNTS,
fixture: { signedIn: gateMode !== "signed-out" },
},
disableVoteSubmission: gateMode === "disabled",
}),
});
});
},
});
@@ -0,0 +1,64 @@
const { test, expect } = require("@playwright/test");
const {
CREDENTIAL_KEY,
createFakeBackend,
injectGeneratedUserscript,
installGmEnvironment,
installHermeticRoutes,
} = require("./harness");
const {
SHORTS_PLACEHOLDER_POOL_COUNTS,
installShortsPlaceholderPoolRoute,
runShortsPlaceholderPoolContract,
shortsPlaceholderPoolUrl,
} = require("../../e2e/shorts-placeholder-pool-contract");
const EXISTING_CREDENTIALS = Object.freeze({
registrationConfirmed: true,
userId: "ExistingUserscriptCredential00000001",
});
test("userscript preserves every Shorts action through ten pre-rendered and recycled Next transitions", async ({
context,
page,
}) => {
test.setTimeout(30_000);
const consoleErrors = [];
const pageErrors = [];
const unexpectedFixtureRequests = [];
page.on("console", (message) => {
if (message.type() === "error") consoleErrors.push(message.text());
});
page.on("pageerror", (error) => pageErrors.push(error.message));
await context.addInitScript(() => {
globalThis.__unhandledRejections = [];
addEventListener("unhandledrejection", (event) => {
const reason = event.reason;
globalThis.__unhandledRejections.push(reason instanceof Error ? reason.message : String(reason));
});
});
const backend = createFakeBackend({ countsByVideo: SHORTS_PLACEHOLDER_POOL_COUNTS });
await installGmEnvironment(context, { [CREDENTIAL_KEY]: EXISTING_CREDENTIALS });
await installHermeticRoutes(context, backend);
await installShortsPlaceholderPoolRoute(context, {
onUnexpectedRequest: (request) => unexpectedFixtureRequests.push(request),
});
await page.setViewportSize({ height: 720, width: 1280 });
await page.goto(shortsPlaceholderPoolUrl(), { waitUntil: "domcontentloaded" });
await injectGeneratedUserscript(page);
const results = await runShortsPlaceholderPoolContract({
page,
readRequests: () => backend.requests,
runtimeName: "userscript",
});
expect(results).toHaveLength(11);
expect(results.filter((result) => result.pixelOracle).map((result) => result.logicalIndex)).toEqual([0, 1, 10]);
expect(unexpectedFixtureRequests).toEqual([]);
expect(backend.blockedRequests).toEqual([]);
expect(consoleErrors).toEqual([]);
expect(pageErrors).toEqual([]);
expect(await page.evaluate(() => globalThis.__unhandledRejections)).toEqual([]);
});
@@ -1,3 +1,5 @@
const fs = require("node:fs");
const path = require("node:path");
const { test, expect } = require("@playwright/test"); const { test, expect } = require("@playwright/test");
const { const {
CREDENTIAL_KEY, CREDENTIAL_KEY,
@@ -8,67 +10,38 @@ const {
installHermeticRoutes, installHermeticRoutes,
openWatchFixture, openWatchFixture,
} = require("./harness"); } = require("./harness");
const {
USERSCRIPT_WATCH_VISUAL_PROFILE,
attachVisualFailureScreenshot,
expectNoStructuralLayoutShift,
expectWatchVisualState,
readWatchVisualContract,
waitForBarRatio,
} = require("../../e2e/watch-visual-contract");
const { getWatchNeutralBaselineId, registerWatchVisualContract } = require("../../e2e/watch-visual-scenarios");
const { annotateVisualEvidence, captureOptionalVisualEvidence } = require("../../e2e/visual-evidence");
const REPOSITORY_ROOT = path.resolve(__dirname, "../../..");
const VISUAL_REVIEW_DIRECTORY = path.join(REPOSITORY_ROOT, "test-results", "visual-review", "userscript");
const EXISTING_CREDENTIALS = { const EXISTING_CREDENTIALS = {
userId: "ExistingUserscriptCredential000000000001", userId: "ExistingUserscriptCredential000000000001",
registrationConfirmed: true, registrationConfirmed: true,
}; };
const BASE_COUNTS = { likes: 300, dislikes: 100 }; const BASE_COUNTS = { likes: 300, dislikes: 100 };
const VIEWPORTS = [
{ name: "wide", width: 1280, height: 720 }, async function captureVisualReview(page, name) {
{ name: "narrow", width: 768, height: 720 }, const outputPath = path.join(VISUAL_REVIEW_DIRECTORY, `${name}.png`);
{ name: "mobile-sized", width: 390, height: 844 }, return captureOptionalVisualEvidence({
]; capture: async (screenshotPath) => {
const TRANSITIONS = [ fs.mkdirSync(VISUAL_REVIEW_DIRECTORY, { recursive: true });
{ await page.locator("#top-row").screenshot({
action: "like", animations: "disabled",
initialState: "neutral", path: screenshotPath,
nextState: "liked", });
value: 1, },
likesDelta: 1, outputPath,
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) { function monitorRuntime(page) {
const consoleErrors = []; const consoleErrors = [];
@@ -92,335 +65,52 @@ async function launchVisualFixture({ context, page }, initialState) {
await injectGeneratedUserscript(page); await injectGeneratedUserscript(page);
await expect(page.locator('[data-ryd-role="dislike"] #text')).toHaveText(String(BASE_COUNTS.dislikes)); await expect(page.locator('[data-ryd-role="dislike"] #text')).toHaveText(String(BASE_COUNTS.dislikes));
await expect(page.locator("#return-youtube-dislike-bar-container")).toBeVisible(); await expect(page.locator(USERSCRIPT_WATCH_VISUAL_PROFILE.container)).toBeVisible();
return { backend, ...runtime }; return { backend, ...runtime };
} }
async function readVisualContract(page) { registerWatchVisualContract({
return page.evaluate(() => { runtime: "userscript",
const required = (selector) => { test,
const element = document.querySelector(selector); async runScenario({ fixtures: { context, page }, scenario, testInfo }) {
if (!element) throw new Error(`Missing visual-contract element: ${selector}`); const { transition, viewport } = scenario;
return element; await page.setViewportSize({ width: viewport.width, height: viewport.height });
}; try {
const rect = (element) => { const harness = await launchVisualFixture({ context, page }, transition.initialState);
const bounds = element.getBoundingClientRect(); const before = await readWatchVisualContract(page, USERSCRIPT_WATCH_VISUAL_PROFILE);
return { expectWatchVisualState(before, transition.initialState, BASE_COUNTS, viewport, USERSCRIPT_WATCH_VISUAL_PROFILE);
bottom: bounds.bottom, const neutralBaselineId = getWatchNeutralBaselineId(scenario);
height: bounds.height, if (neutralBaselineId) {
left: bounds.left, annotateVisualEvidence(testInfo, await captureVisualReview(page, neutralBaselineId));
right: bounds.right, }
top: bounds.top,
width: bounds.width, 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, USERSCRIPT_WATCH_VISUAL_PROFILE);
const box = (element) => { const after = await readWatchVisualContract(page, USERSCRIPT_WATCH_VISUAL_PROFILE);
const style = getComputedStyle(element); expectWatchVisualState(after, transition.nextState, nextCounts, viewport, USERSCRIPT_WATCH_VISUAL_PROFILE);
return { expectNoStructuralLayoutShift(before, after);
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"); expect(harness.backend.requestsFor("GET", "/votes")).toHaveLength(1);
const reactionGroup = required('[data-ryd-role="buttons"]'); expect(harness.backend.requestsFor("POST", "/interact/vote")).toHaveLength(1);
const topRow = required("#top-row"); expect(harness.backend.requestsFor("POST", "/interact/vote")[0].body).toMatchObject({
const wrapper = required(".ryd-tooltip"); userId: EXISTING_CREDENTIALS.userId,
const barContainer = required("#return-youtube-dislike-bar-container"); value: transition.value,
const bar = required("#return-youtube-dislike-bar"); videoId: VIDEO_A,
const tooltip = required("#ryd-dislike-tooltip");
const reactionGroupStyle = getComputedStyle(reactionGroup);
const surfaceStyle = getComputedStyle(surface);
const topRowStyle = getComputedStyle(topRow);
const barStyle = getComputedStyle(bar);
const barContainerStyle = getComputedStyle(barContainer);
const tooltipStyle = getComputedStyle(tooltip);
const wrapperStyle = getComputedStyle(wrapper);
return {
bar: rect(bar),
barAppearance: {
backgroundColor: barStyle.backgroundColor,
borderRadius: barStyle.borderRadius,
},
barContainer: rect(barContainer),
barContainerAppearance: {
backgroundColor: barContainerStyle.backgroundColor,
borderRadius: barContainerStyle.borderRadius,
},
buttons: rect(surface),
buttonsBox: box(surface),
buttonsGap: reactionGroupStyle.gap,
buttonsPosition: surfaceStyle.position,
dislike: control("dislike"),
like: control("like"),
ownership: {
barOwnedByButtons: surface.contains(barContainer),
tooltipDescribedBy: wrapper.getAttribute("aria-describedby"),
tooltipOwnedByButtons: surface.contains(tooltip),
tooltipRole: tooltip.getAttribute("role"),
wrapperOwnedByButtons: wrapper.parentElement === surface,
},
reactionGroup: rect(reactionGroup),
reactionGroupBox: box(reactionGroup),
topRow: rect(topRow),
topRowBox: box(topRow),
topRowStyle: {
borderBottomWidth: topRowStyle.borderBottomWidth,
paddingBottom: topRowStyle.paddingBottom,
},
tooltipText: tooltip.textContent.replace(/\s+/g, " ").trim(),
tooltipAppearance: {
fontSize: tooltipStyle.fontSize,
lineHeight: tooltipStyle.lineHeight,
opacity: tooltipStyle.opacity,
padding: box(tooltip).padding,
position: tooltipStyle.position,
visibility: tooltipStyle.visibility,
},
unique: {
bars: document.querySelectorAll("#return-youtube-dislike-bar").length,
containers: document.querySelectorAll("#return-youtube-dislike-bar-container").length,
tooltips: document.querySelectorAll("#ryd-dislike-tooltip").length,
wrappers: document.querySelectorAll(".ryd-tooltip").length,
},
viewport: { height: innerHeight, width: innerWidth },
wrapper: rect(wrapper),
wrapperAppearance: {
box: box(wrapper),
position: wrapperStyle.position,
},
};
});
}
function expectClose(actual, expected, precision = 5) {
expect(actual).toBeCloseTo(expected, precision);
}
function expectComputedColor(actual, expectedChannel) {
const srgb = actual.match(/^color\(srgb\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)/);
const channels = srgb
? srgb.slice(1).map((channel) => Number(channel) * 255)
: actual
.match(/[\d.]+/g)
?.slice(0, 3)
.map(Number);
expect(channels).toHaveLength(3);
channels.forEach((channel) => expect(channel).toBeCloseTo(expectedChannel, 0));
}
function expectControlGeometry(control) {
expectClose(control.outer.width, 96);
expectClose(control.outer.height, 36);
expectClose(control.button.width, 96);
expectClose(control.button.height, 36);
expect(control.outerBox).toEqual({
margin: ["0px", "0px", "0px", "0px"],
padding: ["0px", "0px", "0px", "0px"],
});
expect(control.buttonBox).toEqual({
margin: ["0px", "0px", "0px", "0px"],
padding: ["0px", "12px", "0px", "12px"],
});
expect(control.iconBox).toEqual({
margin: ["0px", "0px", "0px", "0px"],
padding: ["0px", "0px", "0px", "0px"],
});
expect(control.textBox).toEqual({
margin: ["0px", "0px", "0px", "0px"],
padding: ["0px", "0px", "0px", "0px"],
});
expectClose(control.icon.width, 20);
expectClose(control.icon.height, 20);
expectClose(control.icon.top + control.icon.height / 2, control.button.top + control.button.height / 2);
expectClose(control.text.top + control.text.height / 2, control.button.top + control.button.height / 2);
expectClose(control.text.left - control.icon.right, 6);
}
function expectPressedState(control, pressed) {
expect(control.ariaPressed).toBe(String(pressed));
expect(control.classes).toEqual([pressed ? "style-default-active" : "style-text"]);
expect(control.backgroundColor).toBe(pressed ? "rgb(241, 241, 241)" : "rgb(39, 39, 39)");
expect(control.color).toBe(pressed ? "rgb(15, 15, 15)" : "rgb(241, 241, 241)");
}
function expectVisualState(snapshot, state, counts, viewport) {
expect(snapshot.viewport).toEqual({ height: viewport.height, width: viewport.width });
expect(snapshot.unique).toEqual({ bars: 1, containers: 1, tooltips: 1, wrappers: 1 });
expect(snapshot.ownership).toEqual({
barOwnedByButtons: true,
tooltipDescribedBy: "ryd-dislike-tooltip",
tooltipOwnedByButtons: true,
tooltipRole: "tooltip",
wrapperOwnedByButtons: true,
});
expect(snapshot.tooltipText).toBe(`${counts.likes} / ${counts.dislikes}`);
expect(snapshot.buttonsGap).toBe("0px");
expect(snapshot.buttonsPosition).toBe("relative");
expect(snapshot.buttonsBox).toEqual({
margin: ["0px", "0px", "0px", "0px"],
padding: ["0px", "0px", "0px", "0px"],
});
expect(snapshot.reactionGroupBox).toEqual(snapshot.buttonsBox);
expect(snapshot.topRowBox).toEqual({
margin: ["0px", "0px", "0px", "0px"],
padding: ["0px", "0px", "10px", "0px"],
});
expect(snapshot.topRowStyle).toEqual({ borderBottomWidth: "1px", paddingBottom: "10px" });
expect(snapshot.wrapperAppearance).toEqual({
box: {
margin: ["0px", "0px", "0px", "0px"],
padding: ["0px", "0px", "0px", "0px"],
},
position: "absolute",
});
expect(snapshot.barContainerAppearance.borderRadius).toBe("2px");
expectComputedColor(snapshot.barContainerAppearance.backgroundColor, 139);
expect(snapshot.barAppearance).toEqual({
backgroundColor: "rgb(241, 241, 241)",
borderRadius: "2px",
});
expect(snapshot.tooltipAppearance).toEqual({
fontSize: "12px",
lineHeight: "16px",
opacity: "0",
padding: ["6px", "8px", "6px", "8px"],
position: "absolute",
visibility: "hidden",
});
expectControlGeometry(snapshot.like);
expectControlGeometry(snapshot.dislike);
expectPressedState(snapshot.like, state === "liked");
expectPressedState(snapshot.dislike, state === "disliked");
expectClose(snapshot.like.outer.right, snapshot.dislike.outer.left);
expectClose(snapshot.wrapper.left, snapshot.like.outer.left);
expectClose(snapshot.wrapper.right, snapshot.dislike.outer.right);
expectClose(snapshot.wrapper.width, snapshot.like.outer.width + snapshot.dislike.outer.width);
expectClose(snapshot.wrapper.height, 2);
expectClose(snapshot.wrapper.top - Math.max(snapshot.like.outer.bottom, snapshot.dislike.outer.bottom), 8);
expectClose(snapshot.barContainer.width, snapshot.wrapper.width);
expectClose(snapshot.barContainer.height, 2);
expectClose(snapshot.bar.height, 2);
expectClose(snapshot.bar.width / snapshot.barContainer.width, counts.likes / (counts.likes + counts.dislikes), 3);
expect(snapshot.topRow.left).toBeGreaterThanOrEqual(0);
expect(snapshot.topRow.right).toBeLessThanOrEqual(snapshot.viewport.width);
expect(snapshot.wrapper.left).toBeGreaterThanOrEqual(snapshot.topRow.left);
expect(snapshot.wrapper.right).toBeLessThanOrEqual(snapshot.topRow.right);
}
function expectNoStructuralLayoutShift(before, after) {
const paths = [
["topRow"],
["buttons"],
["reactionGroup"],
["wrapper"],
["barContainer"],
["like", "outer"],
["like", "button"],
["dislike", "outer"],
["dislike", "button"],
];
const dimensions = ["top", "right", "bottom", "left", "width", "height"];
for (const path of paths) {
const beforeRect = path.reduce((value, key) => value[key], before);
const afterRect = path.reduce((value, key) => value[key], after);
for (const dimension of dimensions) {
expect(
Math.abs(afterRect[dimension] - beforeRect[dimension]),
`${path.join(".")}.${dimension}`,
).toBeLessThanOrEqual(0.25);
}
}
}
async function waitForBarRatio(page, counts) {
const expectedRatio = counts.likes / (counts.likes + counts.dislikes);
await expect
.poll(() =>
page.locator("#return-youtube-dislike-bar").evaluate((bar) => {
const container = bar.parentElement;
return bar.getBoundingClientRect().width / container.getBoundingClientRect().width;
}),
)
.toBeCloseTo(expectedRatio, 3);
}
async function attachFailureScreenshot(testInfo, page, name) {
let body;
try {
const scope = page.locator("#top-row");
body = (await scope.count()) > 0 ? await scope.screenshot({ animations: "disabled" }) : await page.screenshot();
} catch {
body = await page.screenshot().catch(() => null);
}
if (body) {
await testInfo.attach(`${name}-visual-contract-failure`, { body, contentType: "image/png" });
}
}
for (const viewport of VIEWPORTS) {
test.describe(`${viewport.name} watch visual contract`, () => {
for (const transition of TRANSITIONS) {
const name = `${transition.initialState} + ${transition.action} -> ${transition.nextState}`;
test(name, async ({ context, page }, testInfo) => {
await page.setViewportSize({ width: viewport.width, height: viewport.height });
try {
const harness = await launchVisualFixture({ context, page }, transition.initialState);
const before = await readVisualContract(page);
expectVisualState(before, transition.initialState, BASE_COUNTS, viewport);
await page.locator(`[data-ryd-role="${transition.action}"] button`).click();
await expect.poll(() => harness.backend.requestsFor("POST", "/interact/confirmVote").length).toBe(1);
const nextCounts = {
likes: BASE_COUNTS.likes + transition.likesDelta,
dislikes: BASE_COUNTS.dislikes + transition.dislikesDelta,
};
await waitForBarRatio(page, nextCounts);
const after = await readVisualContract(page);
expectVisualState(after, transition.nextState, nextCounts, viewport);
expectNoStructuralLayoutShift(before, after);
expect(harness.backend.requestsFor("GET", "/votes")).toHaveLength(1);
expect(harness.backend.requestsFor("POST", "/interact/vote")).toHaveLength(1);
expect(harness.backend.requestsFor("POST", "/interact/vote")[0].body).toMatchObject({
userId: EXISTING_CREDENTIALS.userId,
value: transition.value,
videoId: VIDEO_A,
});
expect(harness.backend.requestsFor("POST", "/interact/confirmVote")).toHaveLength(1);
expect(harness.backend.blockedRequests).toEqual([]);
expect(harness.consoleErrors).toEqual([]);
expect(harness.pageErrors).toEqual([]);
} catch (error) {
await attachFailureScreenshot(testInfo, page, `${viewport.name}-${name}`);
throw error;
}
}); });
expect(harness.backend.requestsFor("POST", "/interact/confirmVote")).toHaveLength(1);
expect(harness.backend.blockedRequests).toEqual([]);
expect(harness.consoleErrors).toEqual([]);
expect(harness.pageErrors).toEqual([]);
annotateVisualEvidence(testInfo, await captureVisualReview(page, scenario.id));
} catch (error) {
await attachVisualFailureScreenshot(testInfo, page, scenario.id);
throw error;
} }
}); },
} });
@@ -0,0 +1,76 @@
const { assertCurrentLiveArtifact } = require("../e2e/live/live-artifact-readiness");
const BUILD_ID = "0123456789abcdef0123456789abcdef";
function verifiers() {
return {
verifyExtensionBuildReceipt: jest.fn(() => ({ inputHash: "extension-inputs" })),
verifyExtensionJavaScript: jest.fn(() => ({ version: "4.0.5" })),
verifyUserscript: jest.fn(() => ({ inputHash: "userscript-inputs" })),
};
}
test("an extension live run verifies current source inputs and the generated browser bundles", () => {
const checks = verifiers();
expect(assertCurrentLiveArtifact("extension", BUILD_ID, checks)).toEqual({
artifact: { version: "4.0.5" },
buildId: BUILD_ID,
receipt: { inputHash: "extension-inputs" },
runtime: "extension",
});
expect(checks.verifyExtensionBuildReceipt).toHaveBeenCalledTimes(1);
expect(checks.verifyExtensionJavaScript).toHaveBeenCalledTimes(1);
expect(checks.verifyUserscript).not.toHaveBeenCalled();
});
test("a userscript live run verifies the exact live receipt and build ID", () => {
const checks = verifiers();
expect(assertCurrentLiveArtifact("userscript", BUILD_ID, checks)).toEqual({
buildId: BUILD_ID,
receipt: { inputHash: "userscript-inputs" },
runtime: "userscript",
});
expect(checks.verifyUserscript).toHaveBeenCalledWith({ expectedBuildId: BUILD_ID, liveTestBuild: true });
expect(checks.verifyExtensionBuildReceipt).not.toHaveBeenCalled();
expect(checks.verifyExtensionJavaScript).not.toHaveBeenCalled();
});
test.each([
[
"extension receipt",
{
verifyExtensionBuildReceipt: () => {
throw new Error("stale extension source");
},
},
],
[
"extension bundles",
{
verifyExtensionJavaScript: () => {
throw new Error("stale extension bundle");
},
},
],
[
"userscript receipt",
{
verifyUserscript: () => {
throw new Error("stale userscript source");
},
},
],
])("propagates a failed %s check before browser attachment", (_label, override) => {
const runtime = _label.startsWith("userscript") ? "userscript" : "extension";
expect(() => assertCurrentLiveArtifact(runtime, BUILD_ID, { ...verifiers(), ...override })).toThrow(/stale/);
});
test("rejects an unsupported live runtime without verifying another artifact", () => {
const checks = verifiers();
expect(() => assertCurrentLiveArtifact("browser", BUILD_ID, checks)).toThrow("Unsupported live artifact runtime");
expect(checks.verifyExtensionBuildReceipt).not.toHaveBeenCalled();
expect(checks.verifyExtensionJavaScript).not.toHaveBeenCalled();
expect(checks.verifyUserscript).not.toHaveBeenCalled();
});
@@ -0,0 +1,390 @@
/** @jest-environment jsdom */
const {
classifyAccountProbeFailure,
inspectVisibleAccountMenu,
isCommittedYoutubePage,
normalizeChannelHandle,
selectAuthenticatedYoutubeContext,
verifyCandidateYoutubePage,
verifyAuthenticatedYoutubePage,
} = require("../e2e/live/live-authenticated-context");
function page(url, { closed = false, context = null } = {}) {
return {
context: () => context,
isClosed: jest.fn(() => closed),
url: jest.fn(() => url),
};
}
function context(pages) {
const value = { pages: jest.fn(() => pages) };
for (const currentPage of pages) currentPage.context = () => value;
return value;
}
function makeHandle(value) {
return {
dispose: jest.fn().mockResolvedValue(undefined),
jsonValue: jest.fn().mockResolvedValue(value),
};
}
describe("authenticated live Chromium context selection", () => {
test.each([
"https://youtube.com/",
"https://www.youtube.com/watch?v=abcdefghijk",
"https://m.youtube.com/shorts/abcdefghijk",
])("accepts a committed ordinary YouTube page at %s", (url) => {
expect(isCommittedYoutubePage(page(url))).toBe(true);
});
test.each([
"about:blank",
"chrome://extensions/",
"chrome-extension://abcdefghijklmnopabcdefghijklmnop/popup.html",
"http://www.youtube.com/",
"https://studio.youtube.com/",
"https://www.youtube.com.example.test/",
"https://user:password@www.youtube.com/",
])("rejects an internal, non-YouTube, or unsafe page at %s", (url) => {
expect(isCommittedYoutubePage(page(url))).toBe(false);
});
test("rejects closed and malformed pages", () => {
expect(isCommittedYoutubePage(page("https://www.youtube.com/", { closed: true }))).toBe(false);
expect(isCommittedYoutubePage(page("not a URL"))).toBe(false);
expect(isCommittedYoutubePage(null)).toBe(false);
});
test("normalizes public handles without accepting an account name or URL", () => {
expect(normalizeChannelHandle(" @Anarios-RYD ")).toBe("@anarios-ryd");
expect(() => normalizeChannelHandle("Anarios RYD")).toThrow(/exact public YouTube @handle/);
expect(() => normalizeChannelHandle("https://youtube.com/@anarios-ryd")).toThrow(/exact public YouTube @handle/);
});
test("scans each context until its profile is resolved and returns the existing matching page", async () => {
const internal = page("chrome://extensions/");
const unrelated = page("https://example.test/");
const wrongYoutube = page("https://www.youtube.com/watch?v=wrongvideo1");
const matchingYoutube = page("https://www.youtube.com/watch?v=rightvideo1");
const laterYoutube = page("https://m.youtube.com/shorts/later-video");
const firstContext = context([internal, wrongYoutube]);
const secondContext = context([unrelated, matchingYoutube, laterYoutube]);
const verifyPage = jest.fn(async (candidate) => candidate === matchingYoutube || candidate === laterYoutube);
const browser = { contexts: jest.fn(() => [firstContext, secondContext]) };
await expect(selectAuthenticatedYoutubeContext(browser, "@Expected", { verifyPage })).resolves.toEqual({
context: secondContext,
contextIndex: 1,
pageIndex: 1,
sessionPage: matchingYoutube,
});
expect(firstContext.pages).toHaveBeenCalledTimes(1);
expect(secondContext.pages).toHaveBeenCalledTimes(2);
expect(verifyPage.mock.calls.map(([candidate]) => candidate)).toEqual([wrongYoutube, matchingYoutube]);
expect(verifyPage).toHaveBeenCalledWith(expect.anything(), "@expected", {
accountProbeTimeoutMilliseconds: 8000,
});
});
test("allows several matching YouTube pages only when they belong to the same context", async () => {
const first = page("https://www.youtube.com/watch?v=abcdefghijk");
const second = page("https://www.youtube.com/shorts/lmnopqrstuv");
const matchingContext = context([first, second]);
await expect(
selectAuthenticatedYoutubeContext({ contexts: () => [matchingContext] }, "@expected", {
verifyPage: async () => true,
}),
).resolves.toMatchObject({ context: matchingContext, pageIndex: 0, sessionPage: first });
});
test("refuses two matching authenticated contexts", async () => {
const first = page("https://www.youtube.com/");
const second = page("https://m.youtube.com/");
await expect(
selectAuthenticatedYoutubeContext({ contexts: () => [context([first]), context([second])] }, "@expected", {
verifyPage: async () => true,
}),
).rejects.toThrow(/More than one attached Chromium context/);
});
test("refuses to select when another candidate context could not be verified", async () => {
const matching = page("https://www.youtube.com/watch?v=abcdefghijk");
const unresolved = page("https://www.youtube.com/watch?v=lmnopqrstuv");
await expect(
selectAuthenticatedYoutubeContext({ contexts: () => [context([matching]), context([unresolved])] }, "@expected", {
verifyPage: async (candidate) => {
if (candidate === unresolved) throw new Error("page disappeared");
return true;
},
}),
).rejects.toThrow(/Could not safely verify every candidate Chromium profile/);
});
test("retries a transient candidate-page failure before selecting its exact authenticated context", async () => {
const matching = page("https://www.youtube.com/watch?v=abcdefghijk");
const matchingContext = context([matching]);
const waitForRetry = jest.fn().mockResolvedValue(undefined);
const timeout = Object.assign(new Error("account menu did not hydrate"), { name: "TimeoutError" });
const verifyPage = jest.fn().mockRejectedValueOnce(timeout).mockResolvedValueOnce(true);
const contexts = jest.fn(() => [matchingContext]);
await expect(
selectAuthenticatedYoutubeContext({ contexts }, "@expected", {
accountSelectionRetryDelayMilliseconds: 10,
verifyPage,
waitForRetry,
}),
).resolves.toMatchObject({ context: matchingContext, pageIndex: 0, sessionPage: matching });
expect(verifyPage).toHaveBeenCalledTimes(2);
expect(waitForRetry).toHaveBeenCalledWith(10);
expect(contexts).toHaveBeenCalledTimes(2);
});
test("re-enumerates pages and can select a matching YouTube page which commits before the second pass", async () => {
const loading = page("about:blank");
const matching = page("https://www.youtube.com/watch?v=abcdefghijk");
const candidateContext = {
pages: jest.fn().mockReturnValueOnce([loading]).mockReturnValue([matching]),
};
loading.context = matching.context = () => candidateContext;
const contexts = jest.fn(() => [candidateContext]);
const verifyPage = jest.fn().mockResolvedValue(true);
await expect(
selectAuthenticatedYoutubeContext({ contexts }, "@expected", {
verifyPage,
waitForRetry: jest.fn().mockResolvedValue(undefined),
}),
).resolves.toMatchObject({ context: candidateContext, sessionPage: matching });
expect(contexts).toHaveBeenCalledTimes(2);
expect(verifyPage).toHaveBeenCalledTimes(1);
});
test("rescans instead of returning a selected page which closed after its account proof", async () => {
const closedAfterProof = page("https://www.youtube.com/watch?v=abcdefghijk");
const replacement = page("https://www.youtube.com/watch?v=lmnopqrstuv");
const candidateContext = {
pages: jest.fn().mockReturnValueOnce([closedAfterProof]).mockReturnValue([replacement]),
};
closedAfterProof.context = replacement.context = () => candidateContext;
const verifyPage = jest.fn(async (candidate) => {
if (candidate === closedAfterProof) closedAfterProof.isClosed.mockReturnValue(true);
return true;
});
await expect(
selectAuthenticatedYoutubeContext({ contexts: () => [candidateContext] }, "@expected", {
verifyPage,
waitForRetry: jest.fn().mockResolvedValue(undefined),
}),
).resolves.toMatchObject({ context: candidateContext, sessionPage: replacement });
expect(verifyPage.mock.calls.map(([candidate]) => candidate)).toEqual([closedAfterProof, replacement]);
});
test("keeps a repeatedly failing committed candidate fail-closed and reports only a sanitized reason", async () => {
const unresolved = page("https://www.youtube.com/watch?v=abcdefghijk");
const privateFailure = Object.assign(new Error("private-page-detail@example.test"), { name: "TimeoutError" });
const selection = selectAuthenticatedYoutubeContext({ contexts: () => [context([unresolved])] }, "@expected", {
verifyPage: jest.fn().mockRejectedValue(privateFailure),
waitForRetry: jest.fn().mockResolvedValue(undefined),
});
await expect(selection).rejects.toThrow(
"Could not safely verify every candidate Chromium profile for @expected; no extension was changed. Unresolved probes: context 1 page 1 (timeout).",
);
await expect(selection).rejects.not.toThrow(/private-page-detail/);
});
test("ignores a candidate which leaves YouTube after a transient probe failure", async () => {
const matching = page("https://www.youtube.com/watch?v=abcdefghijk");
const stale = page("https://www.youtube.com/watch?v=lmnopqrstuv");
const matchingContext = context([matching]);
context([stale]);
const verifyPage = jest.fn(async (candidate) => {
if (candidate === matching) return true;
stale.url.mockReturnValue("about:blank");
throw new Error("Execution context was destroyed");
});
await expect(
selectAuthenticatedYoutubeContext({ contexts: () => [matchingContext, stale.context()] }, "@expected", {
verifyPage,
waitForRetry: jest.fn(),
}),
).resolves.toMatchObject({ context: matchingContext, sessionPage: matching });
expect(verifyPage).toHaveBeenCalledTimes(2);
});
test.each([
[Object.assign(new Error("menu timeout"), { name: "TimeoutError" }), "timeout"],
[new Error("Target page has been closed"), "page-closed"],
[new Error("Execution context was destroyed, most likely because of a navigation"), "navigation-race"],
[new Error("unexpected failure"), "probe-error"],
])("classifies account-probe failures without returning their message", (error, expectedKind) => {
expect(classifyAccountProbeFailure(error)).toBe(expectedKind);
});
test("resolves a candidate that closes during a failed probe as a non-match", async () => {
const candidate = page("https://www.youtube.com/");
const result = await verifyCandidateYoutubePage(
candidate,
"@expected",
async () => {
candidate.isClosed.mockReturnValue(true);
throw new Error("Target page has been closed");
},
{ waitForRetry: jest.fn() },
);
expect(result).toEqual({ matched: false, resolved: true });
});
test("reports separately when there are no YouTube pages and when no account matches", async () => {
await expect(
selectAuthenticatedYoutubeContext(
{ contexts: () => [context([page("chrome://extensions/"), page("https://example.test/")])] },
"@expected",
{ verifyPage: jest.fn() },
),
).rejects.toThrow(/No pre-existing committed HTTPS YouTube page/);
await expect(
selectAuthenticatedYoutubeContext(
{ contexts: () => [context([page("https://www.youtube.com/")])] },
"@expected",
{ verifyPage: async () => false },
),
).rejects.toThrow(/No attached Chromium context has a pre-existing signed-in YouTube page/);
});
test("uses only scoped visible account-menu links and compares the exact handle", () => {
document.body.innerHTML = `
<a id="content-link" href="https://www.youtube.com/@expected">Unrelated video description link</a>
<ytd-multi-page-menu-renderer>
<a id="account-link" href="https://www.youtube.com/@expected/">View your channel</a>
</ytd-multi-page-menu-renderer>
`;
for (const element of document.querySelectorAll("ytd-multi-page-menu-renderer, a")) {
element.getBoundingClientRect = () => ({ height: 20, width: 100 });
}
expect(inspectVisibleAccountMenu("@expected")).toEqual({ matchedExpectedHandle: true, ready: true });
expect(inspectVisibleAccountMenu("@different")).toEqual({ matchedExpectedHandle: false, ready: true });
document.querySelector("ytd-multi-page-menu-renderer").remove();
expect(inspectVisibleAccountMenu("@expected")).toBeNull();
});
test("can verify the active handle from the account header without returning account text", () => {
document.body.innerHTML = `
<ytd-multi-page-menu-renderer>
<ytd-active-account-header-renderer><span>Private Name private@example.test</span><span id="channel-handle">@Expected</span></ytd-active-account-header-renderer>
</ytd-multi-page-menu-renderer>
`;
for (const element of document.querySelectorAll(
"ytd-multi-page-menu-renderer, ytd-active-account-header-renderer, span",
)) {
element.getBoundingClientRect = () => ({ height: 20, width: 100 });
}
const result = inspectVisibleAccountMenu("@expected");
expect(result).toEqual({ matchedExpectedHandle: true, ready: true });
expect(JSON.stringify(result)).not.toContain("private@example.test");
expect(JSON.stringify(result)).not.toContain("Private Name");
});
test("verifies a signed-in page by opening and restoring the account menu", async () => {
const menuStates = [null, { matchedExpectedHandle: true, ready: true }];
const avatar = { click: jest.fn().mockResolvedValue(undefined), isVisible: jest.fn().mockResolvedValue(true) };
const avatars = { count: jest.fn().mockResolvedValue(1), nth: jest.fn(() => avatar) };
const browserPage = {
bringToFront: jest.fn().mockResolvedValue(undefined),
evaluate: jest
.fn()
.mockResolvedValueOnce({ committed: true, configuredLoggedIn: true })
.mockResolvedValueOnce(menuStates.shift()),
isClosed: () => false,
keyboard: { press: jest.fn().mockResolvedValue(undefined) },
locator: jest.fn(() => avatars),
url: () => "https://www.youtube.com/watch?v=abcdefghijk",
waitForFunction: jest.fn().mockResolvedValue(makeHandle(menuStates.shift())),
};
await expect(verifyAuthenticatedYoutubePage(browserPage, "@Expected")).resolves.toBe(true);
expect(browserPage.bringToFront).toHaveBeenCalledTimes(1);
expect(avatar.click).toHaveBeenCalledWith({ timeout: 8000 });
expect(browserPage.keyboard.press).toHaveBeenCalledWith("Escape");
expect(browserPage.waitForFunction).toHaveBeenCalledWith(inspectVisibleAccountMenu, "@expected", {
polling: 100,
timeout: 8000,
});
});
test("rejects an explicitly signed-out page without opening the account menu", async () => {
const browserPage = {
evaluate: jest.fn().mockResolvedValue({ committed: true, configuredLoggedIn: false }),
isClosed: () => false,
locator: jest.fn(),
url: () => "https://www.youtube.com/",
};
await expect(verifyAuthenticatedYoutubePage(browserPage, "@expected")).resolves.toBe(false);
expect(browserPage.locator).not.toHaveBeenCalled();
});
test("surfaces an account-menu timeout or broken page as an unresolved profile", async () => {
const timeout = Object.assign(new Error("menu did not appear"), { name: "TimeoutError" });
const createBrowserPage = (waitError) => {
const avatar = { click: jest.fn().mockResolvedValue(undefined), isVisible: jest.fn().mockResolvedValue(true) };
return {
evaluate: jest
.fn()
.mockResolvedValueOnce({ committed: true, configuredLoggedIn: true })
.mockResolvedValueOnce(null),
isClosed: () => false,
keyboard: { press: jest.fn().mockResolvedValue(undefined) },
locator: jest.fn(() => ({ count: async () => 1, nth: () => avatar })),
url: () => "https://www.youtube.com/",
waitForFunction: jest.fn().mockRejectedValue(waitError),
};
};
await expect(verifyAuthenticatedYoutubePage(createBrowserPage(timeout), "@expected")).rejects.toThrow(
/menu did not appear/,
);
await expect(
verifyAuthenticatedYoutubePage(createBrowserPage(new Error("Target page has been closed")), "@expected"),
).rejects.toThrow(/Target page has been closed/);
});
test("keeps a serialized account-menu match when its remote handle cannot be disposed during navigation", async () => {
const menuHandle = makeHandle({ matchedExpectedHandle: true, ready: true });
menuHandle.dispose.mockRejectedValue(new Error("Execution context was destroyed"));
const avatar = { click: jest.fn().mockResolvedValue(undefined), isVisible: jest.fn().mockResolvedValue(true) };
const browserPage = {
evaluate: jest
.fn()
.mockResolvedValueOnce({ committed: true, configuredLoggedIn: true })
.mockResolvedValueOnce(null),
isClosed: () => false,
keyboard: { press: jest.fn().mockResolvedValue(undefined) },
locator: jest.fn(() => ({ count: async () => 1, nth: () => avatar })),
url: () => "https://www.youtube.com/",
waitForFunction: jest.fn().mockResolvedValue(menuHandle),
};
await expect(verifyAuthenticatedYoutubePage(browserPage, "@expected")).resolves.toBe(true);
expect(menuHandle.dispose).toHaveBeenCalledTimes(1);
});
test("does not read or return cookies, storage, account names, or email addresses", () => {
const probeSource = `${inspectVisibleAccountMenu}\n${verifyAuthenticatedYoutubePage}`;
expect(probeSource).not.toMatch(/\.cookie|localStorage|sessionStorage|\.email|accountName/);
});
});
@@ -0,0 +1,233 @@
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const ACTIVE_PORT_FILE_NAME = "DevToolsActivePort";
const BROWSER_ALIASES = new Set(["brave", "chrome", "chromium", "edge"]);
function nonEmptyEnvironmentValue(environment, name) {
const value = environment[name]?.trim();
return value || null;
}
function platformPath(platform, pathImpl) {
if (pathImpl) return pathImpl;
return platform === "win32" ? path.win32 : path.posix;
}
function browserUserDataDirectories(browser, { environment, homedir, pathImpl, platform }) {
const join = pathImpl.join.bind(pathImpl);
const home = nonEmptyEnvironmentValue(environment, "HOME") || homedir();
if (platform === "win32") {
const localAppData = nonEmptyEnvironmentValue(environment, "LOCALAPPDATA");
if (!localAppData) return [];
const directories = {
brave: [join(localAppData, "BraveSoftware", "Brave-Browser", "User Data")],
chrome: [
join(localAppData, "Google", "Chrome", "User Data"),
join(localAppData, "Google", "Chrome Beta", "User Data"),
join(localAppData, "Google", "Chrome Dev", "User Data"),
join(localAppData, "Google", "Chrome SxS", "User Data"),
],
chromium: [join(localAppData, "Chromium", "User Data")],
edge: [join(localAppData, "Microsoft", "Edge", "User Data")],
};
return directories[browser] || [];
}
if (platform === "darwin") {
const applicationSupport = join(home, "Library", "Application Support");
const directories = {
brave: [join(applicationSupport, "BraveSoftware", "Brave-Browser")],
chrome: [
join(applicationSupport, "Google", "Chrome"),
join(applicationSupport, "Google", "Chrome Beta"),
join(applicationSupport, "Google", "Chrome Dev"),
join(applicationSupport, "Google", "Chrome Canary"),
],
chromium: [join(applicationSupport, "Chromium")],
edge: [join(applicationSupport, "Microsoft Edge")],
};
return directories[browser] || [];
}
const configRoot = nonEmptyEnvironmentValue(environment, "XDG_CONFIG_HOME") || join(home, ".config");
const directories = {
brave: [join(configRoot, "BraveSoftware", "Brave-Browser")],
chrome: [
join(configRoot, "google-chrome"),
join(configRoot, "google-chrome-beta"),
join(configRoot, "google-chrome-unstable"),
],
chromium: [join(configRoot, "chromium")],
edge: [join(configRoot, "microsoft-edge")],
};
return directories[browser] || [];
}
function knownActivePortFiles(browser, settings) {
const browsers = browser ? [browser] : ["chrome", "brave", "edge", "chromium"];
return browsers.flatMap((name) =>
browserUserDataDirectories(name, settings).map((directory) =>
settings.pathImpl.join(directory, ACTIVE_PORT_FILE_NAME),
),
);
}
function parseBareHostAndPort(value) {
let url;
try {
url = new URL(`http://${value}`);
} catch {
return null;
}
if (!url.hostname || !url.port || url.username || url.password || url.pathname !== "/" || url.search || url.hash) {
return null;
}
const port = Number(url.port);
if (!Number.isSafeInteger(port) || port < 1 || port > 65535) return null;
return { authority: url.host, port };
}
function parseActivePortFile(contents, filePath) {
const [rawPort, rawBrowserPath] = String(contents).split(/\r?\n/);
const port = Number(rawPort?.trim());
if (!/^\d+$/.test(rawPort?.trim() || "") || !Number.isSafeInteger(port) || port < 1 || port > 65535) {
throw new Error(`The CDP active-port file at ${filePath} has an invalid port.`);
}
let browserPath = rawBrowserPath?.trim();
if (/^wss?:\/\//i.test(browserPath || "")) {
let url;
try {
url = new URL(browserPath);
} catch {
throw new Error(`The CDP active-port file at ${filePath} has an invalid browser WebSocket URL.`);
}
browserPath = `${url.pathname}${url.search}`;
}
if (!/^\/devtools\/browser\/[A-Za-z0-9._-]+$/.test(browserPath || "")) {
throw new Error(`The CDP active-port file at ${filePath} has an invalid browser WebSocket path.`);
}
return { browserPath, port };
}
function unique(values, platform) {
const keys = new Set();
return values.filter((value) => {
const key = platform === "win32" ? value.toLowerCase() : value;
if (keys.has(key)) return false;
keys.add(key);
return true;
});
}
function activePortFileCandidates(browser, settings) {
const activePortFile = nonEmptyEnvironmentValue(settings.environment, "RYD_CDP_ACTIVE_PORT_FILE");
const userDataDirectory = nonEmptyEnvironmentValue(settings.environment, "RYD_CDP_USER_DATA_DIR");
if (activePortFile && userDataDirectory) {
throw new Error("Set only one of RYD_CDP_ACTIVE_PORT_FILE and RYD_CDP_USER_DATA_DIR.");
}
if (activePortFile) return { explicit: true, paths: [settings.pathImpl.resolve(activePortFile)] };
if (userDataDirectory) {
return {
explicit: true,
paths: [settings.pathImpl.join(settings.pathImpl.resolve(userDataDirectory), ACTIVE_PORT_FILE_NAME)],
};
}
return { explicit: false, paths: unique(knownActivePortFiles(browser, settings), settings.platform) };
}
function readCandidate(filePath, settings, explicit) {
try {
return parseActivePortFile(settings.readFileSync(filePath, "utf8"), filePath);
} catch (error) {
if (!explicit && ["ENOENT", "ENOTDIR"].includes(error.code)) return null;
throw error;
}
}
function protocolEndpoint(value) {
if (!/^(?:https?|wss?):\/\//i.test(value)) return null;
let url;
try {
url = new URL(value);
} catch {
throw new Error("RYD_CDP_ENDPOINT must be a valid HTTP(S), WebSocket, host:port, or browser-alias endpoint.");
}
if (!["http:", "https:", "ws:", "wss:"].includes(url.protocol) || !url.hostname) {
throw new Error("RYD_CDP_ENDPOINT must be a valid HTTP(S), WebSocket, host:port, or browser-alias endpoint.");
}
return value;
}
function resolveCdpEndpoint(
configuredEndpoint,
{
environment = process.env,
homedir = os.homedir,
pathImpl,
platform = process.platform,
readFileSync = fs.readFileSync,
} = {},
) {
const value = configuredEndpoint?.trim() || "chrome";
const explicitProtocolEndpoint = protocolEndpoint(value);
if (explicitProtocolEndpoint) return explicitProtocolEndpoint;
const browser = BROWSER_ALIASES.has(value.toLowerCase()) ? value.toLowerCase() : null;
const bareEndpoint = browser ? null : parseBareHostAndPort(value);
if (!browser && !bareEndpoint) {
throw new Error(
"RYD_CDP_ENDPOINT must be an HTTP(S) URL, WebSocket URL, host:port shown by Chromium, or one of chrome, brave, edge, and chromium.",
);
}
const settings = {
environment,
homedir,
pathImpl: platformPath(platform, pathImpl),
platform,
readFileSync,
};
const candidates = activePortFileCandidates(browser, settings);
const matches = [];
for (const filePath of candidates.paths) {
const activePort = readCandidate(filePath, settings, candidates.explicit);
if (!activePort || (bareEndpoint && activePort.port !== bareEndpoint.port)) continue;
const authority = bareEndpoint?.authority || `127.0.0.1:${activePort.port}`;
matches.push({ endpoint: `ws://${authority}${activePort.browserPath}`, filePath });
}
const endpoints = [...new Set(matches.map(({ endpoint }) => endpoint))];
if (endpoints.length === 1) return endpoints[0];
if (endpoints.length > 1) {
throw new Error(
`More than one DevToolsActivePort file matches RYD_CDP_ENDPOINT=${value}. Set RYD_CDP_ACTIVE_PORT_FILE or RYD_CDP_USER_DATA_DIR to select the intended browser profile.`,
);
}
const fileDescription = candidates.explicit
? candidates.paths[0]
: candidates.paths.length
? candidates.paths.join(", ")
: "the platform's standard browser user-data directories";
const portDescription = bareEndpoint ? ` matching port ${bareEndpoint.port}` : "";
throw new Error(
`Could not find a valid DevToolsActivePort file${portDescription} in ${fileDescription}. ` +
"Set RYD_CDP_ACTIVE_PORT_FILE to that file, RYD_CDP_USER_DATA_DIR to its directory, or use an explicit ws:// or legacy http:// endpoint.",
);
}
module.exports = {
ACTIVE_PORT_FILE_NAME,
browserUserDataDirectories,
parseActivePortFile,
parseBareHostAndPort,
resolveCdpEndpoint,
};
@@ -0,0 +1,226 @@
const path = require("node:path");
const {
browserUserDataDirectories,
parseActivePortFile,
parseBareHostAndPort,
resolveCdpEndpoint,
} = require("./live-cdp-endpoint");
function missingFile(filePath) {
throw Object.assign(new Error(`missing ${filePath}`), { code: "ENOENT" });
}
function mappedReader(files) {
return jest.fn((filePath) => {
if (Object.prototype.hasOwnProperty.call(files, filePath)) return files[filePath];
return missingFile(filePath);
});
}
describe("live CDP endpoint resolver", () => {
test.each([
"ws://127.0.0.1:60011/devtools/browser/session-id",
"wss://debug.example.test/devtools/browser/session-id",
"http://127.0.0.1:9222",
"https://debug.example.test:9222",
])("preserves an explicit protocol endpoint without reading the filesystem: %s", (endpoint) => {
const readFileSync = jest.fn();
expect(resolveCdpEndpoint(endpoint, { readFileSync })).toBe(endpoint);
expect(readFileSync).not.toHaveBeenCalled();
});
test("resolves Chrome's host:port-only UI value through the matching Windows active-port file", () => {
const chromeFile = "C:\\Users\\tester\\AppData\\Local\\Google\\Chrome\\User Data\\DevToolsActivePort";
const braveFile = "C:\\Users\\tester\\AppData\\Local\\BraveSoftware\\Brave-Browser\\User Data\\DevToolsActivePort";
const readFileSync = mappedReader({
[braveFile]: "50000\n/devtools/browser/brave-session\n",
[chromeFile]: "60011\r\n/devtools/browser/chrome-session\r\n",
});
expect(
resolveCdpEndpoint("127.0.0.1:60011", {
environment: { LOCALAPPDATA: "C:\\Users\\tester\\AppData\\Local" },
homedir: () => "C:\\Users\\tester",
pathImpl: path.win32,
platform: "win32",
readFileSync,
}),
).toBe("ws://127.0.0.1:60011/devtools/browser/chrome-session");
expect(readFileSync).toHaveBeenCalledWith(chromeFile, "utf8");
});
test("uses the configured bare hostname while taking the browser path from the file", () => {
const activePortFile = "C:\\portable\\profile\\DevToolsActivePort";
expect(
resolveCdpEndpoint("localhost:60011", {
environment: { RYD_CDP_USER_DATA_DIR: "C:\\portable\\profile" },
pathImpl: path.win32,
platform: "win32",
readFileSync: mappedReader({ [activePortFile]: "60011\n/devtools/browser/portable-session\n" }),
}),
).toBe("ws://localhost:60011/devtools/browser/portable-session");
});
test("supports a bracketed IPv6 loopback host", () => {
expect(
resolveCdpEndpoint("[::1]:60011", {
environment: { RYD_CDP_ACTIVE_PORT_FILE: "/profile/DevToolsActivePort" },
pathImpl: path.posix,
platform: "linux",
readFileSync: mappedReader({
"/profile/DevToolsActivePort": "60011\n/devtools/browser/ipv6-session\n",
}),
}),
).toBe("ws://[::1]:60011/devtools/browser/ipv6-session");
});
test.each([
[
"win32",
path.win32,
{ LOCALAPPDATA: "C:\\Users\\tester\\AppData\\Local" },
() => "C:\\Users\\tester",
"chrome",
"C:\\Users\\tester\\AppData\\Local\\Google\\Chrome\\User Data",
],
[
"darwin",
path.posix,
{ HOME: "/Users/tester" },
() => "/fallback",
"brave",
"/Users/tester/Library/Application Support/BraveSoftware/Brave-Browser",
],
[
"linux",
path.posix,
{ HOME: "/home/tester", XDG_CONFIG_HOME: "/custom/config" },
() => "/fallback",
"edge",
"/custom/config/microsoft-edge",
],
])("discovers standard %s browser user-data roots", (platform, pathImpl, environment, homedir, browser, expected) => {
expect(browserUserDataDirectories(browser, { environment, homedir, pathImpl, platform })).toContain(expected);
});
test.each(["chrome", "CHROME", "brave", "edge", "chromium", undefined])(
"resolves browser alias %p from its active-port file",
(alias) => {
const activePortFile = "/custom/profile/DevToolsActivePort";
expect(
resolveCdpEndpoint(alias, {
environment: { RYD_CDP_ACTIVE_PORT_FILE: activePortFile },
pathImpl: path.posix,
platform: "linux",
readFileSync: mappedReader({
[activePortFile]: "60123\n/devtools/browser/alias-session\n",
}),
}),
).toBe("ws://127.0.0.1:60123/devtools/browser/alias-session");
},
);
test("accepts an absolute WebSocket URL in an active-port file but uses the configured authority", () => {
expect(
resolveCdpEndpoint("localhost:60011", {
environment: { RYD_CDP_ACTIVE_PORT_FILE: "/profile/DevToolsActivePort" },
pathImpl: path.posix,
platform: "linux",
readFileSync: mappedReader({
"/profile/DevToolsActivePort": "60011\nws://127.0.0.1:60011/devtools/browser/full-url-session\n",
}),
}),
).toBe("ws://localhost:60011/devtools/browser/full-url-session");
});
test("uses an exact active-port-file override", () => {
const readFileSync = mappedReader({
"/custom/ChromeActivePort": "61234\n/devtools/browser/custom-session\n",
});
expect(
resolveCdpEndpoint("chrome", {
environment: { RYD_CDP_ACTIVE_PORT_FILE: "/custom/ChromeActivePort" },
pathImpl: path.posix,
platform: "linux",
readFileSync,
}),
).toBe("ws://127.0.0.1:61234/devtools/browser/custom-session");
expect(readFileSync).toHaveBeenCalledTimes(1);
});
test("rejects a port mismatch instead of attaching to a different browser", () => {
expect(() =>
resolveCdpEndpoint("127.0.0.1:60011", {
environment: { RYD_CDP_ACTIVE_PORT_FILE: "/profile/DevToolsActivePort" },
pathImpl: path.posix,
platform: "linux",
readFileSync: mappedReader({
"/profile/DevToolsActivePort": "60012\n/devtools/browser/wrong-browser\n",
}),
}),
).toThrow(/matching port 60011/);
});
test("rejects ambiguous standard profiles with the same port and different browser paths", () => {
const localAppData = "C:\\Users\\tester\\AppData\\Local";
const chromeFile = `${localAppData}\\Google\\Chrome\\User Data\\DevToolsActivePort`;
const braveFile = `${localAppData}\\BraveSoftware\\Brave-Browser\\User Data\\DevToolsActivePort`;
expect(() =>
resolveCdpEndpoint("127.0.0.1:60011", {
environment: { LOCALAPPDATA: localAppData },
pathImpl: path.win32,
platform: "win32",
readFileSync: mappedReader({
[chromeFile]: "60011\n/devtools/browser/chrome-session\n",
[braveFile]: "60011\n/devtools/browser/brave-session\n",
}),
}),
).toThrow(/More than one DevToolsActivePort file matches/);
});
test("rejects conflicting file and user-data-directory overrides", () => {
expect(() =>
resolveCdpEndpoint("chrome", {
environment: {
RYD_CDP_ACTIVE_PORT_FILE: "/profile/DevToolsActivePort",
RYD_CDP_USER_DATA_DIR: "/profile",
},
pathImpl: path.posix,
platform: "linux",
}),
).toThrow(/Set only one/);
});
test.each([
["not-a-port\n/devtools/browser/session\n", /invalid port/],
["70000\n/devtools/browser/session\n", /invalid port/],
["60011\n/devtools/page/not-a-browser\n", /invalid browser WebSocket path/],
["60011\n", /invalid browser WebSocket path/],
["60011\nws://bad host/devtools/browser/session\n", /invalid browser WebSocket URL/],
])("rejects a malformed active-port file", (contents, expectedError) => {
expect(() => parseActivePortFile(contents, "DevToolsActivePort")).toThrow(expectedError);
});
test.each([
["127.0.0.1:60011", { authority: "127.0.0.1:60011", port: 60011 }],
["localhost:9222", { authority: "localhost:9222", port: 9222 }],
["[::1]:1234", { authority: "[::1]:1234", port: 1234 }],
["localhost", null],
["localhost:0", null],
["localhost:70000", null],
["localhost:9222/json/version", null],
])("parses bare host/port endpoint %p", (endpoint, expected) => {
expect(parseBareHostAndPort(endpoint)).toEqual(expected);
});
test.each(["not-an-endpoint", "ftp://127.0.0.1:9222", "127.0.0.1", "127.0.0.1:70000/path"])(
"rejects unsupported endpoint %p",
(endpoint) => {
expect(() => resolveCdpEndpoint(endpoint)).toThrow(/RYD_CDP_ENDPOINT/);
},
);
});
@@ -4,10 +4,14 @@
const path = require("node:path"); const path = require("node:path");
const { const {
LiveFatalSignalGuard,
LiveReadOnlyGate,
LiveRunDiagnostics, LiveRunDiagnostics,
UNHANDLED_REJECTION_PREFIX, UNHANDLED_REJECTION_PREFIX,
diagnosticApiUrl, diagnosticApiUrl,
environmentalConsoleWarningId,
readLivePageState, readLivePageState,
runIndependentLoggedStages,
runLoggedStage, runLoggedStage,
} = require("../e2e/live/live-diagnostics"); } = require("../e2e/live/live-diagnostics");
@@ -34,6 +38,29 @@ function visibleRect() {
return { bottom: 100, height: 90, left: 10, right: 60, top: 10, width: 50 }; return { bottom: 100, height: 90, left: 10, right: 60, top: 10, width: 50 };
} }
function consoleError(text, url = "https://www.youtube.com/watch?v=abcdefghijk") {
return {
location: () => ({ lineNumber: 7, url }),
text: () => text,
type: () => "error",
};
}
function createLiveHarness(diagnosticsOptions = {}) {
const context = new FakeEmitter();
context.serviceWorkers = jest.fn(() => []);
const page = new FakeEmitter();
page.addInitScript = jest.fn().mockResolvedValue(undefined);
page.evaluate = jest.fn().mockResolvedValue(undefined);
page.isClosed = jest.fn(() => false);
page.url = jest.fn(() => "https://www.youtube.com/watch?v=abcdefghijk");
return {
context,
diagnostics: new LiveRunDiagnostics(page, context, { log: jest.fn(), ...diagnosticsOptions }),
page,
};
}
describe("live interactive diagnostics", () => { describe("live interactive diagnostics", () => {
test("captures runtime and Shorts ownership state without dumping page HTML", () => { test("captures runtime and Shorts ownership state without dumping page HTML", () => {
document.documentElement.setAttribute("data-ryd-userscript-version", "3.2.0"); document.documentElement.setAttribute("data-ryd-userscript-version", "3.2.0");
@@ -159,6 +186,417 @@ describe("live interactive diagnostics", () => {
expect(diagnostics.stageFailed).toHaveBeenCalledWith("failure", expect.any(Number), failure); expect(diagnostics.stageFailed).toHaveBeenCalledWith("failure", expect.any(Number), failure);
}); });
test.each([
["pageerror", (page) => page.emit("pageerror", new Error("page exploded"))],
[
"unhandledrejection",
(page) => page.emit("console", consoleError(`${UNHANDLED_REJECTION_PREFIX}promise exploded`)),
],
["console.error", (page) => page.emit("console", consoleError("console exploded"))],
])("turns an otherwise-passing stage red for a %s", async (type, emitSignal) => {
const { diagnostics, page } = createLiveHarness();
await diagnostics.start();
await expect(
runLoggedStage(diagnostics, "read-only.watch", async () => {
emitSignal(page);
return "rendered";
}),
).rejects.toMatchObject({ name: "LiveFatalSignalError" });
expect(diagnostics.browserSignals).toEqual([
expect.objectContaining({ ignoredBy: null, stage: "read-only.watch", type }),
]);
diagnostics.stop();
});
test.each([
[
"googlevideo HTTP 403",
"Failed to load resource: the server responded with a status of 403 ()",
"https://rr2---sn-q4flrnel.googlevideo.com/videoplayback?opaque=1",
"googlevideo-resource-failure",
],
[
"googlevideo DNS failure",
"Failed to load resource: net::ERR_NAME_NOT_RESOLVED",
"https://rr3---sn-oxup5-f5fk.googlevideo.com/generate_204",
"googlevideo-resource-failure",
],
[
"doubleclick follow-on network failure",
"Failed to load resource: net::ERR_FAILED",
"https://googleads.g.doubleclick.net/pagead/viewthroughconversion/123/",
"doubleclick-resource-failure",
],
[
"doubleclick CORS failure",
"Access to XMLHttpRequest at 'https://googleads.g.doubleclick.net/pagead/id' from origin 'https://www.youtube.com' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.",
"https://www.youtube.com/watch?v=abcdefghijk",
"doubleclick-cors",
],
[
"doubleclick CORS failure after a YouTube ad redirect",
"Access to fetch at 'https://googleads.g.doubleclick.net/pagead/viewthroughconversion/962985656/?backend=innertube&cname=1' (redirected from 'https://www.youtube.com/pagead/viewthroughconversion/962985656/?backend=innertube&cname=1') from origin 'https://www.youtube.com' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.",
"https://www.youtube.com/watch?v=abcdefghijk&list=RDabcdefghijk",
"doubleclick-cors",
],
[
"sandboxed about:blank frame script block",
"Blocked script execution in 'about:blank' because the document's frame is sandboxed and the 'allow-scripts' permission is not set.",
"about:blank",
"sandboxed-about-blank-script",
],
[
"YouTube account-cookie rotation rate limit",
"Failed to load resource: the server responded with a status of 429 ()",
"https://accounts.youtube.com/RotateCookies",
"youtube-rotate-cookies-rate-limit",
],
[
"YouTube accounts report-only frame-ancestors violation",
"Framing 'https://accounts.youtube.com/' violates the following report-only Content Security Policy directive: \"frame-ancestors 'self'\". The violation has been logged, but no further action has been taken.\n",
"",
"youtube-accounts-report-only-frame-ancestors",
],
])("records a narrowly recognized external %s as a non-fatal warning", async (_name, message, url, warningId) => {
const { diagnostics, page } = createLiveHarness();
await diagnostics.start();
await expect(
runLoggedStage(diagnostics, "read-only.watch", async () => {
page.emit("console", consoleError(message, url));
return "rendered";
}),
).resolves.toBe("rendered");
expect(diagnostics.browserSignals).toEqual([
expect.objectContaining({
environmentalWarning: warningId,
ignoredBy: null,
severity: "warning",
stage: "read-only.watch",
type: "console.error",
}),
]);
diagnostics.stop();
});
test.each([
[
"unknown status on a media CDN",
"Failed to load resource: the server responded with a status of 404 ()",
"https://rr2---sn-q4flrnel.googlevideo.com/videoplayback",
],
[
"page JavaScript failure reported at a media URL",
"TypeError: Cannot read properties of undefined",
"https://rr2---sn-q4flrnel.googlevideo.com/videoplayback",
],
[
"known network wording on YouTube itself",
"Failed to load resource: net::ERR_NAME_NOT_RESOLVED",
"https://www.youtube.com/youtubei/v1/player",
],
[
"production RYD API failure",
"Failed to load resource: net::ERR_FAILED",
"https://returnyoutubedislikeapi.com/votes?videoId=abcdefghijk",
],
[
"lookalike media hostname",
"Failed to load resource: the server responded with a status of 403 ()",
"https://googlevideo.com.attacker.test/videoplayback",
],
[
"insecure media URL",
"Failed to load resource: net::ERR_NAME_NOT_RESOLVED",
"http://rr2---sn-q4flrnel.googlevideo.com/videoplayback",
],
[
"nonstandard media port",
"Failed to load resource: net::ERR_NAME_NOT_RESOLVED",
"https://rr2---sn-q4flrnel.googlevideo.com:444/videoplayback",
],
[
"different YouTube account endpoint",
"Failed to load resource: the server responded with a status of 429 ()",
"https://accounts.youtube.com/RotateCookiesAgain",
],
[
"different status from YouTube cookie rotation",
"Failed to load resource: the server responded with a status of 500 ()",
"https://accounts.youtube.com/RotateCookies",
],
[
"lookalike YouTube accounts hostname",
"Failed to load resource: the server responded with a status of 429 ()",
"https://accounts.youtube.com.attacker.test/RotateCookies",
],
[
"enforced YouTube accounts frame-ancestors violation",
"Framing 'https://accounts.youtube.com/' violates the following Content Security Policy directive: \"frame-ancestors 'self'\".",
"",
],
[
"report-only YouTube accounts frame-ancestors near miss",
"Framing 'https://accounts.youtube.com/' violates the following report-only Content Security Policy directive: \"frame-ancestors 'self'\". The violation has been logged, but no further action has been taken.",
"",
],
[
"unrelated report-only accounts frame-ancestors violation",
"Framing 'https://accounts.google.com/' violates the following report-only Content Security Policy directive: \"frame-ancestors 'self'\". The violation has been logged, but no further action has been taken.\n",
"",
],
[
"YouTube accounts report-only frame-ancestors violation with a page location",
"Framing 'https://accounts.youtube.com/' violates the following report-only Content Security Policy directive: \"frame-ancestors 'self'\". The violation has been logged, but no further action has been taken.\n",
"https://www.youtube.com/watch?v=abcdefghijk",
],
[
"lookalike CORS target",
"Access to fetch at 'https://doubleclick.net.attacker.test/pixel' from origin 'https://www.youtube.com' has been blocked by CORS policy: blocked",
"https://www.youtube.com/watch?v=abcdefghijk",
],
[
"non-YouTube CORS origin",
"Access to fetch at 'https://googleads.g.doubleclick.net/pixel' from origin 'https://attacker.test' has been blocked by CORS policy: blocked",
"https://www.youtube.com/watch?v=abcdefghijk",
],
[
"doubleclick CORS redirect from a foreign origin",
"Access to fetch at 'https://googleads.g.doubleclick.net/pagead/viewthroughconversion/123/' (redirected from 'https://attacker.test/pagead/viewthroughconversion/123/') from origin 'https://www.youtube.com' has been blocked by CORS policy: blocked",
"https://www.youtube.com/watch?v=abcdefghijk",
],
[
"doubleclick CORS redirect from a different YouTube endpoint",
"Access to fetch at 'https://googleads.g.doubleclick.net/pagead/viewthroughconversion/123/' (redirected from 'https://www.youtube.com/youtubei/v1/player') from origin 'https://www.youtube.com' has been blocked by CORS policy: blocked",
"https://www.youtube.com/watch?v=abcdefghijk",
],
[
"sandboxed-frame message at a page URL",
"Blocked script execution in 'about:blank' because the document's frame is sandboxed and the 'allow-scripts' permission is not set.",
"https://www.youtube.com/watch?v=abcdefghijk",
],
[
"near-miss sandboxed-frame message",
"Blocked script execution in 'about:blank' because the document's frame is sandboxed and the 'allow-scripts' permission is not set. Unexpected",
"about:blank",
],
["unrelated about:blank JavaScript error", "TypeError: Cannot read properties of undefined", "about:blank"],
])("keeps %s fatal", async (_name, message, url) => {
const { diagnostics, page } = createLiveHarness();
await diagnostics.start();
page.emit("console", consoleError(message, url));
expect(() => diagnostics.consumeFatalSignals("unexpected console error")).toThrow(/fatal browser signal/);
expect(diagnostics.browserSignals[0]).toEqual(
expect.objectContaining({ environmentalWarning: null, severity: "fatal", type: "console.error" }),
);
diagnostics.stop();
});
test("classifies only page console errors, never page exceptions or unhandled rejections", () => {
const details = {
location: { url: "https://rr2---sn-q4flrnel.googlevideo.com/videoplayback" },
message: "Failed to load resource: net::ERR_NAME_NOT_RESOLVED",
source: "page",
};
expect(environmentalConsoleWarningId({ ...details, type: "console.error" })).toBe("googlevideo-resource-failure");
expect(environmentalConsoleWarningId({ ...details, type: "pageerror" })).toBeNull();
expect(environmentalConsoleWarningId({ ...details, type: "unhandledrejection" })).toBeNull();
expect(
environmentalConsoleWarningId({ ...details, source: "extension-service-worker", type: "console.error" }),
).toBeNull();
});
test("fails closed if a future signal reaches the guard without an explicit severity", () => {
const { context, page } = createLiveHarness();
const guard = new LiveFatalSignalGuard(page, context, { log: jest.fn() });
guard.signals.push({ message: "unclassified", type: "console.error" });
expect(() => guard.assertNoFatalSignalsBetween(0, 1, "future signal")).toThrow(/console\.error: unclassified/);
});
test("allows only an explicit exact benign-console rule and keeps mismatches fatal", async () => {
const { diagnostics, page } = createLiveHarness({
ignoredSignalRules: [
{
id: "known-youtube-message",
message: "Known YouTube-only diagnostic",
type: "console.error",
url: "https://www.youtube.com/watch?v=abcdefghijk",
},
],
});
await diagnostics.start();
page.emit("console", consoleError("Known YouTube-only diagnostic"));
expect(() => diagnostics.consumeFatalSignals("known noise")).not.toThrow();
expect(diagnostics.browserSignals[0]).toEqual(
expect.objectContaining({ ignoredBy: "known-youtube-message", severity: "warning" }),
);
page.emit("console", consoleError("Known YouTube-only diagnostic with extra text"));
expect(() => diagnostics.consumeFatalSignals("unexpected noise")).toThrow(/fatal browser signal/);
diagnostics.stop();
});
test("rejects broad or callback-based console suppression rules", () => {
const { context, page } = createLiveHarness();
expect(
() =>
new LiveFatalSignalGuard(page, context, {
ignoredSignalRules: [{ id: "message-only", message: "Failed", type: "console.error" }],
}),
).toThrow(/requires a non-empty exact url/);
expect(
() =>
new LiveFatalSignalGuard(page, context, {
ignoredSignalRules: [{ id: "broad", messagePattern: /error/, type: "console.error" }],
}),
).toThrow(/unsupported fields: messagePattern/);
expect(
() =>
new LiveFatalSignalGuard(page, context, {
ignoredSignalRules: [{ id: "callback", message: "ignored", predicate: () => true, type: "console.error" }],
}),
).toThrow(/unsupported fields: predicate/);
});
test("fails only for console errors from the selected extension service worker", async () => {
const extensionId = "a".repeat(32);
const matchingWorker = new FakeEmitter();
matchingWorker.url = jest.fn(() => `chrome-extension://${extensionId}/ryd.background.js`);
matchingWorker.evaluate = jest.fn().mockResolvedValue(undefined);
const unrelatedWorker = new FakeEmitter();
unrelatedWorker.url = jest.fn(() => `chrome-extension://${"b".repeat(32)}/ryd.background.js`);
unrelatedWorker.evaluate = jest.fn().mockResolvedValue(undefined);
const { context, diagnostics } = createLiveHarness({ selectedExtensionId: extensionId });
context.serviceWorkers.mockReturnValue([matchingWorker, unrelatedWorker]);
await diagnostics.start();
unrelatedWorker.emit("console", consoleError("unrelated extension failed", unrelatedWorker.url()));
expect(() => diagnostics.consumeFatalSignals("unrelated worker")).not.toThrow();
matchingWorker.emit("console", consoleError("selected extension failed", matchingWorker.url()));
expect(() => diagnostics.consumeFatalSignals("selected worker")).toThrow(
/extension-service-worker\.console\.error: selected extension failed/,
);
expect(matchingWorker.evaluate).toHaveBeenCalledWith(expect.any(Function), UNHANDLED_REJECTION_PREFIX);
expect(unrelatedWorker.evaluate).not.toHaveBeenCalled();
diagnostics.stop();
});
test("keeps external-resource wording fatal when it comes from the selected extension worker", async () => {
const extensionId = "a".repeat(32);
const matchingWorker = new FakeEmitter();
matchingWorker.url = jest.fn(() => `chrome-extension://${extensionId}/ryd.background.js`);
matchingWorker.evaluate = jest.fn().mockResolvedValue(undefined);
const { context, diagnostics } = createLiveHarness({ selectedExtensionId: extensionId });
context.serviceWorkers.mockReturnValue([matchingWorker]);
await diagnostics.start();
matchingWorker.emit(
"console",
consoleError(
"Failed to load resource: net::ERR_NAME_NOT_RESOLVED",
"https://rr2---sn-q4flrnel.googlevideo.com/videoplayback",
),
);
expect(() => diagnostics.consumeFatalSignals("selected worker")).toThrow(
/extension-service-worker\.console\.error/,
);
expect(diagnostics.browserSignals[0]).toEqual(
expect.objectContaining({ environmentalWarning: null, severity: "fatal" }),
);
diagnostics.stop();
});
test("guards a selected extension service worker that restarts during a scenario", async () => {
const extensionId = "a".repeat(32);
const replacementWorker = new FakeEmitter();
replacementWorker.url = jest.fn(() => `chrome-extension://${extensionId}/ryd.background.js`);
replacementWorker.evaluate = jest.fn().mockResolvedValue(undefined);
const { context, diagnostics } = createLiveHarness({ selectedExtensionId: extensionId });
await diagnostics.start();
context.emit("serviceworker", replacementWorker);
replacementWorker.emit(
"console",
consoleError(`${UNHANDLED_REJECTION_PREFIX}replacement rejected`, replacementWorker.url()),
);
expect(() => diagnostics.consumeFatalSignals("replacement worker")).toThrow(
/extension-service-worker\.unhandledrejection: replacement rejected/,
);
diagnostics.stop();
});
test("continues independent read-only stages, then rejects the aggregate run", async () => {
const diagnostics = {
consumeFatalSignals: jest.fn(),
stageCompleted: jest.fn(),
stageFailed: jest.fn(),
stageStarted: jest.fn(),
};
const laterStage = jest.fn().mockResolvedValue("healthy");
let aggregate;
try {
await runIndependentLoggedStages(diagnostics, [
{
action: async () => {
throw new Error("first failed");
},
name: "first",
},
{ action: laterStage, name: "second" },
]);
} catch (error) {
aggregate = error;
}
expect(aggregate).toBeInstanceOf(AggregateError);
expect(aggregate.message).toBe("1 of 2 independent live read-only stages failed.");
expect(aggregate.stageResults).toEqual([
expect.objectContaining({ error: expect.objectContaining({ message: "first failed" }), name: "first" }),
{ name: "second", result: "healthy" },
]);
expect(laterStage).toHaveBeenCalledTimes(1);
expect(diagnostics.stageCompleted).toHaveBeenCalledWith("second", expect.any(Number));
});
test("blocks production reactions unless this worker completed every read-only scenario", () => {
const gate = new LiveReadOnlyGate(["shorts-render", "watch-render", "responsive-visual"], {
allowedSkippedScenarioIds: ["watch-render"],
});
gate.record("shorts-render", "passed");
gate.record("watch-render", "skipped");
expect(() => gate.assertPassed()).toThrow(/Missing: responsive-visual; failed: none/);
gate.record("responsive-visual", "passed");
expect(() => gate.assertPassed()).not.toThrow();
gate.record("shorts-render", "failed");
gate.record("shorts-render", "passed");
expect(() => gate.assertPassed()).toThrow(/Missing: shorts-render; failed: shorts-render/);
const requiredSkip = new LiveReadOnlyGate(["shorts-render"]);
requiredSkip.record("shorts-render", "skipped");
expect(() => requiredSkip.assertPassed()).toThrow(/failed: shorts-render/);
});
test("a fresh worker cannot inherit read-only gate success from a previous worker", () => {
const firstWorker = new LiveReadOnlyGate(["shorts-render"]);
firstWorker.record("shorts-render", "passed");
expect(() => firstWorker.assertPassed()).not.toThrow();
const restartedWorker = new LiveReadOnlyGate(["shorts-render"]);
expect(() => restartedWorker.assertPassed()).toThrow(/Missing: shorts-render/);
});
test("redacts identity and proof material while retaining diagnostic video IDs", () => { test("redacts identity and proof material while retaining diagnostic video IDs", () => {
expect( expect(
diagnosticApiUrl( diagnosticApiUrl(
@@ -0,0 +1,397 @@
const path = require("node:path");
const {
assertWorkerIdentity,
extensionServiceWorkerUrl,
normalizeExtensionPath,
readUnpackedExtensions,
reloadLiveExtension,
reloadLiveExtensionInBrowser,
selectEnabledUnpackedExtension,
waitForReloadedExtensionEnabled,
waitForExtensionWorker,
} = require("../e2e/live/live-extension-reloader");
const EXTENSION_ID = "abcdefghijklmnopabcdefghijklmnop";
const BUILD_ID = "0123456789abcdef0123456789abcdef";
const EXTENSION_PATH = "C:\\repo\\Extensions\\combined\\dist\\chrome";
function identity(overrides = {}) {
return {
backgroundScript: "ryd.background.js",
compiledBuildId: BUILD_ID,
extensionId: EXTENSION_ID,
resourceBuildId: BUILD_ID,
version: "4.0.5",
...overrides,
};
}
async function flushPromiseJobs() {
for (let index = 0; index < 10; index += 1) await Promise.resolve();
}
describe("live unpacked-extension reload helper", () => {
test("matches the exact unpacked path with Windows separator and case normalization", () => {
const extension = selectEnabledUnpackedExtension(
[
{ enabled: true, id: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", path: "C:\\other", version: "4.0.5" },
{ enabled: true, id: EXTENSION_ID, path: "c:/REPO/Extensions/combined/dist/chrome/", version: "4.0.4" },
],
EXTENSION_PATH,
{ pathImpl: path.win32, platform: "win32" },
);
expect(extension.id).toBe(EXTENSION_ID);
expect(normalizeExtensionPath(extension.path, { pathImpl: path.win32, platform: "win32" })).toBe(
normalizeExtensionPath(EXTENSION_PATH, { pathImpl: path.win32, platform: "win32" }),
);
});
test.each([
[[], /is not installed/],
[[{ enabled: false, id: EXTENSION_ID, path: EXTENSION_PATH }], /installed but disabled/],
[[{ enabled: true, id: "not-an-id", path: EXTENSION_PATH }], /invalid extension ID/],
[
[
{ enabled: true, id: EXTENSION_ID, path: EXTENSION_PATH },
{ enabled: true, id: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", path: EXTENSION_PATH },
],
/more than one/,
],
])("refuses an unsafe unpacked-extension inventory", (extensions, expectedError) => {
expect(() =>
selectEnabledUnpackedExtension(extensions, EXTENSION_PATH, {
pathImpl: path.win32,
platform: "win32",
}),
).toThrow(expectedError);
});
test("can use the default-profile inventory only for exact path-to-ID identification", () => {
expect(
selectEnabledUnpackedExtension(
[{ enabled: false, id: EXTENSION_ID, path: EXTENSION_PATH, version: "4.0.5" }],
EXTENSION_PATH,
{ pathImpl: path.win32, platform: "win32", requireEnabled: false },
),
).toMatchObject({ enabled: false, id: EXTENSION_ID });
});
test("uses an exact extension worker URL", () => {
expect(extensionServiceWorkerUrl(EXTENSION_ID)).toBe(`chrome-extension://${EXTENSION_ID}/ryd.background.js`);
expect(() => extensionServiceWorkerUrl("not-an-id")).toThrow(/valid Chrome extension ID/);
});
test("starts a dormant worker through the ServiceWorker domain", async () => {
const worker = { url: () => extensionServiceWorkerUrl(EXTENSION_ID) };
let workers = [];
const context = { serviceWorkers: jest.fn(() => workers) };
const serviceWorkerSession = {
send: jest.fn(async (method) => {
if (method === "ServiceWorker.startWorker") workers = [worker];
}),
};
await expect(
waitForExtensionWorker(context, serviceWorkerSession, EXTENSION_ID, {
delayImpl: async () => {},
now: (() => {
let value = 0;
return () => value++;
})(),
timeoutMilliseconds: 20,
}),
).resolves.toBe(worker);
expect(serviceWorkerSession.send).toHaveBeenNthCalledWith(1, "ServiceWorker.enable");
expect(serviceWorkerSession.send).toHaveBeenNthCalledWith(2, "ServiceWorker.startWorker", {
scopeURL: `chrome-extension://${EXTENSION_ID}/`,
});
});
test("fails closed when Chrome cannot inventory unpacked extensions", async () => {
const browserSession = { send: jest.fn().mockRejectedValue(new Error("Method not available")) };
await expect(readUnpackedExtensions(browserSession)).rejects.toThrow(
/cannot be identified safely.*no extension was changed/i,
);
});
test("polls through Chrome's transient post-reload disabled inventory state", async () => {
jest.useFakeTimers({ doNotFake: ["performance"] });
try {
const browserSession = {
send: jest
.fn()
.mockResolvedValueOnce({
extensions: [{ enabled: false, id: EXTENSION_ID, path: EXTENSION_PATH, version: "4.0.5" }],
})
.mockResolvedValueOnce({
extensions: [{ enabled: true, id: EXTENSION_ID, path: EXTENSION_PATH, version: "4.0.5" }],
}),
};
const extensionPromise = waitForReloadedExtensionEnabled(browserSession, EXTENSION_PATH, EXTENSION_ID, {
pathOptions: { pathImpl: path.win32, platform: "win32" },
timeoutMilliseconds: 100,
});
await flushPromiseJobs();
expect(jest.getTimerCount()).toBe(1);
jest.advanceTimersByTime(50);
await flushPromiseJobs();
await expect(extensionPromise).resolves.toMatchObject({ enabled: true, id: EXTENSION_ID });
expect(browserSession.send).toHaveBeenCalledTimes(2);
} finally {
jest.useRealTimers();
}
});
test("times out when Chrome's post-reload inventory remains disabled", async () => {
jest.useFakeTimers({ doNotFake: ["performance"] });
try {
const browserSession = {
send: jest.fn().mockResolvedValue({
extensions: [{ enabled: false, id: EXTENSION_ID, path: EXTENSION_PATH, version: "4.0.5" }],
}),
};
const extensionPromise = waitForReloadedExtensionEnabled(browserSession, EXTENSION_PATH, EXTENSION_ID, {
pathOptions: { pathImpl: path.win32, platform: "win32" },
timeoutMilliseconds: 100,
});
const rejection = expect(extensionPromise).rejects.toThrow(/remained disabled for 100ms after reload/);
await flushPromiseJobs();
expect(jest.getTimerCount()).toBe(1);
jest.advanceTimersByTime(50);
await flushPromiseJobs();
expect(jest.getTimerCount()).toBe(1);
jest.advanceTimersByTime(50);
await flushPromiseJobs();
await rejection;
expect(browserSession.send).toHaveBeenCalledTimes(3);
} finally {
jest.useRealTimers();
}
});
test("rejects a different extension ID immediately while polling the exact unpacked path", async () => {
const differentExtensionId = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
const browserSession = {
send: jest.fn().mockResolvedValue({
extensions: [{ enabled: false, id: differentExtensionId, path: EXTENSION_PATH, version: "4.0.5" }],
}),
};
await expect(
waitForReloadedExtensionEnabled(browserSession, EXTENSION_PATH, EXTENSION_ID, {
pathOptions: { pathImpl: path.win32, platform: "win32" },
timeoutMilliseconds: 100,
}),
).rejects.toThrow(`changed from ${EXTENSION_ID} to ${differentExtensionId}`);
expect(browserSession.send).toHaveBeenCalledTimes(1);
});
test("requires both the compiled and resource build IDs after reload", () => {
expect(() =>
assertWorkerIdentity(identity({ compiledBuildId: "f".repeat(32) }), EXTENSION_ID, {
expectedBuildId: BUILD_ID,
expectedVersion: "4.0.5",
}),
).toThrow(/is running build/);
expect(() =>
assertWorkerIdentity(identity({ resourceBuildId: "f".repeat(32) }), EXTENSION_ID, {
expectedBuildId: BUILD_ID,
expectedVersion: "4.0.5",
}),
).toThrow(/exposes resource build/);
});
test("reloads only the exact enabled unpacked extension and proves the replacement worker build", async () => {
let workers;
let inventoryVersion = "4.0.4";
let closeOldWorker;
const oldWorkerClosed = new Promise((resolve) => {
closeOldWorker = resolve;
});
const oldWorker = {
evaluate: jest
.fn()
.mockResolvedValueOnce(
identity({ compiledBuildId: "a".repeat(32), resourceBuildId: BUILD_ID, version: "4.0.4" }),
)
.mockImplementationOnce(async () => {
workers = [];
inventoryVersion = "4.0.5";
closeOldWorker();
return true;
}),
url: () => extensionServiceWorkerUrl(EXTENSION_ID),
waitForEvent: jest.fn(() => oldWorkerClosed),
};
const newWorker = {
evaluate: jest.fn().mockResolvedValue(identity()),
url: () => extensionServiceWorkerUrl(EXTENSION_ID),
};
workers = [oldWorker];
const browserSession = {
send: jest.fn(async (method) => {
expect(method).toBe("Extensions.getExtensions");
return {
extensions: [{ enabled: true, id: EXTENSION_ID, path: EXTENSION_PATH, version: inventoryVersion }],
};
}),
};
const context = { serviceWorkers: jest.fn(() => workers) };
const serviceWorkerSession = {
send: jest.fn(async (method) => {
if (method === "ServiceWorker.startWorker") workers = [newWorker];
}),
};
await expect(
reloadLiveExtension({
browserSession,
context,
expectedBuildId: BUILD_ID,
expectedExtensionPath: EXTENSION_PATH,
expectedVersion: "4.0.5",
pathOptions: { pathImpl: path.win32, platform: "win32" },
serviceWorkerSession,
timeoutMilliseconds: 100,
waitOptions: { delayImpl: async () => {}, now: Date.now },
}),
).resolves.toEqual({
buildId: BUILD_ID,
extensionId: EXTENSION_ID,
previousVersion: "4.0.4",
version: "4.0.5",
});
expect(browserSession.send).toHaveBeenCalledTimes(2);
expect(oldWorker.waitForEvent).toHaveBeenCalledWith("close", { timeout: 100 });
expect(oldWorker.evaluate).toHaveBeenCalledTimes(2);
expect(newWorker.evaluate).toHaveBeenCalledTimes(1);
});
test("refuses to start or reload the unpacked extension when Chrome reports it disabled", async () => {
const browserSession = {
send: jest.fn().mockResolvedValue({
extensions: [{ enabled: false, id: EXTENSION_ID, path: EXTENSION_PATH, version: "4.0.5" }],
}),
};
const context = { serviceWorkers: jest.fn() };
const serviceWorkerSession = { send: jest.fn() };
await expect(
reloadLiveExtension({
browserSession,
context,
expectedBuildId: BUILD_ID,
expectedExtensionPath: EXTENSION_PATH,
expectedVersion: "4.0.5",
pathOptions: { pathImpl: path.win32, platform: "win32" },
serviceWorkerSession,
}),
).rejects.toThrow(/installed but disabled/);
expect(context.serviceWorkers).not.toHaveBeenCalled();
expect(serviceWorkerSession.send).not.toHaveBeenCalled();
});
test("does not request a worker reload when the expected unpacked path is absent", async () => {
const browserSession = {
send: jest.fn().mockResolvedValue({
extensions: [{ enabled: true, id: EXTENSION_ID, path: "C:\\somewhere-else", version: "4.0.5" }],
}),
};
const context = { serviceWorkers: jest.fn() };
const serviceWorkerSession = { send: jest.fn() };
await expect(
reloadLiveExtension({
browserSession,
context,
expectedBuildId: BUILD_ID,
expectedExtensionPath: EXTENSION_PATH,
expectedVersion: "4.0.5",
pathOptions: { pathImpl: path.win32, platform: "win32" },
serviceWorkerSession,
}),
).rejects.toThrow(/is not installed/);
expect(context.serviceWorkers).not.toHaveBeenCalled();
expect(serviceWorkerSession.send).not.toHaveBeenCalled();
});
test("uses only the caller-selected YouTube page for the target-profile ServiceWorker session", async () => {
const existingInternalPage = { url: () => "chrome://extensions/" };
const selectedPage = {
context: jest.fn(),
isClosed: jest.fn().mockReturnValue(false),
url: jest.fn().mockReturnValue("https://www.youtube.com/watch?v=AAAAAAAAAAA"),
};
const browserSession = { detach: jest.fn().mockResolvedValue(undefined) };
const serviceWorkerSession = { detach: jest.fn().mockResolvedValue(undefined) };
const browser = { newBrowserCDPSession: jest.fn().mockResolvedValue(browserSession) };
const context = {
newCDPSession: jest.fn().mockResolvedValue(serviceWorkerSession),
newPage: jest.fn(),
pages: jest.fn().mockReturnValue([existingInternalPage]),
};
selectedPage.context.mockReturnValue(context);
const reloadResult = { extensionId: EXTENSION_ID };
const reloadImpl = jest.fn().mockResolvedValue(reloadResult);
await expect(
reloadLiveExtensionInBrowser({
browser,
context,
expectedBuildId: BUILD_ID,
expectedExtensionPath: EXTENSION_PATH,
expectedVersion: "4.0.5",
reloadImpl,
sessionPage: selectedPage,
timeoutMilliseconds: 12345,
}),
).resolves.toBe(reloadResult);
expect(context.pages).not.toHaveBeenCalled();
expect(context.newPage).not.toHaveBeenCalled();
expect(context.newCDPSession).toHaveBeenCalledWith(selectedPage);
expect(reloadImpl).toHaveBeenCalledWith({
browserSession,
context,
expectedBuildId: BUILD_ID,
expectedExtensionPath: EXTENSION_PATH,
expectedVersion: "4.0.5",
serviceWorkerSession,
timeoutMilliseconds: 12345,
});
expect(serviceWorkerSession.detach).toHaveBeenCalledTimes(1);
expect(browserSession.detach).toHaveBeenCalledTimes(1);
});
test("rejects a session page from a different profile before opening a browser CDP session", async () => {
const otherContext = {};
const sessionPage = {
context: () => otherContext,
isClosed: () => false,
url: () => "https://www.youtube.com/",
};
const browser = { newBrowserCDPSession: jest.fn() };
const context = { newCDPSession: jest.fn(), newPage: jest.fn() };
await expect(
reloadLiveExtensionInBrowser({
browser,
context,
expectedBuildId: BUILD_ID,
expectedExtensionPath: EXTENSION_PATH,
expectedVersion: "4.0.5",
sessionPage,
}),
).rejects.toThrow(/does not belong to the attached Chrome context/);
expect(browser.newBrowserCDPSession).not.toHaveBeenCalled();
expect(context.newCDPSession).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,137 @@
/**
* @jest-environment node
*/
const fs = require("node:fs");
const path = require("node:path");
const packageManifest = require("../../../package.json");
const {
LiveValidationIncompleteError,
REQUIRED_PRODUCTION_REACTION_SCENARIOS,
assertFullLiveReactionCompletion,
buildLiveRuntimeArtifact,
readFullLiveRuntime,
requireProductionReactionApproval,
requireUserscriptInstallAcknowledgement,
} = require("../e2e/live/live-full-run-contract");
test.each(["extension", "userscript"])("selects and builds the exact %s live runtime before a full run", (runtime) => {
const spawnSyncImpl = jest.fn(() => ({ status: 0 }));
const environment = { npm_execpath: "C:\\node\\node_modules\\npm\\bin\\npm-cli.js", RYD_LIVE_RUNTIME: runtime };
expect(readFullLiveRuntime(environment)).toBe(runtime);
expect(
buildLiveRuntimeArtifact(runtime, {
environment,
nodeExecutable: "C:\\node\\node.exe",
platform: "win32",
repositoryRoot: "repository",
spawnSyncImpl,
}),
).toEqual({ runtime, script: `build:live:${runtime}` });
expect(spawnSyncImpl).toHaveBeenCalledWith(
"C:\\node\\node.exe",
["C:\\node\\node_modules\\npm\\bin\\npm-cli.js", "run", `build:live:${runtime}`],
{
cwd: "repository",
env: environment,
stdio: "inherit",
},
);
});
test("uses a Windows command shell only when the npm CLI path is unavailable", () => {
const spawnSyncImpl = jest.fn(() => ({ status: 0 }));
const environment = { RYD_LIVE_RUNTIME: "extension" };
buildLiveRuntimeArtifact("extension", {
environment,
platform: "win32",
repositoryRoot: "repository",
spawnSyncImpl,
});
expect(spawnSyncImpl).toHaveBeenCalledWith("npm.cmd", ["run", "build:live:extension"], {
cwd: "repository",
env: environment,
shell: true,
stdio: "inherit",
});
});
test("a failed live build prevents the full runner from continuing", () => {
expect(() =>
buildLiveRuntimeArtifact("extension", {
spawnSyncImpl: () => ({ status: 2 }),
}),
).toThrow("build:live:extension failed with exit code 2");
});
test.each([undefined, "", "browser"])("rejects unsupported full-live runtime %p", (runtime) => {
expect(() => readFullLiveRuntime({ RYD_LIVE_RUNTIME: runtime })).toThrow(/RYD_LIVE_RUNTIME/);
});
test("classifies only both completed production reaction scenarios as a full run", () => {
expect(REQUIRED_PRODUCTION_REACTION_SCENARIOS).toEqual(["post-navigation-vote", "reaction-matrix"]);
expect(
assertFullLiveReactionCompletion({
"post-navigation-vote": { videoId: "abcdefghijk" },
"reaction-matrix": [{ action: "like" }],
}),
).toEqual({
classification: "full",
completedScenarioIds: REQUIRED_PRODUCTION_REACTION_SCENARIOS,
productionReactionsCompleted: true,
releaseReady: true,
});
});
test.each([
[null, /no production reaction results/],
[{}, /post-navigation-vote, reaction-matrix/],
[{ "post-navigation-vote": {} }, /reaction-matrix/],
[{ "reaction-matrix": [] }, /post-navigation-vote/],
[{ "post-navigation-vote": null, "reaction-matrix": {} }, /post-navigation-vote/],
])("rejects incomplete full-live reaction results %#", (results, message) => {
expect(() => assertFullLiveReactionCompletion(results)).toThrow(message);
});
test("SKIP is an explicit incomplete failure, not a successful full run", () => {
expect(() => requireProductionReactionApproval("SKIP")).toThrow(LiveValidationIncompleteError);
expect(() => requireProductionReactionApproval(" skip ")).toThrow(/production reaction scenarios were skipped/);
expect(requireProductionReactionApproval("extension:abcdefghijk:123")).toBe("extension:abcdefghijk:123");
});
test("a freshly built userscript requires an explicit installation acknowledgement", () => {
expect(requireUserscriptInstallAcknowledgement("INSTALLED")).toBe(true);
expect(() => requireUserscriptInstallAcknowledgement("SKIP")).toThrow(LiveValidationIncompleteError);
expect(() => requireUserscriptInstallAcknowledgement("")).toThrow(/not acknowledged as installed/);
});
test("package commands distinguish full reaction validation from the read-only Playwright suite", () => {
expect(packageManifest.scripts["test:live:youtube"]).toBe(
"node Extensions/UserScript/e2e/live/live-interactive-runner.js",
);
expect(packageManifest.scripts["test:live:youtube:full"]).toBe("npm run test:live:youtube");
expect(packageManifest.scripts["test:live:youtube:interactive"]).toBe("npm run test:live:youtube");
expect(packageManifest.scripts["test:live:youtube:read-only"]).toBe(
"playwright test --config playwright.live-youtube.config.js",
);
});
test("the read-only Playwright suite cannot silently register a skipped reaction test", () => {
const source = fs.readFileSync(path.resolve(__dirname, "../e2e/live/live-youtube.live.e2e.js"), "utf8");
expect(source).toContain('test.describe("live YouTube RYD read-only smoke"');
expect(source).not.toMatch(/\btest\.skip\s*\(/);
expect(source).not.toContain("reaction-matrix");
expect(source).not.toContain("post-navigation-vote");
expect(source).toContain("LIVE_VALIDATION_READ_ONLY_COMPLETE");
expect(source).toContain("releaseReady: false");
});
test("standalone artifact runners verify current source before consuming existing output", () => {
expect(packageManifest.scripts["test:e2e:userscript:run"]).toMatch(/^npm run check:userscript-artifact && /);
expect(packageManifest.scripts["test:e2e:artifacts"]).toMatch(
/^npm run check:extension-artifact && npm run check:userscript-artifact && /,
);
});
@@ -0,0 +1,212 @@
const {
INTERACTIVE_READ_ONLY_SCENARIOS,
createInteractiveReadOnlyStages,
runInteractiveReadOnlyAfterRuntimeReload,
} = require("../e2e/live/live-interactive-readonly");
const {
REQUIRED_INTERACTIVE_READ_ONLY_SCENARIO_IDS,
assertInteractiveReadOnlyScenarioCompletions,
assertInteractiveReadOnlyScenarioDefinitions,
} = require("../e2e/live/live-interactive-scenario-contract");
const OPTIONS = Object.freeze({
navigation: Object.freeze({ watch: "watch-target" }),
runtime: "extension",
short: "9LjMX9xeeok",
});
describe("interactive live read-only ordering", () => {
test("declares the exact independent required scenario set with the cold Short first", () => {
expect(REQUIRED_INTERACTIVE_READ_ONLY_SCENARIO_IDS).toEqual([
"shorts-render",
"channel-shorts-navigation",
"channel-watch-navigation",
"reload",
"responsive-visual",
"spa-navigation",
"watch-action-topology",
"sidebar-navigation-stress",
"watch-render",
]);
expect(new Set(REQUIRED_INTERACTIVE_READ_ONLY_SCENARIO_IDS)).toHaveProperty(
"size",
REQUIRED_INTERACTIVE_READ_ONLY_SCENARIO_IDS.length,
);
expect(REQUIRED_INTERACTIVE_READ_ONLY_SCENARIO_IDS[0]).toBe("shorts-render");
});
test("keeps the implementation aligned with the independent required order", () => {
const stages = createInteractiveReadOnlyStages({
diagnostics: { checkpoint: jest.fn() },
options: OPTIONS,
readOnly: {},
runtimeAdapter: {},
scenarioRunner: { run: jest.fn() },
});
expect(stages.map(({ scenarioId }) => scenarioId)).toEqual(REQUIRED_INTERACTIVE_READ_ONLY_SCENARIO_IDS);
expect(() => assertInteractiveReadOnlyScenarioDefinitions(INTERACTIVE_READ_ONLY_SCENARIOS)).not.toThrow();
expect(stages[0]).toMatchObject({ name: "read-only.short-render", scenarioId: "shorts-render" });
expect(stages.findIndex(({ scenarioId }) => scenarioId === "shorts-render")).toBeLessThan(
stages.findIndex(({ scenarioId }) => scenarioId === "channel-shorts-navigation"),
);
expect(stages.findIndex(({ scenarioId }) => scenarioId === "shorts-render")).toBeLessThan(
stages.findIndex(({ scenarioId }) => scenarioId === "channel-watch-navigation"),
);
});
test.each(REQUIRED_INTERACTIVE_READ_ONLY_SCENARIO_IDS)(
"fails the independent contract when %s is deleted from the implementation",
(deletedScenarioId) => {
const incompleteDefinitions = INTERACTIVE_READ_ONLY_SCENARIOS.filter(
({ scenarioId }) => scenarioId !== deletedScenarioId,
);
expect(() => assertInteractiveReadOnlyScenarioDefinitions(incompleteDefinitions)).toThrow(
new RegExp(`Missing: .*${deletedScenarioId}`),
);
},
);
test("rejects duplicate, unexpected, and reordered implementation scenarios", () => {
const duplicate = [
...INTERACTIVE_READ_ONLY_SCENARIOS,
{ key: "duplicate", name: "read-only.duplicate", scenarioId: "shorts-render" },
];
const unexpected = [
...INTERACTIVE_READ_ONLY_SCENARIOS.slice(0, -1),
{ key: "unexpected", name: "read-only.unexpected", scenarioId: "unexpected" },
];
const reordered = [...INTERACTIVE_READ_ONLY_SCENARIOS];
[reordered[0], reordered[1]] = [reordered[1], reordered[0]];
expect(() => assertInteractiveReadOnlyScenarioDefinitions(duplicate)).toThrow(/duplicate scenario IDs/);
expect(() => assertInteractiveReadOnlyScenarioDefinitions(unexpected)).toThrow(
/Missing: watch-render; unexpected: unexpected/,
);
expect(() => assertInteractiveReadOnlyScenarioDefinitions(reordered)).toThrow(
/required interactive read-only order/,
);
});
test("waits for the exact extension reload, then runs the configured Short cold before other scenarios", async () => {
const events = [];
let finishReload;
const reloadPending = new Promise((resolve) => {
finishReload = resolve;
});
const scenarioRunner = {
run: jest.fn(async (_adapter, scenarioId, receivedOptions) => {
events.push(`scenario:${scenarioId}:${receivedOptions.short}`);
return scenarioId;
}),
};
const execution = runInteractiveReadOnlyAfterRuntimeReload({
initializeRuntime: async (reloadResult) => {
events.push(`initialize:${reloadResult.extensionId}`);
return { diagnostics: {}, runtimeAdapter: { runtime: "extension" } };
},
options: OPTIONS,
reloadExtension: async () => {
events.push("reload:start");
const result = await reloadPending;
events.push(`reload:complete:${result.extensionId}`);
return result;
},
runStages: async (_diagnostics, stages) => {
for (const stage of stages) await stage.action();
},
scenarioRunner,
});
await Promise.resolve();
expect(events).toEqual(["reload:start"]);
finishReload({ extensionId: "installed-extension" });
await expect(execution).resolves.toMatchObject({
readOnly: { short: "shorts-render" },
reloadResult: { extensionId: "installed-extension" },
});
expect(events.slice(0, 4)).toEqual([
"reload:start",
"reload:complete:installed-extension",
"initialize:installed-extension",
`scenario:shorts-render:${OPTIONS.short}`,
]);
expect(scenarioRunner.run).toHaveBeenNthCalledWith(1, { runtime: "extension" }, "shorts-render", OPTIONS);
});
test("refuses to classify an unconfigured channel-to-Watch scenario as completed", () => {
expect(() =>
createInteractiveReadOnlyStages({
diagnostics: { checkpoint: jest.fn() },
options: { ...OPTIONS, navigation: { watch: null } },
readOnly: {},
runtimeAdapter: {},
scenarioRunner: { run: jest.fn() },
}),
).toThrow(/requires a configured channel Watch target/);
});
test("refuses reaction readiness when the stage executor silently omits any scenario", async () => {
const scenarioRunner = { run: jest.fn(async (_adapter, scenarioId) => scenarioId) };
await expect(
runInteractiveReadOnlyAfterRuntimeReload({
initializeRuntime: async () => ({ diagnostics: {}, runtimeAdapter: {} }),
options: OPTIONS,
reloadExtension: async () => ({ extensionId: "installed-extension" }),
runStages: async (_diagnostics, stages) => {
for (const stage of stages.slice(0, -1)) await stage.action();
},
scenarioRunner,
}),
).rejects.toThrow(/Missing: watch-render/);
expect(scenarioRunner.run).toHaveBeenCalledTimes(REQUIRED_INTERACTIVE_READ_ONLY_SCENARIO_IDS.length - 1);
});
test("refuses reaction readiness when a scenario resolves without a result", async () => {
await expect(
runInteractiveReadOnlyAfterRuntimeReload({
initializeRuntime: async () => ({ diagnostics: {}, runtimeAdapter: {} }),
options: OPTIONS,
reloadExtension: async () => ({ extensionId: "installed-extension" }),
runStages: async (_diagnostics, stages) => {
await stages[0].action();
},
scenarioRunner: { run: jest.fn().mockResolvedValue(undefined) },
}),
).rejects.toThrow(/shorts-render completed without a result/);
});
test("refuses reaction readiness when stages complete out of order or more than once", async () => {
const execute = (runStages) =>
runInteractiveReadOnlyAfterRuntimeReload({
initializeRuntime: async () => ({ diagnostics: {}, runtimeAdapter: {} }),
options: OPTIONS,
reloadExtension: async () => ({ extensionId: "installed-extension" }),
runStages,
scenarioRunner: { run: async (_adapter, scenarioId) => scenarioId },
});
await expect(
execute(async (_diagnostics, stages) => {
for (const stage of [...stages].reverse()) await stage.action();
}),
).rejects.toThrow(/required interactive read-only order/);
await expect(
execute(async (_diagnostics, stages) => {
for (const stage of stages) await stage.action();
await stages[0].action();
}),
).rejects.toThrow(/duplicate scenario IDs/);
});
test("completion validation independently rejects an empty or incomplete result set", () => {
expect(() => assertInteractiveReadOnlyScenarioCompletions([])).toThrow(/Missing:/);
expect(() =>
assertInteractiveReadOnlyScenarioCompletions(REQUIRED_INTERACTIVE_READ_ONLY_SCENARIO_IDS.slice(0, -1)),
).toThrow(/Missing: watch-render/);
});
});
@@ -0,0 +1,452 @@
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { PassThrough } = require("node:stream");
const {
assertReusableConnectionOptions,
closeLiveAttemptPage,
createInputLineReader,
createInteractiveLiveDriver,
createInteractiveVoteRecorderFactory,
createReactionTargetDetails,
formatReactionApprovalReadiness,
main,
normalizeLiveSessionCommand,
preserveLiveAttemptEvidence,
readPersistentSessionMode,
resetLiveAttemptEvidenceWorkspace,
resolveSafeLiveEvidenceDirectory,
} = require("../e2e/live/live-interactive-runner");
const SELECTED_EXTENSION_ID = "abcdefghijklmnopabcdefghijklmnop";
const EXPECTED_BUILD_ID = "0123456789abcdef0123456789abcdef";
function createOutput() {
return { error: jest.fn(), log: jest.fn() };
}
function createLiveOptions(overrides = {}) {
return {
cdpConnectTimeoutMilliseconds: 120_000,
cdpEndpoint: "ws://127.0.0.1:60011/devtools/browser/session",
expectedBuildId: EXPECTED_BUILD_ID,
expectedChannel: "@anarios-ryd",
runtime: "extension",
...overrides,
};
}
function createContext() {
return {
off: jest.fn(),
on: jest.fn(),
pages: jest.fn(() => []),
};
}
function createPage() {
return {
setDefaultNavigationTimeout: jest.fn(),
setDefaultTimeout: jest.fn(),
};
}
describe("interactive live runtime binding", () => {
test("reports the dedicated reaction targets without conflating the read-only Short", () => {
const options = {
reactionShort: "reactshort1",
runtime: "extension",
short: "readonly001",
watchB: "reaction001",
};
expect(createReactionTargetDetails(options)).toEqual({
reactionShort: "reactshort1",
reactionWatch: "reaction001",
});
expect(formatReactionApprovalReadiness(options)).toBe(
"READY_FOR_REACTION_APPROVAL runtime=extension watch=reaction001 reactionShort=reactshort1 readOnlyShort=readonly001",
);
});
test("passes the selected account and exact extension ID into the live driver", () => {
const context = createContext();
const diagnostics = { checkpoint: jest.fn() };
const page = createPage();
const driver = createInteractiveLiveDriver({
context,
diagnostics,
options: { expectedBuildId: EXPECTED_BUILD_ID, expectedChannel: "@Expected.Handle", runtime: "extension" },
page,
selectedExtensionId: SELECTED_EXTENSION_ID,
});
expect(driver.authenticatedHandle).toBe("@expected.handle");
expect(driver.expectedBuildId).toBe(EXPECTED_BUILD_ID);
expect(driver.selectedExtensionId).toBe(SELECTED_EXTENSION_ID);
expect(() => driver.configureRequestAttributionRuntime("extension")).not.toThrow();
});
test("passes the same exact extension ID and selected page into every vote recorder", () => {
const context = createContext();
const page = createPage();
const createRecorder = createInteractiveVoteRecorderFactory({
context,
driver: { trafficLedger: null },
options: { runtime: "extension" },
page,
selectedExtensionId: SELECTED_EXTENSION_ID,
});
const recorder = createRecorder("abcdefghijk");
expect(recorder.page).toBe(page);
expect(recorder.runtime).toBe("extension");
expect(recorder.selectedExtensionId).toBe(SELECTED_EXTENSION_ID);
expect(recorder.videoId).toBe("abcdefghijk");
recorder.stop();
});
test("cannot create an extension vote recorder without the selected extension ID", () => {
const createRecorder = createInteractiveVoteRecorderFactory({
context: createContext(),
driver: { trafficLedger: null },
options: { runtime: "extension" },
page: createPage(),
selectedExtensionId: null,
});
expect(() => createRecorder("abcdefghijk")).toThrow(/requires the exact selected extension ID/);
});
test("keeps userscript recorders page-bound without inventing an extension identity", () => {
const context = createContext();
const page = createPage();
const createRecorder = createInteractiveVoteRecorderFactory({
context,
driver: { trafficLedger: null },
options: { runtime: "userscript" },
page,
selectedExtensionId: null,
});
const recorder = createRecorder("abcdefghijk");
expect(recorder.page).toBe(page);
expect(recorder.runtime).toBe("userscript");
expect(recorder.selectedExtensionId).toBeNull();
recorder.stop();
});
});
describe("interactive live attempt cleanup", () => {
test("closes a live-attempt page within the configured bound", async () => {
const page = {
close: jest.fn().mockResolvedValue(undefined),
isClosed: jest.fn(() => false),
};
await expect(closeLiveAttemptPage(page, { timeoutMs: 50 })).resolves.toBe(true);
expect(page.close).toHaveBeenCalledTimes(1);
});
test("does not try to close an already-closed live-attempt page", async () => {
const page = {
close: jest.fn(),
isClosed: jest.fn(() => true),
};
await expect(closeLiveAttemptPage(page, { timeoutMs: 50 })).resolves.toBe(false);
expect(page.close).not.toHaveBeenCalled();
});
test("rejects within the configured bound when page close never settles", async () => {
const page = {
close: jest.fn(() => new Promise(() => {})),
isClosed: jest.fn(() => false),
};
await expect(closeLiveAttemptPage(page, { timeoutMs: 10 })).rejects.toThrow(
"Timed out after 10ms while closing the live-attempt test page.",
);
});
});
describe("persistent interactive live session", () => {
test("buffers multiple control lines on one stdin reader for the whole connected process", async () => {
const input = new PassThrough();
const reader = createInputLineReader(input);
input.write("first\nRERUN\n");
await expect(reader.readLine()).resolves.toBe("first");
await expect(reader.readLine()).resolves.toBe("RERUN");
input.end();
await expect(reader.readLine()).resolves.toBeNull();
reader.close();
});
test.each([
[{ RYD_LIVE_RUNTIME: "extension" }, false],
[{ RYD_LIVE_KEEP_CDP_SESSION: "", RYD_LIVE_RUNTIME: "extension" }, false],
[{ RYD_LIVE_KEEP_CDP_SESSION: "1", RYD_LIVE_RUNTIME: "extension" }, true],
])("reads the opt-in without changing default behavior", (environment, expected) => {
expect(readPersistentSessionMode(environment)).toBe(expected);
});
test.each(["true", "yes", "0", "2"])("rejects invalid persistent-session value %s", (configured) => {
expect(() => readPersistentSessionMode({ RYD_LIVE_KEEP_CDP_SESSION: configured })).toThrow(/must be 1 or unset/);
});
test.each([
["RERUN", "RERUN"],
[" rerun ", "RERUN"],
["exit", "EXIT"],
[null, "EXIT"],
["", null],
["again", null],
])("normalizes persistent-session command %p", (value, expected) => {
expect(normalizeLiveSessionCommand(value)).toBe(expected);
});
test("keeps one browser connection across a failed attempt and a complete rerun", async () => {
const firstOptions = createLiveOptions();
const secondOptions = createLiveOptions({ expectedBuildId: "fedcba9876543210fedcba9876543210" });
const browser = { close: jest.fn().mockResolvedValue(undefined) };
const selectedSession = { context: {}, contextIndex: 0, pageIndex: 1, sessionPage: {} };
const buildArtifact = jest
.fn()
.mockReturnValueOnce({ runtime: "extension", script: "build:live:extension", attempt: 1 })
.mockReturnValueOnce({ runtime: "extension", script: "build:live:extension", attempt: 2 });
const connectBrowser = jest.fn().mockResolvedValue(browser);
const executeAttempt = jest
.fn()
.mockRejectedValueOnce(new Error("first live attempt failed"))
.mockResolvedValueOnce({ classification: "full", attempt: 2 });
const output = createOutput();
const preserveEvidence = jest
.fn()
.mockResolvedValueOnce("attempt-01-failed")
.mockResolvedValueOnce("attempt-02-passed");
const resetEvidenceWorkspace = jest
.fn()
.mockResolvedValueOnce({ removedEntries: ["stale-first"] })
.mockResolvedValueOnce({ removedEntries: ["stale-second"] });
const readLine = jest.fn().mockResolvedValueOnce("RERUN").mockResolvedValueOnce("EXIT");
const readOptions = jest.fn().mockReturnValueOnce(firstOptions).mockReturnValueOnce(secondOptions);
const selectContext = jest.fn().mockResolvedValue(selectedSession);
await expect(
main({
buildArtifact,
connectBrowser,
environment: { RYD_LIVE_KEEP_CDP_SESSION: "1", RYD_LIVE_RUNTIME: "extension" },
executeAttempt,
output,
preserveEvidence,
readLine,
readOptions,
resetEvidenceWorkspace,
selectContext,
}),
).resolves.toEqual({ classification: "full", attempt: 2 });
expect(buildArtifact).toHaveBeenCalledTimes(2);
expect(readOptions).toHaveBeenCalledTimes(2);
expect(connectBrowser).toHaveBeenCalledTimes(1);
expect(selectContext).toHaveBeenCalledTimes(1);
expect(executeAttempt).toHaveBeenCalledTimes(2);
expect(resetEvidenceWorkspace).toHaveBeenNthCalledWith(1, { attemptNumber: 1 });
expect(resetEvidenceWorkspace).toHaveBeenNthCalledWith(2, { attemptNumber: 2 });
expect(resetEvidenceWorkspace.mock.invocationCallOrder[0]).toBeLessThan(executeAttempt.mock.invocationCallOrder[0]);
expect(preserveEvidence.mock.invocationCallOrder[0]).toBeLessThan(
resetEvidenceWorkspace.mock.invocationCallOrder[1],
);
expect(resetEvidenceWorkspace.mock.invocationCallOrder[1]).toBeLessThan(executeAttempt.mock.invocationCallOrder[1]);
expect(executeAttempt.mock.calls[0][0]).toMatchObject({ browser, options: firstOptions, selectedSession });
expect(executeAttempt.mock.calls[1][0]).toMatchObject({ browser, options: secondOptions, selectedSession });
expect(preserveEvidence).toHaveBeenNthCalledWith(1, { attemptNumber: 1, outcome: "failed" });
expect(preserveEvidence).toHaveBeenNthCalledWith(2, { attemptNumber: 2, outcome: "passed" });
expect(output.log).toHaveBeenCalledWith("READY_FOR_LIVE_RERUN_OR_EXIT attempt=1 outcome=failed");
expect(output.log).toHaveBeenCalledWith("READY_FOR_LIVE_RERUN_OR_EXIT attempt=2 outcome=passed");
expect(browser.close).toHaveBeenCalledTimes(1);
});
test("retains normal one-attempt exit and failure semantics when persistence is unset", async () => {
const options = createLiveOptions();
const browser = { close: jest.fn().mockResolvedValue(undefined) };
const failure = new Error("strict live failure");
const executeAttempt = jest.fn().mockRejectedValue(failure);
const preserveEvidence = jest.fn();
const readLine = jest.fn();
await expect(
main({
buildArtifact: jest.fn(() => ({ runtime: "extension" })),
connectBrowser: jest.fn().mockResolvedValue(browser),
environment: { RYD_LIVE_RUNTIME: "extension" },
executeAttempt,
output: createOutput(),
preserveEvidence,
readLine,
readOptions: jest.fn(() => options),
resetEvidenceWorkspace: jest.fn().mockResolvedValue({ removedEntries: [] }),
selectContext: jest.fn(async () => ({ context: {}, contextIndex: 0, pageIndex: 0, sessionPage: {} })),
}),
).rejects.toBe(failure);
expect(executeAttempt).toHaveBeenCalledTimes(1);
expect(preserveEvidence).not.toHaveBeenCalled();
expect(readLine).not.toHaveBeenCalled();
expect(browser.close).toHaveBeenCalledTimes(1);
});
test("returns the latest failed attempt as a failure when a persistent session exits", async () => {
const options = createLiveOptions();
const browser = { close: jest.fn().mockResolvedValue(undefined) };
const failure = new Error("persistent live failure");
await expect(
main({
buildArtifact: jest.fn(() => ({ runtime: "extension" })),
connectBrowser: jest.fn().mockResolvedValue(browser),
environment: { RYD_LIVE_KEEP_CDP_SESSION: "1", RYD_LIVE_RUNTIME: "extension" },
executeAttempt: jest.fn().mockRejectedValue(failure),
output: createOutput(),
preserveEvidence: jest.fn().mockResolvedValue("attempt-01-failed"),
readLine: jest.fn().mockResolvedValue("EXIT"),
readOptions: jest.fn(() => options),
resetEvidenceWorkspace: jest.fn().mockResolvedValue({ removedEntries: [] }),
selectContext: jest.fn(async () => ({ context: {}, contextIndex: 0, pageIndex: 0, sessionPage: {} })),
}),
).rejects.toBe(failure);
expect(browser.close).toHaveBeenCalledTimes(1);
});
test("keeps the connection open while rejecting unknown rerun commands", async () => {
const options = createLiveOptions();
const browser = { close: jest.fn().mockResolvedValue(undefined) };
const output = createOutput();
const executeAttempt = jest.fn().mockResolvedValue({ classification: "full" });
const readLine = jest
.fn()
.mockResolvedValueOnce("not-a-command")
.mockResolvedValueOnce("RERUN")
.mockResolvedValueOnce("EXIT");
await main({
buildArtifact: jest.fn(() => ({ runtime: "extension" })),
connectBrowser: jest.fn().mockResolvedValue(browser),
environment: { RYD_LIVE_KEEP_CDP_SESSION: "1", RYD_LIVE_RUNTIME: "extension" },
executeAttempt,
output,
preserveEvidence: jest.fn().mockResolvedValue("evidence"),
readLine,
readOptions: jest.fn(() => options),
resetEvidenceWorkspace: jest.fn().mockResolvedValue({ removedEntries: [] }),
selectContext: jest.fn(async () => ({ context: {}, contextIndex: 0, pageIndex: 0, sessionPage: {} })),
});
expect(output.error).toHaveBeenCalledWith("LIVE_RERUN_COMMAND_REJECTED expected=RERUN_OR_EXIT");
expect(executeAttempt).toHaveBeenCalledTimes(2);
expect(browser.close).toHaveBeenCalledTimes(1);
});
test("fails closed if a rerun tries to switch endpoint, account, or runtime", () => {
const initial = createLiveOptions();
expect(() => assertReusableConnectionOptions(initial, { ...initial, cdpEndpoint: "ws://other" })).toThrow(
/cdpEndpoint changed/,
);
expect(() => assertReusableConnectionOptions(initial, { ...initial, expectedChannel: "@other" })).toThrow(
/expectedChannel changed/,
);
expect(() => assertReusableConnectionOptions(initial, { ...initial, runtime: "userscript" })).toThrow(
/runtime changed/,
);
expect(assertReusableConnectionOptions(initial, { ...initial, expectedBuildId: "f".repeat(32) })).toMatchObject({
expectedBuildId: "f".repeat(32),
});
});
test("copies each persistent attempt's evidence without recursively copying prior attempt archives", () => {
const evidenceDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "ryd-live-attempt-"));
try {
fs.mkdirSync(path.join(evidenceDirectory, "responsive"), { recursive: true });
fs.writeFileSync(path.join(evidenceDirectory, "responsive", "watch.png"), "image");
fs.mkdirSync(path.join(evidenceDirectory, "attempts", "old"), { recursive: true });
fs.writeFileSync(path.join(evidenceDirectory, "attempts", "old", "old.json"), "{}");
const fixedDate = new Date("2026-09-04T12:00:00.000Z");
const archived = preserveLiveAttemptEvidence(
{ attemptNumber: 3, outcome: "failed" },
{ evidenceDirectory, now: () => fixedDate },
);
expect(archived).toBe(path.join(evidenceDirectory, "attempts", "03-2026-09-04T12-00-00-000Z-failed"));
expect(fs.readFileSync(path.join(archived, "responsive", "watch.png"), "utf8")).toBe("image");
expect(fs.existsSync(path.join(archived, "attempts", "old", "old.json"))).toBe(false);
expect(JSON.parse(fs.readFileSync(path.join(archived, "attempt.json"), "utf8"))).toEqual({
attemptNumber: 3,
capturedAt: fixedDate.toISOString(),
outcome: "failed",
});
} finally {
fs.rmSync(evidenceDirectory, { force: true, recursive: true });
}
});
test("removes only the active attempt workspace while preserving every prior attempt archive", () => {
const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ryd-live-reset-"));
const evidenceDirectory = path.join(temporaryRoot, "live-youtube");
try {
fs.mkdirSync(path.join(evidenceDirectory, "responsive"), { recursive: true });
fs.writeFileSync(path.join(evidenceDirectory, "responsive", "stale-watch.png"), "stale image");
fs.writeFileSync(path.join(evidenceDirectory, "manual-persistent.png"), "stale manual image");
fs.mkdirSync(path.join(evidenceDirectory, "Attempts", "01-passed"), { recursive: true });
fs.writeFileSync(path.join(evidenceDirectory, "Attempts", "01-passed", "attempt.json"), "{}");
const reset = resetLiveAttemptEvidenceWorkspace(
{ attemptNumber: 2 },
{ allowedParentDirectory: temporaryRoot, evidenceDirectory },
);
expect(reset).toEqual({
attemptNumber: 2,
evidenceDirectory: fs.realpathSync.native(evidenceDirectory),
removedEntries: ["manual-persistent.png", "responsive"],
});
expect(fs.readdirSync(evidenceDirectory).map((entry) => entry.toLowerCase())).toEqual(["attempts"]);
expect(fs.readFileSync(path.join(evidenceDirectory, "Attempts", "01-passed", "attempt.json"), "utf8")).toBe("{}");
} finally {
fs.rmSync(temporaryRoot, { force: true, recursive: true });
}
});
test("fails closed for filesystem roots, the repository root, and broader evidence parents", () => {
const repositoryDirectory = path.resolve(__dirname, "../../..");
const filesystemRoot = path.parse(repositoryDirectory).root;
expect(() => resolveSafeLiveEvidenceDirectory(filesystemRoot)).toThrow(/unsafe live evidence directory/);
expect(() => resolveSafeLiveEvidenceDirectory(repositoryDirectory)).toThrow(/unsafe live evidence directory/);
expect(() => resolveSafeLiveEvidenceDirectory(path.join(repositoryDirectory, "test-results"))).toThrow(
/unsafe live evidence directory/,
);
});
test("refuses an unrelated directory merely named live-youtube without deleting its contents", () => {
const unrelatedParent = fs.mkdtempSync(path.join(os.tmpdir(), "unrelated-live-evidence-"));
const unrelatedEvidence = path.join(unrelatedParent, "live-youtube");
const protectedFile = path.join(unrelatedEvidence, "keep.txt");
try {
fs.mkdirSync(unrelatedEvidence, { recursive: true });
fs.writeFileSync(protectedFile, "keep");
expect(() =>
resetLiveAttemptEvidenceWorkspace({ attemptNumber: 1 }, { evidenceDirectory: unrelatedEvidence }),
).toThrow(/unsafe live evidence directory/);
expect(fs.readFileSync(protectedFile, "utf8")).toBe("keep");
} finally {
fs.rmSync(unrelatedParent, { force: true, recursive: true });
}
});
});
@@ -0,0 +1,9 @@
const DEFAULT_LIVE_SHORTS_NEXT_HOPS = 10;
const MIN_LIVE_SHORTS_NEXT_HOPS = 10;
const MAX_LIVE_SHORTS_NEXT_HOPS = 25;
module.exports = {
DEFAULT_LIVE_SHORTS_NEXT_HOPS,
MAX_LIVE_SHORTS_NEXT_HOPS,
MIN_LIVE_SHORTS_NEXT_HOPS,
};
+50 -19
View File
@@ -3,11 +3,21 @@ const fs = require("node:fs");
const path = require("node:path"); const path = require("node:path");
const extensionPackage = require("../../../package.json"); const extensionPackage = require("../../../package.json");
const userscriptVersion = require("../userscript-version.json"); const userscriptVersion = require("../userscript-version.json");
const { assertCurrentLiveArtifact } = require("../e2e/live/live-artifact-readiness");
const { resolveCdpEndpoint } = require("./live-cdp-endpoint");
const {
DEFAULT_LIVE_SHORTS_NEXT_HOPS,
MAX_LIVE_SHORTS_NEXT_HOPS,
MIN_LIVE_SHORTS_NEXT_HOPS,
} = require("./live-navigation-constants");
const LIVE_VOTE_APPROVAL_WINDOW_SECONDS = 120; const LIVE_VOTE_APPROVAL_WINDOW_SECONDS = 120;
const LIVE_VOTE_APPROVALS_DIRECTORY = path.resolve(__dirname, "../../../test-results/live-youtube-vote-approvals"); 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_CDP_CONNECT_TIMEOUT_MILLISECONDS = 120_000;
const DEFAULT_LIVE_NAV_SHORT = "iKQhN7omLM4"; const MIN_LIVE_CDP_CONNECT_TIMEOUT_MILLISECONDS = 15_000;
const MAX_LIVE_CDP_CONNECT_TIMEOUT_MILLISECONDS = 300_000;
const DEFAULT_LIVE_NAV_CHANNEL_URL = "https://www.youtube.com/@MrBeast";
const DEFAULT_LIVE_NAV_SHORT = "5mU6SRS2Bxo";
const DEFAULT_LIVE_SIDEBAR_HOPS = 3; const DEFAULT_LIVE_SIDEBAR_HOPS = 3;
const MAX_LIVE_SIDEBAR_HOPS = 10; const MAX_LIVE_SIDEBAR_HOPS = 10;
const VIDEO_ID_PATTERN = /^[A-Za-z0-9_-]{11}$/; const VIDEO_ID_PATTERN = /^[A-Za-z0-9_-]{11}$/;
@@ -32,25 +42,16 @@ function requireVideoId(environment, name) {
return value; return value;
} }
function optionalVideoId(environment, name) { function optionalBoundedInteger(environment, name, defaultValue, maximum, minimum = 1) {
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(); const rawValue = environment[name]?.trim();
if (!rawValue) return defaultValue; if (!rawValue) return defaultValue;
if (!/^\d+$/.test(rawValue)) { if (!/^\d+$/.test(rawValue)) {
throw new Error(`${name} must be a whole number from 1 to ${maximum}.`); throw new Error(`${name} must be a whole number from ${minimum} to ${maximum}.`);
} }
const value = Number(rawValue); const value = Number(rawValue);
if (!Number.isSafeInteger(value) || value < 1 || value > maximum) { if (!Number.isSafeInteger(value) || value < minimum || value > maximum) {
throw new Error(`${name} must be a whole number from 1 to ${maximum}.`); throw new Error(`${name} must be a whole number from ${minimum} to ${maximum}.`);
} }
return value; return value;
} }
@@ -149,7 +150,14 @@ function parseChannelUrl(value) {
return url.toString(); return url.toString();
} }
function readExpectedBuildId(runtime, { markerPaths = LIVE_BUILD_MARKER_PATHS, readFileSync = fs.readFileSync } = {}) { function readExpectedBuildId(
runtime,
{
assertArtifactCurrent = assertCurrentLiveArtifact,
markerPaths = LIVE_BUILD_MARKER_PATHS,
readFileSync = fs.readFileSync,
} = {},
) {
const markerPath = markerPaths[runtime]; const markerPath = markerPaths[runtime];
let marker; let marker;
try { try {
@@ -163,13 +171,14 @@ function readExpectedBuildId(runtime, { markerPaths = LIVE_BUILD_MARKER_PATHS, r
if (!LIVE_BUILD_ID_PATTERN.test(marker?.buildId)) { if (!LIVE_BUILD_ID_PATTERN.test(marker?.buildId)) {
throw new Error(`The ${runtime} live-build marker at ${markerPath} is malformed.`); throw new Error(`The ${runtime} live-build marker at ${markerPath} is malformed.`);
} }
assertArtifactCurrent(runtime, marker.buildId);
return marker.buildId; return marker.buildId;
} }
function readLiveOptions( function readLiveOptions(
environment = process.env, environment = process.env,
nowMilliseconds = Date.now(), nowMilliseconds = Date.now(),
{ readBuildId = readExpectedBuildId } = {}, { readBuildId = readExpectedBuildId, resolveEndpoint = resolveCdpEndpoint } = {},
) { ) {
if (environment.RYD_LIVE_YOUTUBE !== "1") return null; if (environment.RYD_LIVE_YOUTUBE !== "1") return null;
@@ -187,6 +196,9 @@ function readLiveOptions(
const watchA = requireVideoId(environment, "RYD_LIVE_WATCH_A"); const watchA = requireVideoId(environment, "RYD_LIVE_WATCH_A");
const watchB = requireVideoId(environment, "RYD_LIVE_WATCH_B"); const watchB = requireVideoId(environment, "RYD_LIVE_WATCH_B");
const short = requireVideoId(environment, "RYD_LIVE_SHORT"); const short = requireVideoId(environment, "RYD_LIVE_SHORT");
const reactionShort = environment.RYD_LIVE_REACTION_SHORT?.trim()
? requireVideoId(environment, "RYD_LIVE_REACTION_SHORT")
: short;
if (watchA === watchB) throw new Error("RYD_LIVE_WATCH_A and RYD_LIVE_WATCH_B must be different videos."); if (watchA === watchB) throw new Error("RYD_LIVE_WATCH_A and RYD_LIVE_WATCH_B must be different videos.");
const expectedVersion = const expectedVersion =
@@ -194,7 +206,14 @@ function readLiveOptions(
(runtime === "userscript" ? userscriptVersion : extensionPackage.version); (runtime === "userscript" ? userscriptVersion : extensionPackage.version);
return { return {
cdpEndpoint: environment.RYD_CDP_ENDPOINT?.trim() || "chrome", cdpEndpoint: resolveEndpoint(environment.RYD_CDP_ENDPOINT?.trim() || "chrome", { environment }),
cdpConnectTimeoutMilliseconds: optionalBoundedInteger(
environment,
"RYD_CDP_CONNECT_TIMEOUT_MS",
DEFAULT_LIVE_CDP_CONNECT_TIMEOUT_MILLISECONDS,
MAX_LIVE_CDP_CONNECT_TIMEOUT_MILLISECONDS,
MIN_LIVE_CDP_CONNECT_TIMEOUT_MILLISECONDS,
),
expectedBuildId: readBuildId(runtime), expectedBuildId: readBuildId(runtime),
expectedChannel: requireChannelHandle(environment), expectedChannel: requireChannelHandle(environment),
expectedVersion, expectedVersion,
@@ -204,10 +223,18 @@ function readLiveOptions(
{ RYD_LIVE_NAV_SHORT: environment.RYD_LIVE_NAV_SHORT?.trim() || DEFAULT_LIVE_NAV_SHORT }, { RYD_LIVE_NAV_SHORT: environment.RYD_LIVE_NAV_SHORT?.trim() || DEFAULT_LIVE_NAV_SHORT },
"RYD_LIVE_NAV_SHORT", "RYD_LIVE_NAV_SHORT",
), ),
watch: optionalVideoId(environment, "RYD_LIVE_NAV_WATCH"), shortsNextHops: optionalBoundedInteger(
environment,
"RYD_LIVE_SHORTS_NEXT_HOPS",
DEFAULT_LIVE_SHORTS_NEXT_HOPS,
MAX_LIVE_SHORTS_NEXT_HOPS,
MIN_LIVE_SHORTS_NEXT_HOPS,
),
watch: requireVideoId(environment, "RYD_LIVE_NAV_WATCH"),
}, },
playlistUrl: parsePlaylistUrl(requireValue(environment, "RYD_LIVE_PLAYLIST_URL"), watchA), playlistUrl: parsePlaylistUrl(requireValue(environment, "RYD_LIVE_PLAYLIST_URL"), watchA),
productionApiApproved: true, productionApiApproved: true,
reactionShort,
runtime, runtime,
sidebar: { sidebar: {
hopCount: optionalBoundedInteger( hopCount: optionalBoundedInteger(
@@ -227,8 +254,12 @@ module.exports = {
DEFAULT_LIVE_NAV_CHANNEL_URL, DEFAULT_LIVE_NAV_CHANNEL_URL,
DEFAULT_LIVE_NAV_SHORT, DEFAULT_LIVE_NAV_SHORT,
DEFAULT_LIVE_SIDEBAR_HOPS, DEFAULT_LIVE_SIDEBAR_HOPS,
DEFAULT_LIVE_SHORTS_NEXT_HOPS,
DEFAULT_LIVE_CDP_CONNECT_TIMEOUT_MILLISECONDS,
LIVE_VOTE_APPROVALS_DIRECTORY, LIVE_VOTE_APPROVALS_DIRECTORY,
LIVE_VOTE_APPROVAL_WINDOW_SECONDS, LIVE_VOTE_APPROVAL_WINDOW_SECONDS,
MAX_LIVE_SHORTS_NEXT_HOPS,
MIN_LIVE_SHORTS_NEXT_HOPS,
consumeLiveVoteApproval, consumeLiveVoteApproval,
hasFreshVoteApproval, hasFreshVoteApproval,
liveVoteApproval, liveVoteApproval,
+103 -3
View File
@@ -5,6 +5,8 @@ const {
DEFAULT_LIVE_NAV_CHANNEL_URL, DEFAULT_LIVE_NAV_CHANNEL_URL,
DEFAULT_LIVE_NAV_SHORT, DEFAULT_LIVE_NAV_SHORT,
DEFAULT_LIVE_SIDEBAR_HOPS, DEFAULT_LIVE_SIDEBAR_HOPS,
DEFAULT_LIVE_SHORTS_NEXT_HOPS,
DEFAULT_LIVE_CDP_CONNECT_TIMEOUT_MILLISECONDS,
LIVE_VOTE_APPROVALS_DIRECTORY, LIVE_VOTE_APPROVALS_DIRECTORY,
LIVE_VOTE_APPROVAL_WINDOW_SECONDS, LIVE_VOTE_APPROVAL_WINDOW_SECONDS,
consumeLiveVoteApproval, consumeLiveVoteApproval,
@@ -24,6 +26,7 @@ const VALID_ENVIRONMENT = {
RYD_LIVE_WATCH_A: "abcdefghijk", RYD_LIVE_WATCH_A: "abcdefghijk",
RYD_LIVE_WATCH_B: "zyxwvutsrqp", RYD_LIVE_WATCH_B: "zyxwvutsrqp",
RYD_LIVE_SHORT: "shortsabcde", RYD_LIVE_SHORT: "shortsabcde",
RYD_LIVE_NAV_WATCH: "navwatch001",
RYD_LIVE_PLAYLIST_URL: "https://www.youtube.com/watch?v=abcdefghijk&list=PL-test", RYD_LIVE_PLAYLIST_URL: "https://www.youtube.com/watch?v=abcdefghijk&list=PL-test",
RYD_LIVE_EXPECTED_CHANNEL: "@ryd-test", RYD_LIVE_EXPECTED_CHANNEL: "@ryd-test",
}; };
@@ -31,20 +34,24 @@ const VALID_ENVIRONMENT = {
function readLiveOptions(environment, nowMilliseconds = NOW) { function readLiveOptions(environment, nowMilliseconds = NOW) {
return readLiveOptionsFromEnvironment(environment, nowMilliseconds, { return readLiveOptionsFromEnvironment(environment, nowMilliseconds, {
readBuildId: () => EXPECTED_BUILD_ID, readBuildId: () => EXPECTED_BUILD_ID,
resolveEndpoint: (value) => value,
}); });
} }
describe("live YouTube options", () => { describe("live YouTube options", () => {
test("reads the exact generated build ID from the selected runtime marker", () => { test("reads the exact generated build ID from the selected runtime marker", () => {
const readFileSync = jest.fn(() => JSON.stringify({ buildId: EXPECTED_BUILD_ID })); const readFileSync = jest.fn(() => JSON.stringify({ buildId: EXPECTED_BUILD_ID }));
const assertArtifactCurrent = jest.fn();
expect( expect(
readExpectedBuildId("userscript", { readExpectedBuildId("userscript", {
assertArtifactCurrent,
markerPaths: { userscript: "owned-live-build.json" }, markerPaths: { userscript: "owned-live-build.json" },
readFileSync, readFileSync,
}), }),
).toBe(EXPECTED_BUILD_ID); ).toBe(EXPECTED_BUILD_ID);
expect(readFileSync).toHaveBeenCalledWith("owned-live-build.json", "utf8"); expect(readFileSync).toHaveBeenCalledWith("owned-live-build.json", "utf8");
expect(assertArtifactCurrent).toHaveBeenCalledWith("userscript", EXPECTED_BUILD_ID);
}); });
test.each([ test.each([
@@ -60,12 +67,29 @@ describe("live YouTube options", () => {
])("rejects a %s generated live-build marker", (_label, readFileSync, expectedMessage) => { ])("rejects a %s generated live-build marker", (_label, readFileSync, expectedMessage) => {
expect(() => expect(() =>
readExpectedBuildId("extension", { readExpectedBuildId("extension", {
assertArtifactCurrent: jest.fn(),
markerPaths: { extension: "owned-live-build.json" }, markerPaths: { extension: "owned-live-build.json" },
readFileSync, readFileSync,
}), }),
).toThrow(expectedMessage); ).toThrow(expectedMessage);
}); });
test("rejects a live marker when the corresponding artifact receipt is stale", () => {
const staleArtifact = new Error("Userscript build inputs changed after the artifact was generated.");
const assertArtifactCurrent = jest.fn(() => {
throw staleArtifact;
});
expect(() =>
readExpectedBuildId("userscript", {
assertArtifactCurrent,
markerPaths: { userscript: "owned-live-build.json" },
readFileSync: () => JSON.stringify({ buildId: EXPECTED_BUILD_ID }),
}),
).toThrow(staleArtifact);
expect(assertArtifactCurrent).toHaveBeenCalledWith("userscript", EXPECTED_BUILD_ID);
});
test("stores consumed vote approvals outside Playwright's cleaned output directory", () => { test("stores consumed vote approvals outside Playwright's cleaned output directory", () => {
const playwrightOutputDirectory = path.resolve(__dirname, "../../../test-results/live-youtube"); const playwrightOutputDirectory = path.resolve(__dirname, "../../../test-results/live-youtube");
expect(LIVE_VOTE_APPROVALS_DIRECTORY).not.toBe(playwrightOutputDirectory); expect(LIVE_VOTE_APPROVALS_DIRECTORY).not.toBe(playwrightOutputDirectory);
@@ -76,6 +100,11 @@ describe("live YouTube options", () => {
expect(readLiveOptions({})).toBeNull(); expect(readLiveOptions({})).toBeNull();
}); });
test("defaults to the currently verified high-volume channel navigation fixture", () => {
expect(DEFAULT_LIVE_NAV_CHANNEL_URL).toBe("https://www.youtube.com/@MrBeast");
expect(DEFAULT_LIVE_NAV_SHORT).toBe("5mU6SRS2Bxo");
});
test("requires production API acknowledgement", () => { test("requires production API acknowledgement", () => {
expect(() => readLiveOptions({ ...VALID_ENVIRONMENT, RYD_LIVE_PRODUCTION_API: undefined })).toThrow( expect(() => readLiveOptions({ ...VALID_ENVIRONMENT, RYD_LIVE_PRODUCTION_API: undefined })).toThrow(
"RYD_LIVE_PRODUCTION_API=1", "RYD_LIVE_PRODUCTION_API=1",
@@ -116,12 +145,15 @@ describe("live YouTube options", () => {
test.each(["userscript", "extension"])("accepts the %s runtime", (runtime) => { test.each(["userscript", "extension"])("accepts the %s runtime", (runtime) => {
expect(readLiveOptions({ ...VALID_ENVIRONMENT, RYD_LIVE_RUNTIME: runtime })).toMatchObject({ expect(readLiveOptions({ ...VALID_ENVIRONMENT, RYD_LIVE_RUNTIME: runtime })).toMatchObject({
cdpEndpoint: "chrome", cdpEndpoint: "chrome",
cdpConnectTimeoutMilliseconds: DEFAULT_LIVE_CDP_CONNECT_TIMEOUT_MILLISECONDS,
expectedBuildId: EXPECTED_BUILD_ID, expectedBuildId: EXPECTED_BUILD_ID,
navigation: { navigation: {
channelUrl: DEFAULT_LIVE_NAV_CHANNEL_URL, channelUrl: DEFAULT_LIVE_NAV_CHANNEL_URL,
short: DEFAULT_LIVE_NAV_SHORT, short: DEFAULT_LIVE_NAV_SHORT,
watch: null, shortsNextHops: DEFAULT_LIVE_SHORTS_NEXT_HOPS,
watch: "navwatch001",
}, },
reactionShort: "shortsabcde",
runtime, runtime,
sidebar: { hopCount: DEFAULT_LIVE_SIDEBAR_HOPS }, sidebar: { hopCount: DEFAULT_LIVE_SIDEBAR_HOPS },
short: "shortsabcde", short: "shortsabcde",
@@ -135,19 +167,68 @@ describe("live YouTube options", () => {
const result = readLiveOptionsFromEnvironment( const result = readLiveOptionsFromEnvironment(
{ ...VALID_ENVIRONMENT, RYD_LIVE_EXPECTED_BUILD_ID: "f".repeat(32) }, { ...VALID_ENVIRONMENT, RYD_LIVE_EXPECTED_BUILD_ID: "f".repeat(32) },
NOW, NOW,
{ readBuildId }, { readBuildId, resolveEndpoint: (value) => value },
); );
expect(readBuildId).toHaveBeenCalledWith("userscript"); expect(readBuildId).toHaveBeenCalledWith("userscript");
expect(result.expectedBuildId).toBe(EXPECTED_BUILD_ID); expect(result.expectedBuildId).toBe(EXPECTED_BUILD_ID);
}); });
test("resolves the configured browser endpoint with the same environment used for live options", () => {
const resolveEndpoint = jest.fn(() => "ws://127.0.0.1:60011/devtools/browser/session");
const environment = { ...VALID_ENVIRONMENT, RYD_CDP_ENDPOINT: " 127.0.0.1:60011 " };
const result = readLiveOptionsFromEnvironment(environment, NOW, {
readBuildId: () => EXPECTED_BUILD_ID,
resolveEndpoint,
});
expect(resolveEndpoint).toHaveBeenCalledWith("127.0.0.1:60011", { environment });
expect(result.cdpEndpoint).toBe("ws://127.0.0.1:60011/devtools/browser/session");
});
test("accepts a bounded remote-debugging approval timeout", () => {
expect(readLiveOptions({ ...VALID_ENVIRONMENT, RYD_CDP_CONNECT_TIMEOUT_MS: "180000" })).toMatchObject({
cdpConnectTimeoutMilliseconds: 180_000,
});
});
test.each(["0", "14999", "300001", "1.5", "two minutes", "-1"])(
"rejects invalid remote-debugging approval timeout %p",
(timeout) => {
expect(() => readLiveOptions({ ...VALID_ENVIRONMENT, RYD_CDP_CONNECT_TIMEOUT_MS: timeout })).toThrow(
/RYD_CDP_CONNECT_TIMEOUT_MS.*whole number from 15000 to 300000/,
);
},
);
test("accepts a bounded sidebar stress hop count", () => { test("accepts a bounded sidebar stress hop count", () => {
expect(readLiveOptions({ ...VALID_ENVIRONMENT, RYD_LIVE_SIDEBAR_HOPS: "5" })).toMatchObject({ expect(readLiveOptions({ ...VALID_ENVIRONMENT, RYD_LIVE_SIDEBAR_HOPS: "5" })).toMatchObject({
sidebar: { hopCount: 5 }, sidebar: { hopCount: 5 },
}); });
}); });
test("defaults the live Shorts stress to ten successful Next samples", () => {
expect(readLiveOptions(VALID_ENVIRONMENT)).toMatchObject({
navigation: { shortsNextHops: 10 },
});
});
test("accepts a larger bounded successful live Shorts Next count", () => {
expect(readLiveOptions({ ...VALID_ENVIRONMENT, RYD_LIVE_SHORTS_NEXT_HOPS: "15" })).toMatchObject({
navigation: { shortsNextHops: 15 },
});
});
test.each(["0", "1", "9", "26", "10.5", "three", "-1"])(
"rejects live Shorts Next count below ten or outside the bound: %p",
(hopCount) => {
expect(() => readLiveOptions({ ...VALID_ENVIRONMENT, RYD_LIVE_SHORTS_NEXT_HOPS: hopCount })).toThrow(
/RYD_LIVE_SHORTS_NEXT_HOPS.*whole number from 10 to 25/,
);
},
);
test.each(["0", "11", "1.5", "three", "-1"])("rejects invalid sidebar stress hop count %p", (hopCount) => { 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( expect(() => readLiveOptions({ ...VALID_ENVIRONMENT, RYD_LIVE_SIDEBAR_HOPS: hopCount })).toThrow(
/RYD_LIVE_SIDEBAR_HOPS.*whole number from 1 to 10/, /RYD_LIVE_SIDEBAR_HOPS.*whole number from 1 to 10/,
@@ -171,6 +252,19 @@ describe("live YouTube options", () => {
}); });
}); });
test("accepts a dedicated reaction Short without changing the read-only Short", () => {
expect(readLiveOptions({ ...VALID_ENVIRONMENT, RYD_LIVE_REACTION_SHORT: "reactshort1" })).toMatchObject({
reactionShort: "reactshort1",
short: "shortsabcde",
});
});
test.each(["invalid", "abcdefghij!", "abcdefghijkl"])("rejects malformed reaction Short ID %p", (videoId) => {
expect(() => readLiveOptions({ ...VALID_ENVIRONMENT, RYD_LIVE_REACTION_SHORT: videoId })).toThrow(
"RYD_LIVE_REACTION_SHORT must be an 11-character YouTube video ID",
);
});
test.each([ test.each([
"http://www.youtube.com/@SmashTrash", "http://www.youtube.com/@SmashTrash",
"https://example.com/@SmashTrash", "https://example.com/@SmashTrash",
@@ -184,7 +278,13 @@ describe("live YouTube options", () => {
); );
}); });
test.each(["RYD_LIVE_NAV_SHORT", "RYD_LIVE_NAV_WATCH"])("validates optional navigation ID %s", (name) => { test("requires an exact channel Watch target", () => {
expect(() => readLiveOptions({ ...VALID_ENVIRONMENT, RYD_LIVE_NAV_WATCH: undefined })).toThrow(
"RYD_LIVE_NAV_WATCH is required",
);
});
test.each(["RYD_LIVE_NAV_SHORT", "RYD_LIVE_NAV_WATCH"])("validates navigation ID %s", (name) => {
expect(() => readLiveOptions({ ...VALID_ENVIRONMENT, [name]: "invalid" })).toThrow("11-character YouTube video ID"); expect(() => readLiveOptions({ ...VALID_ENVIRONMENT, [name]: "invalid" })).toThrow("11-character YouTube video ID");
}); });
@@ -6,6 +6,7 @@ class FakeBrowserContext {
this.routes = []; this.routes = [];
this.routeCalls = 0; this.routeCalls = 0;
this.unrouteCalls = 0; this.unrouteCalls = 0;
this.unrouteImplementation = null;
} }
on(event, handler) { on(event, handler) {
@@ -25,6 +26,7 @@ class FakeBrowserContext {
async unroute(matcher, handler) { async unroute(matcher, handler) {
this.unrouteCalls += 1; this.unrouteCalls += 1;
this.routes = this.routes.filter((route) => route.matcher !== matcher || route.handler !== handler); this.routes = this.routes.filter((route) => route.matcher !== matcher || route.handler !== handler);
if (this.unrouteImplementation) await this.unrouteImplementation();
} }
async dispatch(request) { async dispatch(request) {
@@ -45,12 +47,12 @@ class FakeBrowserContext {
} }
} }
function createDriver(context) { function createDriver(context, options = {}) {
const page = { const page = {
setDefaultNavigationTimeout: jest.fn(), setDefaultNavigationTimeout: jest.fn(),
setDefaultTimeout: jest.fn(), setDefaultTimeout: jest.fn(),
}; };
return new LiveYoutubeDriver(page, context); return new LiveYoutubeDriver(page, context, options);
} }
function request(method, url) { function request(method, url) {
@@ -116,4 +118,59 @@ describe("live read-only production-interaction guard", () => {
expect(context.unrouteCalls).toBe(1); expect(context.unrouteCalls).toBe(1);
expect(context.routes).toEqual([]); expect(context.routes).toEqual([]);
}); });
test("bounds a stalled route removal, clears the guard, and allows a fresh guard", async () => {
const context = new FakeBrowserContext();
const reportProgress = jest.fn();
const driver = createDriver(context, {
readOnlyInteractionGuardCleanupTimeout: 10,
reportProgress,
});
context.unrouteImplementation = () => new Promise(() => {});
await expect(driver.withNoProductionInteractions(async () => "first action complete")).rejects.toThrow(
"Timed out after 10ms while removing the live read-only production-interaction route.",
);
expect(driver.readOnlyInteractionGuard).toBeNull();
expect(context.listeners.get("request")?.size ?? 0).toBe(0);
expect(context.routes).toEqual([]);
expect(reportProgress).toHaveBeenCalledWith("read-only-interaction-guard.unroute-started", { timeoutMs: 10 });
expect(reportProgress).toHaveBeenCalledWith("read-only-interaction-guard.unroute-failed", {
message: "Timed out after 10ms while removing the live read-only production-interaction route.",
timeoutMs: 10,
});
context.unrouteImplementation = null;
await expect(driver.withNoProductionInteractions(async () => "second action complete")).resolves.toBe(
"second action complete",
);
expect(context.routeCalls).toBe(2);
expect(context.unrouteCalls).toBe(2);
});
test("preserves both the action failure and a stalled route-removal failure", async () => {
const context = new FakeBrowserContext();
const driver = createDriver(context, { readOnlyInteractionGuardCleanupTimeout: 10 });
const actionError = new Error("read-only action failed");
context.unrouteImplementation = () => new Promise(() => {});
const failure = await driver
.withNoProductionInteractions(async () => {
throw actionError;
})
.catch((error) => error);
expect(failure).toBeInstanceOf(AggregateError);
expect(failure.message).toBe("The read-only live scenario and its production-interaction guard both failed.");
expect(failure.errors).toHaveLength(2);
expect(failure.errors[0]).toBe(actionError);
expect(failure.errors[1]).toEqual(
expect.objectContaining({
message: "Timed out after 10ms while removing the live read-only production-interaction route.",
}),
);
expect(driver.readOnlyInteractionGuard).toBeNull();
expect(context.listeners.get("request")?.size ?? 0).toBe(0);
});
}); });
@@ -0,0 +1,120 @@
const { AttributedRuntimeTrafficLedger, attributeRuntimeRequest } = require("../e2e/live/live-youtube-driver");
const SELECTED_EXTENSION_ID = "abcdefghijklmnopabcdefghijklmnop";
const EXPECTED_BUILD_ID = "0123456789abcdef0123456789abcdef";
function createEventContext(pages = []) {
const listeners = new Map();
return {
context: {
off: jest.fn((event, listener) => listeners.get(event)?.delete(listener)),
on: jest.fn((event, listener) => {
if (!listeners.has(event)) listeners.set(event, new Set());
listeners.get(event).add(listener);
}),
pages: jest.fn(() => pages),
},
emit(event, value) {
listeners.get(event)?.forEach((listener) => listener(value));
},
listeners,
};
}
function pageRequest(page, { method, pathname, videoId }) {
return {
frame: () => ({ page: () => page }),
method: () => method,
postDataJSON: () => ({ userId: "A".repeat(36), value: -1, videoId }),
url: () => `https://returnyoutubedislikeapi.com${pathname}${pathname === "/votes" ? `?videoId=${videoId}` : ""}`,
};
}
async function advanceFakeTime(milliseconds) {
for (let elapsed = 0; elapsed < milliseconds; elapsed += 25) {
jest.advanceTimersByTime(Math.min(25, milliseconds - elapsed));
await Promise.resolve();
await Promise.resolve();
}
}
describe("run-wide attributed runtime traffic ledger", () => {
afterEach(() => {
jest.useRealTimers();
});
test.each([
["late vote-data GET", { method: "GET", pathname: "/votes" }],
["late interaction POST", { method: "POST", pathname: "/interact/vote" }],
])("rejects a %s emitted after the stage listener detached", async (_label, requestOptions) => {
jest.useFakeTimers({ doNotFake: ["performance"] });
const page = {};
const harness = createEventContext([page]);
const ledger = new AttributedRuntimeTrafficLedger(harness.context, page, (request) =>
attributeRuntimeRequest(request, page, "userscript"),
);
const detachedStageListener = jest.fn();
harness.context.on("request", detachedStageListener);
harness.context.off("request", detachedStageListener);
const request = pageRequest(page, { ...requestOptions, videoId: "abcdefghijk" });
const audit = ledger.assertNoUnclaimed({ label: "detached stage", quietMs: 100, timeoutMs: 500 });
const rejection = expect(audit).rejects.toThrow(/escaped every active audit/);
setTimeout(() => harness.emit("request", request), 50);
await advanceFakeTime(250);
await rejection;
expect(detachedStageListener).not.toHaveBeenCalled();
expect(ledger.records).toEqual([
expect.objectContaining({
method: requestOptions.method,
pathname: requestOptions.pathname,
videoId: "abcdefghijk",
}),
]);
ledger.stop();
});
test("accepts delayed traffic only when an active stage claims that exact request", async () => {
jest.useFakeTimers({ doNotFake: ["performance"] });
const page = {};
const harness = createEventContext([page]);
const ledger = new AttributedRuntimeTrafficLedger(harness.context, page, (request) =>
attributeRuntimeRequest(request, page, "userscript"),
);
const request = pageRequest(page, { method: "GET", pathname: "/votes", videoId: "abcdefghijk" });
harness.context.on("request", (candidate) => ledger.claim(candidate, "exact-votes-stage"));
const audit = ledger.assertNoUnclaimed({ label: "claimed stage", quietMs: 100, timeoutMs: 500 });
setTimeout(() => harness.emit("request", request), 50);
await advanceFakeTime(250);
await expect(audit).resolves.toEqual({ attributedRequests: 1, label: "claimed stage" });
ledger.stop();
});
test("retains same-video other-tab ambiguity from request time after that tab navigates away", async () => {
const page = { url: () => "https://www.youtube.com/watch?v=abcdefghijk" };
let otherUrl = "https://www.youtube.com/shorts/abcdefghijk";
const otherPage = { url: () => otherUrl };
const harness = createEventContext([page, otherPage]);
const ledger = new AttributedRuntimeTrafficLedger(harness.context, page, (request) =>
attributeRuntimeRequest(request, page, "extension", SELECTED_EXTENSION_ID, EXPECTED_BUILD_ID),
);
const request = {
frame: () => {
throw new Error("Service Worker requests do not have a frame");
},
method: () => "GET",
serviceWorker: () => ({ url: () => `chrome-extension://${SELECTED_EXTENSION_ID}/ryd.background.js` }),
url: () => "https://returnyoutubedislikeapi.com/votes?videoId=abcdefghijk",
};
harness.emit("request", request);
expect(ledger.claim(request, "exact-votes-stage")).toBe(true);
otherUrl = "https://www.youtube.com/watch?v=lmnopqrstuv";
await expect(ledger.assertNoUnclaimed({ quietMs: 0 })).rejects.toThrow(/same video open at send time/);
ledger.stop();
});
});
File diff suppressed because it is too large Load Diff
@@ -1,7 +1,8 @@
const { assertLogicalVoteHandshake } = require("../e2e/live/live-youtube-driver"); const { assertLogicalVoteHandshake } = require("../e2e/live/live-youtube-driver");
const VIDEO_ID = "abcdefghijk"; const VIDEO_ID = "abcdefghijk";
const USER_ID = "shared-user-id"; const USER_ID = "A".repeat(36);
const SOLUTION = Buffer.alloc(4).toString("base64");
function vote({ status = 200, userId = USER_ID, value = 1, videoId = VIDEO_ID } = {}) { function vote({ status = 200, userId = USER_ID, value = 1, videoId = VIDEO_ID } = {}) {
return { return {
@@ -14,7 +15,7 @@ function vote({ status = 200, userId = USER_ID, value = 1, videoId = VIDEO_ID }
function confirmation({ confirmed = true, status = 200, userId = USER_ID, videoId = VIDEO_ID } = {}) { function confirmation({ confirmed = true, status = 200, userId = USER_ID, videoId = VIDEO_ID } = {}) {
return { return {
body: { solution: "proof", userId, videoId }, body: { solution: SOLUTION, userId, videoId },
pathname: "/interact/confirmVote", pathname: "/interact/confirmVote",
responseBody: confirmed, responseBody: confirmed,
responseError: null, responseError: null,
@@ -42,13 +43,24 @@ describe("live logical vote handshake validation", () => {
}); });
test.each([ test.each([
["user", [vote(), vote({ userId: "another-user" }), confirmation()], /different user IDs/], ["user", [vote(), vote({ userId: "B".repeat(36) }), confirmation()], /different user IDs/],
["video", [vote(), vote({ videoId: "lmnopqrstuv" }), confirmation()], /different video/], ["video", [vote(), vote({ videoId: "lmnopqrstuv" }), confirmation()], /different video/],
["value", [vote(), vote({ value: 0 }), confirmation()], /changed the requested vote value/], ["value", [vote(), vote({ value: 0 }), confirmation()], /changed the requested vote value/],
])("rejects a retry with a mismatched %s", (_field, records, message) => { ])("rejects a retry with a mismatched %s", (_field, records, message) => {
expect(() => assertLogicalVoteHandshake(records, VIDEO_ID, 1)).toThrow(message); expect(() => assertLogicalVoteHandshake(records, VIDEO_ID, 1)).toThrow(message);
}); });
test("rejects a complete handshake that targets the stale origin video", () => {
const staleVideoId = "lmnopqrstuv";
expect(() =>
assertLogicalVoteHandshake(
[vote({ videoId: staleVideoId }), confirmation({ videoId: staleVideoId })],
VIDEO_ID,
1,
),
).toThrow(/targeted a different video/);
});
test.each([ test.each([
["extra confirmation", [vote(), confirmation(), confirmation()]], ["extra confirmation", [vote(), confirmation(), confirmation()]],
["other interaction", [vote(), { ...vote(), pathname: "/interact/other" }, confirmation()]], ["other interaction", [vote(), { ...vote(), pathname: "/interact/other" }, confirmation()]],
@@ -65,6 +77,27 @@ describe("live logical vote handshake validation", () => {
); );
}); });
test.each([
["short user ID", [vote({ userId: "short" }), confirmation({ userId: "short" })], /36-character/],
[
"extra vote body key",
[{ ...vote(), body: { ...vote().body, extra: true } }, confirmation()],
/exactly userId, value, and videoId/,
],
[
"extra confirmation body key",
[vote(), { ...confirmation(), body: { ...confirmation().body, extra: true } }],
/exactly solution, userId, and videoId/,
],
[
"non-four-byte proof",
[vote(), { ...confirmation(), body: { ...confirmation().body, solution: "AA==" } }],
/four-byte proof/,
],
])("rejects a handshake with a %s", (_label, records, message) => {
expect(() => assertLogicalVoteHandshake(records, VIDEO_ID, 1)).toThrow(message);
});
test("rejects a failed vote or confirmation response", () => { test("rejects a failed vote or confirmation response", () => {
expect(() => assertLogicalVoteHandshake([vote({ status: 500 }), confirmation()], VIDEO_ID, 1)).toThrow( expect(() => assertLogicalVoteHandshake([vote({ status: 500 }), confirmation()], VIDEO_ID, 1)).toThrow(
/Vote request failed with HTTP 500/, /Vote request failed with HTTP 500/,
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,132 @@
const {
assertWatchRatioData,
colorContrastRatio,
formattedWatchTooltipCandidates,
normalizeTooltipText,
parseCssColor,
} = require("../e2e/live/watch-ratio-audit");
const VIDEO_ID = "abcdefghijk";
const COUNTS = Object.freeze({ dislikes: 250, likes: 750 });
function measurement(overrides = {}) {
return {
appearance: {
inlineFillWidth: "75%",
negativeTrackColor: "rgb(126, 126, 126)",
pageBackgroundColor: "rgb(15, 15, 15)",
tooltipText: "750 / 250",
wrapperVideoId: VIDEO_ID,
...overrides.appearance,
},
geometry: {
bar: { width: 150 },
container: { width: 200 },
...overrides.geometry,
},
};
}
function assertMeasurement(value, options = {}) {
return assertWatchRatioData(value, {
expectedCounts: COUNTS,
expectedTooltipCandidates: ["750 / 250"],
runtime: "extension",
videoId: VIDEO_ID,
...options,
});
}
describe("live Watch ratio data audit", () => {
test.each([
["dark", "rgb(126, 126, 126)", "rgb(15, 15, 15)"],
["light", "rgb(115, 115, 115)", "rgb(255, 255, 255)"],
])("accepts an owned exact-ratio bar with a visible negative track in the %s theme", (_theme, track, page) => {
expect(
assertMeasurement(measurement({ appearance: { negativeTrackColor: track, pageBackgroundColor: page } })),
).toMatchObject({
expectedRatio: 0.75,
inlineRatio: 0.75,
renderedRatio: 0.75,
tooltipText: "750 / 250",
wrapperVideoId: VIDEO_ID,
});
});
test.each([null, "zyxwvutsrqp"])("rejects a Watch ratio wrapper owned by %s", (wrapperVideoId) => {
expect(() => assertMeasurement(measurement({ appearance: { wrapperVideoId } }))).toThrow(
/Watch ratio bar is owned by/,
);
});
test.each([
["inline width", { appearance: { inlineFillWidth: "25%" } }],
["rendered geometry", { geometry: { bar: { width: 50 } } }],
])("rejects a wrong %s even when the displayed count is unchanged", (_label, override) => {
expect(() => assertMeasurement(measurement(override))).toThrow(
/expected 75\.000% from exact API counts|expected 75\.00%/,
);
});
test("rejects a stale tooltip even when ownership, count text, and ratio are valid", () => {
expect(() => assertMeasurement(measurement({ appearance: { tooltipText: "900 / 100" } }))).toThrow(
/tooltip.*does not exactly match the API counts/,
);
});
test("accepts the exact configured percentage variants", () => {
expect(
assertMeasurement(measurement({ appearance: { tooltipText: "750 / 250 - 75%" } }), {
expectedTooltipCandidates: ["750 / 250", "750 / 250 - 75%", "750 / 250 - 25%"],
}).tooltipText,
).toBe("750 / 250 - 75%");
});
test("derives every configured tooltip form from the exact API counts and browser locale", () => {
expect(formattedWatchTooltipCandidates(COUNTS, "en-US")).toEqual([
{ form: "counts", text: "750 / 250" },
{ form: "counts-with-like-percentage", text: "750 / 250 - 75%" },
{ form: "counts-with-dislike-percentage", text: "750 / 250 - 25%" },
{ form: "both-percentages", text: "75% / 25%" },
{ form: "like-percentage", text: "75%" },
{ form: "dislike-percentage", text: "25%" },
]);
});
test.each([
["transparent", "rgba(115, 115, 115, 0)"],
["ancestor opacity", "rgb(115, 115, 115)", "rgb(15, 15, 15)", 0.01],
["near-background dark", "rgb(18, 18, 18)"],
["near-background light", "rgb(248, 248, 248)", "rgb(255, 255, 255)"],
])("rejects an effectively invisible negative track: %s", (_label, track, page = "rgb(15, 15, 15)", opacity = 1) => {
expect(() =>
assertMeasurement(
measurement({
appearance: { negativeTrackColor: track, negativeTrackOpacity: opacity, pageBackgroundColor: page },
}),
),
).toThrow(/negative track is effectively invisible/);
});
test("rejects a negative-track background image instead of guessing its contrast", () => {
expect(() =>
assertMeasurement(measurement({ appearance: { negativeTrackBackgroundImage: "linear-gradient(#000, #fff)" } })),
).toThrow(/background image whose effective contrast cannot be audited/);
});
test("normalizes non-breaking spaces and directional formatting in tooltip text", () => {
expect(normalizeTooltipText("\u2066 750\u00a0/\u00a0250 \u2069")).toBe("750 / 250");
});
test.each([
["#737373", { a: 1, b: 115, g: 115, r: 115 }],
["rgba(115, 115, 115, 50%)", { a: 0.5, b: 115, g: 115, r: 115 }],
["color(srgb 0.5 0.25 0 / 0.75)", { a: 0.75, b: 0, g: 63.75, r: 127.5 }],
])("parses computed CSS color %s", (value, expected) => {
expect(parseCssColor(value)).toEqual(expected);
});
test("computes the standard black-to-white contrast ratio", () => {
expect(colorContrastRatio(parseCssColor("#000"), parseCssColor("#fff"))).toBeCloseTo(21, 5);
});
});
+307 -58
View File
@@ -1,4 +1,14 @@
import { createVoteClient } from "../../common/vote-client"; import { createVoteClient } from "../../common/vote-client";
import { resolveDelegatedVoteActivation } from "../../common/delegated-vote-activation";
import {
actionBarHasHydratedData,
captureShortsNativeControlInventory,
getShortsControlSurfaceStabilityMs,
getShortsIdentityLinkVideoIds,
isShortsControlSurfaceReadyForMutation as sharedShortsSurfaceIsReady,
shortsNativeControlInventoryIsReadyForFallback as sharedShortsNativeInventoryIsReadyForFallback,
shortsNativeControlInventoryMatches,
} from "../../common/shorts-control-readiness";
import { import {
LIKED_STATE, LIKED_STATE,
DISLIKED_STATE, DISLIKED_STATE,
@@ -58,7 +68,6 @@ function getShortVideoIdFromPathname(pathname) {
let isShorts = () => getShortVideoIdFromPathname(location.pathname) !== null; let isShorts = () => getShortVideoIdFromPathname(location.pathname) !== null;
let mobileDislikes = 0; let mobileDislikes = 0;
let suppressNextLikeActivation = false; let suppressNextLikeActivation = false;
const boundLikeButtons = new WeakSet();
const boundDislikeButtons = new WeakSet(); const boundDislikeButtons = new WeakSet();
const boundActivationVideoIds = new WeakMap(); const boundActivationVideoIds = new WeakMap();
const suppressedStaleRefreshTargets = new WeakSet(); const suppressedStaleRefreshTargets = new WeakSet();
@@ -71,6 +80,7 @@ let shortsLifecycleObserver = null;
let shortsLifecycleObserverTarget = null; let shortsLifecycleObserverTarget = null;
let initializationGeneration = 0; let initializationGeneration = 0;
let initializationTimer = null; let initializationTimer = null;
let pendingShortsMutationSurface = null;
let activeCountRequest = null; let activeCountRequest = null;
let countStateVideoId = null; let countStateVideoId = null;
let countStateLoaded = false; let countStateLoaded = false;
@@ -115,6 +125,16 @@ function intersectsViewport(element) {
); );
} }
function hasMeaningfulViewportPresence(element) {
const rect = element?.getBoundingClientRect?.();
if (!rect || rect.width <= 0 || rect.height <= 0) return false;
const height = innerHeight || document.documentElement.clientHeight;
const width = innerWidth || document.documentElement.clientWidth;
const visibleWidth = Math.max(0, Math.min(rect.right, width) - Math.max(rect.left, 0));
const visibleHeight = Math.max(0, Math.min(rect.bottom, height) - Math.max(rect.top, 0));
return (visibleWidth * visibleHeight) / (rect.width * rect.height) >= 0.5;
}
function hasRenderedBox(element) { function hasRenderedBox(element) {
if (!element?.isConnected || element.closest("[hidden], [aria-hidden='true'], [inert]")) { if (!element?.isConnected || element.closest("[hidden], [aria-hidden='true'], [inert]")) {
return false; return false;
@@ -136,26 +156,22 @@ function hasRenderedBox(element) {
function getRendererShortVideoIds(renderer) { function getRendererShortVideoIds(renderer) {
const identities = new Set(); const identities = new Set();
if (!renderer) {
return identities;
}
const attributeVideoId = renderer.getAttribute("video-id"); const attributeVideoId = renderer.getAttribute("video-id");
if (attributeVideoId) { if (attributeVideoId) {
identities.add(attributeVideoId); identities.add(attributeVideoId);
return identities;
} }
for (const link of renderer.querySelectorAll('a[href*="/shorts/"]')) { for (const linkVideoId of getShortsIdentityLinkVideoIds(renderer, location.href)) {
try { identities.add(linkVideoId);
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; return identities;
} }
function rendererMatchesShort(renderer, videoId) { function rendererMatchesShort(renderer, videoId) {
return Boolean(videoId) && getRendererShortVideoIds(renderer).has(videoId); const videoIds = getRendererShortVideoIds(renderer);
return Boolean(videoId) && videoIds.size === 1 && videoIds.has(videoId);
} }
function getControlOwnershipVideoIds(container) { function getControlOwnershipVideoIds(container) {
@@ -438,9 +454,9 @@ function getActiveDesktopShortsActionBar() {
})) }))
.filter(({ actionBar }) => actionBar); .filter(({ actionBar }) => actionBar);
const matchingCandidate = candidates.find(({ renderer }) => rendererMatchesShort(renderer, videoId)); const matchingCandidates = candidates.filter(({ renderer }) => rendererMatchesShort(renderer, videoId));
if (matchingCandidate) { if (matchingCandidates.length === 1) {
return matchingCandidate.actionBar; return matchingCandidates[0].actionBar;
} }
// During channel/watch -> Shorts SPA transitions, YouTube can render the active // During channel/watch -> Shorts SPA transitions, YouTube can render the active
@@ -464,6 +480,47 @@ function getActiveDesktopShortsActionBar() {
return fullyVisibleCandidates.length === 1 ? fullyVisibleCandidates[0].actionBar : null; return fullyVisibleCandidates.length === 1 ? fullyVisibleCandidates[0].actionBar : null;
} }
function getShortsControlSurfaceSignature(buttons) {
return (
Array.from(getRendererShortVideoIds(buttons?.closest("ytd-reel-video-renderer")))
.sort()
.join("|") || "identityless"
);
}
function getVisibleDesktopShortsControlSurfaceCount() {
return Array.from(document.querySelectorAll("reel-action-bar-view-model")).filter(
(candidate) => hasRenderedBox(candidate) && hasMeaningfulViewportPresence(candidate),
).length;
}
function shortsControlSurfaceIsReadyForMutation(
buttons,
currentVideoId,
{ allowUnhydratedFallback = false, isHydrated = false, isStable = false } = {},
) {
return sharedShortsSurfaceIsReady({
allowUnhydratedFallback,
candidateVideoIds: getRendererShortVideoIds(buttons?.closest("ytd-reel-video-renderer")),
currentVideoId,
isConnected: buttons?.isConnected,
isHydrated,
isRendered: hasRenderedBox(buttons),
isStable,
isViewportIntersecting: hasMeaningfulViewportPresence(buttons),
visibleCandidateCount: getVisibleDesktopShortsControlSurfaceCount(),
});
}
function shortsNativeControlInventoryIsReadyForFallback(inventory) {
return sharedShortsNativeInventoryIsReadyForFallback(inventory, {
getTopmostElement:
typeof document.elementFromPoint === "function" ? (x, y) => document.elementFromPoint(x, y) : null,
isMeaningfullyInViewport: hasMeaningfulViewportPresence,
isRendered: hasRenderedBox,
});
}
function getActiveMobileShortsButtons() { function getActiveMobileShortsButtons() {
const videoId = getVideoId(); const videoId = getVideoId();
const candidates = Array.from(document.querySelectorAll("ytm-like-button-renderer")) const candidates = Array.from(document.querySelectorAll("ytm-like-button-renderer"))
@@ -486,18 +543,7 @@ function getActiveMobileShortsButtons() {
} }
function getExactShortLinkVideoIds(element) { function getExactShortLinkVideoIds(element) {
const identities = new Set(); return getShortsIdentityLinkVideoIds(element, location.href);
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) { function getMobileShortOwnership(buttons) {
@@ -629,19 +675,32 @@ function removeSyntheticShortsDislike(syntheticDislike) {
} }
} }
function ensureSyntheticShortsDislikeButton(buttons) { function ensureSyntheticShortsDislikeButton(
buttons,
{ allowUnhydratedFallback = false, currentVideoId = getVideoId(), isHydrated = false, isStable = false } = {},
) {
if (!isShorts() || isMobile || !buttons) { if (!isShorts() || isMobile || !buttons) {
return; return;
} }
const syntheticDislike = buttons.querySelector(SYNTHETIC_SHORTS_DISLIKE_SELECTOR); const syntheticDislikes = Array.from(buttons.querySelectorAll(SYNTHETIC_SHORTS_DISLIKE_SELECTOR));
const syntheticDislike = syntheticDislikes[0];
const nativeDislike = buttons.querySelector("dislike-button-view-model, #dislike-button"); const nativeDislike = buttons.querySelector("dislike-button-view-model, #dislike-button");
const readyForMutation = shortsControlSurfaceIsReadyForMutation(buttons, currentVideoId, {
allowUnhydratedFallback,
isHydrated: isHydrated && actionBarHasHydratedData(buttons),
isStable,
});
if (nativeDislike) { if (nativeDislike) {
removeSyntheticShortsDislike(syntheticDislike); if (readyForMutation) syntheticDislikes.forEach(removeSyntheticShortsDislike);
return;
}
if (!readyForMutation) {
return; return;
} }
if (syntheticDislike) { if (syntheticDislike) {
const videoId = getVideoId(); syntheticDislikes.slice(1).forEach(removeSyntheticShortsDislike);
const videoId = currentVideoId;
if (videoId && syntheticDislike.getAttribute("data-ryd-video-id") !== videoId) { if (videoId && syntheticDislike.getAttribute("data-ryd-video-id") !== videoId) {
syntheticDislike.setAttribute("data-ryd-video-id", videoId); syntheticDislike.setAttribute("data-ryd-video-id", videoId);
setSyntheticShortsPressed(false, syntheticDislike); setSyntheticShortsPressed(false, syntheticDislike);
@@ -715,9 +774,7 @@ function ensureSyntheticShortsDislikeButton(buttons) {
likeButton.insertAdjacentElement("afterend", ownedDislike); likeButton.insertAdjacentElement("afterend", ownedDislike);
} }
function getDislikeButton() { function getDislikeButton(buttons = getButtons()) {
const buttons = getButtons();
ensureSyntheticShortsDislikeButton(buttons);
if (buttons?.tagName === "REEL-ACTION-BAR-VIEW-MODEL") { if (buttons?.tagName === "REEL-ACTION-BAR-VIEW-MODEL") {
return ( return (
buttons.querySelector("dislike-button-view-model, #dislike-button") ?? buttons.querySelector("dislike-button-view-model, #dislike-button") ??
@@ -746,8 +803,7 @@ function getDislikeButton() {
} }
} }
function getLikeButton() { function getLikeButton(buttons = getButtons()) {
const buttons = getButtons();
const firstButton = buttons?.children?.[0]; const firstButton = buttons?.children?.[0];
if (!firstButton) { if (!firstButton) {
return null; return null;
@@ -768,18 +824,27 @@ function getLikeTextContainer() {
); );
} }
function getDislikeTextContainer() { function getDislikeTextContainer(dislikeButton = getDislikeButton()) {
const dislikeButton = getDislikeButton(); if (!dislikeButton) {
return null;
}
let result = let result =
dislikeButton?.querySelector("#text") ?? dislikeButton.querySelector("#text") ??
dislikeButton?.getElementsByTagName("yt-formatted-string")[0] ?? dislikeButton.getElementsByTagName("yt-formatted-string")[0] ??
dislikeButton?.querySelector("span[role='text']"); dislikeButton.querySelector("span[role='text']");
if (result === null) { if (result == null) {
let textSpan = document.createElement("span"); const activationTarget = getActivationTarget(dislikeButton);
if (!activationTarget?.matches("button, tp-yt-paper-button#button")) {
return null;
}
const textSpan = document.createElement("span");
textSpan.id = "text"; textSpan.id = "text";
textSpan.setAttribute("role", "text");
textSpan.style.marginLeft = "6px"; textSpan.style.marginLeft = "6px";
dislikeButton?.querySelector("button").appendChild(textSpan); activationTarget.appendChild(textSpan);
if (dislikeButton) dislikeButton.querySelector("button").style.width = "auto"; activationTarget.style.width = "auto";
result = textSpan; result = textSpan;
} }
return result; return result;
@@ -834,6 +899,9 @@ function createObserver(options, callback) {
observerWrapper.disconnect = function () { observerWrapper.disconnect = function () {
this.observer.disconnect(); this.observer.disconnect();
}; };
observerWrapper.takeRecords = function () {
return this.observer.takeRecords();
};
return observerWrapper; return observerWrapper;
} }
@@ -1293,11 +1361,7 @@ function removeWatchRateBarArtifacts(buttons) {
} }
} }
function clearStaleWatchPresentation(buttons, dislikeButton, videoId) { function clearWatchPresentation(buttons, dislikeButton) {
if (isMobile || isShorts() || !initializedVideoId || initializedVideoId === videoId) {
return;
}
removeWatchRateBarArtifacts(buttons); removeWatchRateBarArtifacts(buttons);
const dislikeText = const dislikeText =
dislikeButton?.querySelector("#text") ?? dislikeButton?.querySelector("#text") ??
@@ -1308,6 +1372,14 @@ function clearStaleWatchPresentation(buttons, dislikeButton, videoId) {
} }
} }
function clearStaleWatchPresentation(buttons, dislikeButton, videoId) {
if (isMobile || isShorts() || !initializedVideoId || initializedVideoId === videoId) {
return;
}
clearWatchPresentation(buttons, dislikeButton);
}
function canRepairWatchRateBar(buttons, videoId) { function canRepairWatchRateBar(buttons, videoId) {
if ( if (
isMobile || isMobile ||
@@ -1465,8 +1537,14 @@ function setState() {
} }
function updateDOMDislikes() { function updateDOMDislikes() {
const videoId = getVideoId();
if (!videoId || !countStateLoaded || countStateVideoId !== videoId) {
return false;
}
setDislikes(numberFormat(dislikesvalue)); setDislikes(numberFormat(dislikesvalue));
createRateBar(likesvalue, dislikesvalue); createRateBar(likesvalue, dislikesvalue);
return true;
} }
function reportVoteFailure(error) { function reportVoteFailure(error) {
@@ -1651,6 +1729,49 @@ function dislikeClicked(event) {
handleVoteActivation(DISLIKE_ACTION); handleVoteActivation(DISLIKE_ACTION);
} }
function handleDelegatedVoteClick(event) {
const buttons = getButtons();
const likeButton = getLikeButton(buttons);
const dislikeButton = getDislikeButton(buttons);
const activation = resolveDelegatedVoteActivation({
buttons,
dislikeButton,
event,
getActivationTarget,
likeButton,
});
if (!activation) {
return;
}
const videoId = getVideoId();
if (!videoId) {
return;
}
const targetHydration = hydratingShortsActivationTargets.get(activation.activationTarget);
const hydration = targetHydration?.videoId === videoId ? targetHydration : null;
if (targetHydration && !hydration) {
hydratingShortsActivationTargets.delete(activation.activationTarget);
}
const targetIsBoundToCurrentVideo = boundActivationVideoIds.get(activation.activationTarget) === videoId;
const currentCountStateIsLoaded = countStateLoaded && countStateVideoId === videoId;
const isUnboundCurrentShortsActivation = isShorts() && !hydration && !targetIsBoundToCurrentVideo;
if (!hydration && !targetIsBoundToCurrentVideo && !currentCountStateIsLoaded && !isUnboundCurrentShortsActivation) {
return;
}
if (isUnboundCurrentShortsActivation) {
previousState = getState();
}
boundActivationVideoIds.set(activation.activationTarget, videoId);
const delegatedEvent = { currentTarget: activation.activationTarget };
if (activation.action === LIKE_ACTION) {
likeClicked(delegatedEvent);
} else {
dislikeClicked(delegatedEvent);
}
}
function refreshDislikesForBoundControl(event) { function refreshDislikesForBoundControl(event) {
if (boundActivationVideoIds.get(event.currentTarget) === getVideoId()) { if (boundActivationVideoIds.get(event.currentTarget) === getVideoId()) {
updateDOMDislikes(); updateDOMDislikes();
@@ -1862,9 +1983,14 @@ function shortsControlsNeedInitialization() {
const buttons = getButtons(); const buttons = getButtons();
const likeButton = getLikeButton(); const likeButton = getLikeButton();
const dislikeButton = getDislikeButton(); const dislikeButton = getDislikeButton();
const syntheticDislikes = buttons?.querySelectorAll?.(SYNTHETIC_SHORTS_DISLIKE_SELECTOR).length ?? 0;
const hasNativeDislike = Boolean(buttons?.querySelector?.("dislike-button-view-model, #dislike-button"));
if (!buttons || !likeButton || !dislikeButton) { if (!buttons || !likeButton || !dislikeButton) {
return true; return true;
} }
if ((hasNativeDislike && syntheticDislikes > 0) || (!hasNativeDislike && syntheticDislikes !== 1)) {
return true;
}
if ( if (
initializedVideoId !== videoId || initializedVideoId !== videoId ||
initializedLikeButton !== likeButton || initializedLikeButton !== likeButton ||
@@ -2016,12 +2142,7 @@ function bindVoteButtonListeners(likeButton, dislikeButton, { enableSynthetic =
const videoId = getVideoId(); const videoId = getVideoId();
boundActivationVideoIds.set(likeActivationTarget, videoId); boundActivationVideoIds.set(likeActivationTarget, videoId);
boundActivationVideoIds.set(dislikeActivationTarget, videoId); boundActivationVideoIds.set(dislikeActivationTarget, videoId);
if (!boundLikeButtons.has(likeActivationTarget)) {
likeActivationTarget.addEventListener("click", likeClicked);
boundLikeButtons.add(likeActivationTarget);
}
if (!boundDislikeButtons.has(dislikeActivationTarget)) { if (!boundDislikeButtons.has(dislikeActivationTarget)) {
dislikeActivationTarget.addEventListener("click", dislikeClicked);
dislikeActivationTarget.addEventListener("focusin", refreshDislikesForBoundControl); dislikeActivationTarget.addEventListener("focusin", refreshDislikesForBoundControl);
dislikeActivationTarget.addEventListener("focusout", refreshDislikesForBoundControl); dislikeActivationTarget.addEventListener("focusout", refreshDislikesForBoundControl);
boundDislikeButtons.add(dislikeActivationTarget); boundDislikeButtons.add(dislikeActivationTarget);
@@ -2032,6 +2153,106 @@ function bindVoteButtonListeners(likeButton, dislikeButton, { enableSynthetic =
} }
} }
function getStableDesktopShortsControls(videoId, generation) {
const buttons = getButtons();
const inventory = captureShortsNativeControlInventory(buttons);
const isHydrated = actionBarHasHydratedData(buttons);
if (
!buttons ||
!inventory ||
(!isHydrated && !shortsNativeControlInventoryIsReadyForFallback(inventory)) ||
!shortsControlSurfaceIsReadyForMutation(buttons, videoId, {
allowUnhydratedFallback: !isHydrated,
isHydrated,
isStable: true,
})
) {
pendingShortsMutationSurface = null;
return null;
}
const signature = getShortsControlSurfaceSignature(buttons);
const now = performance.now();
if (
pendingShortsMutationSurface?.buttons !== buttons ||
pendingShortsMutationSurface.generation !== generation ||
pendingShortsMutationSurface.videoId !== videoId ||
pendingShortsMutationSurface.signature !== signature ||
!shortsNativeControlInventoryMatches(pendingShortsMutationSurface.inventory, inventory)
) {
pendingShortsMutationSurface = {
buttons,
generation,
inventory,
observedAt: now,
signature,
unhydratedObservedAt: isHydrated ? null : now,
videoId,
};
return null;
}
if (isHydrated) {
pendingShortsMutationSurface.unhydratedObservedAt = null;
} else if (pendingShortsMutationSurface.unhydratedObservedAt == null) {
pendingShortsMutationSurface.unhydratedObservedAt = now;
}
const stabilityObservedAt = isHydrated
? pendingShortsMutationSurface.observedAt
: pendingShortsMutationSurface.unhydratedObservedAt;
if (now - stabilityObservedAt < getShortsControlSurfaceStabilityMs(isHydrated)) {
return null;
}
const allowUnhydratedFallback = !isHydrated;
const preMutationButtons = getButtons();
const preMutationInventory = captureShortsNativeControlInventory(preMutationButtons);
const preMutationIsHydrated = actionBarHasHydratedData(preMutationButtons);
const preMutationAllowsFallback = !preMutationIsHydrated && allowUnhydratedFallback;
if (
preMutationButtons !== buttons ||
!shortsNativeControlInventoryMatches(inventory, preMutationInventory) ||
(preMutationAllowsFallback && !shortsNativeControlInventoryIsReadyForFallback(preMutationInventory)) ||
getShortsControlSurfaceSignature(preMutationButtons) !== signature ||
!shortsControlSurfaceIsReadyForMutation(preMutationButtons, videoId, {
allowUnhydratedFallback: preMutationAllowsFallback,
isHydrated: preMutationIsHydrated,
isStable: true,
})
) {
pendingShortsMutationSurface = null;
return null;
}
ensureSyntheticShortsDislikeButton(preMutationButtons, {
allowUnhydratedFallback: preMutationAllowsFallback,
currentVideoId: videoId,
isHydrated: preMutationIsHydrated,
isStable: true,
});
const confirmedButtons = getButtons();
const confirmedInventory = captureShortsNativeControlInventory(confirmedButtons);
const confirmedIsHydrated = actionBarHasHydratedData(confirmedButtons);
if (
confirmedButtons !== buttons ||
!shortsNativeControlInventoryMatches(inventory, confirmedInventory) ||
(preMutationAllowsFallback && !shortsNativeControlInventoryIsReadyForFallback(confirmedInventory)) ||
getShortsControlSurfaceSignature(confirmedButtons) !== signature ||
!shortsControlSurfaceIsReadyForMutation(confirmedButtons, videoId, {
allowUnhydratedFallback: !confirmedIsHydrated && preMutationAllowsFallback,
isHydrated: confirmedIsHydrated,
isStable: true,
})
) {
pendingShortsMutationSurface = null;
return null;
}
pendingShortsMutationSurface = null;
return confirmedButtons;
}
async function initializeCurrentButtons(generation) { async function initializeCurrentButtons(generation) {
const videoId = getVideoId(); const videoId = getVideoId();
if (!videoId) { if (!videoId) {
@@ -2045,15 +2266,31 @@ async function initializeCurrentButtons(generation) {
return false; return false;
} }
const buttons = getButtons(); let buttons = getButtons();
const likeButton = getLikeButton(); const needsStableShortsControls =
const dislikeButton = getDislikeButton(); isShorts() &&
!isMobile &&
buttons?.tagName === "REEL-ACTION-BAR-VIEW-MODEL" &&
(!buttons.querySelector("dislike-button-view-model, #dislike-button") ||
buttons.querySelector(SYNTHETIC_SHORTS_DISLIKE_SELECTOR) ||
!actionBarHasHydratedData(buttons));
if (needsStableShortsControls) {
buttons = getStableDesktopShortsControls(videoId, generation);
if (!buttons) {
return false;
}
}
const likeButton = getLikeButton(buttons);
const dislikeButton = getDislikeButton(buttons);
if (!buttons || !likeButton || !dislikeButton) { if (!buttons || !likeButton || !dislikeButton) {
return false; return false;
} }
if (!isShorts() && !watchControlsAreReadyForVideo(buttons, likeButton, dislikeButton, videoId)) { if (!isShorts() && !watchControlsAreReadyForVideo(buttons, likeButton, dislikeButton, videoId)) {
return false; return false;
} }
if (!isShorts() && !getDislikeTextContainer(dislikeButton)) {
return false;
}
observeShortsLifecycle(buttons); observeShortsLifecycle(buttons);
observeWatchRateBar(buttons, videoId); observeWatchRateBar(buttons, videoId);
@@ -2238,7 +2475,13 @@ function checkPageLifecycle() {
} }
function handleNavigateStart() { function handleNavigateStart() {
pendingShortsMutationSurface = null;
disconnectWatchRateBarObserver(); disconnectWatchRateBarObserver();
if (smartimationObserver) {
smartimationObserver.takeRecords();
smartimationObserver.disconnect();
smartimationObserver.container = null;
}
clearPendingWatchNavigationBoundary(); clearPendingWatchNavigationBoundary();
clearPendingWatchControlObservers(); clearPendingWatchControlObservers();
if (isShorts()) { if (isShorts()) {
@@ -2266,6 +2509,11 @@ function handleNavigateStart() {
return; return;
} }
activeCountRequest = null;
countStateLoaded = false;
countStateEpoch += 1;
clearWatchPresentation(buttons, dislikeButton);
const boundary = { const boundary = {
buttons, buttons,
completedVideoId: null, completedVideoId: null,
@@ -2317,6 +2565,7 @@ function handleNavigateFinish(event) {
(function () { (function () {
"use strict"; "use strict";
void voteClient.ensureRegistered().catch(reportVoteFailure); void voteClient.ensureRegistered().catch(reportVoteFailure);
document.addEventListener("click", handleDelegatedVoteClick, true);
window.addEventListener("yt-navigate-start", handleNavigateStart, true); window.addEventListener("yt-navigate-start", handleNavigateStart, true);
window.addEventListener("yt-navigate-finish", handleNavigateFinish, true); window.addEventListener("yt-navigate-finish", handleNavigateFinish, true);
window.addEventListener("popstate", checkPageLifecycle, true); window.addEventListener("popstate", checkPageLifecycle, true);
@@ -4,9 +4,26 @@ const path = require("path");
const vm = require("vm"); const vm = require("vm");
const webpack = require("webpack"); const webpack = require("webpack");
const createUserscriptConfig = require("../../webpack.userscript.config"); const createUserscriptConfig = require("../../webpack.userscript.config");
const {
USERSCRIPT_BUILD_RECEIPT_RELATIVE_PATH,
USERSCRIPT_LIVE_BUILD_RECEIPT_RELATIVE_PATH,
} = require("../../userscript-build-receipt");
const userscriptMeta = require("./userscript.meta"); const userscriptMeta = require("./userscript.meta");
const ARTIFACT_PATH = path.join(__dirname, "Return Youtube Dislike.user.js"); const ARTIFACT_PATH = path.join(__dirname, "Return Youtube Dislike.user.js");
const REPOSITORY_ROOT = path.resolve(__dirname, "../..");
function snapshotFile(filePath) {
return fs.existsSync(filePath) ? fs.readFileSync(filePath) : null;
}
function expectFileUnchanged(filePath, before) {
if (before === null) {
expect(fs.existsSync(filePath)).toBe(false);
return;
}
expect(fs.readFileSync(filePath)).toEqual(before);
}
function compile(config) { function compile(config) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@@ -51,6 +68,7 @@ describe("generated userscript artifact", () => {
it("is a standalone syntactically valid script with editable user options", () => { it("is a standalone syntactically valid script with editable user options", () => {
expect(() => new vm.Script(artifact, { filename: ARTIFACT_PATH })).not.toThrow(); expect(() => new vm.Script(artifact, { filename: ARTIFACT_PATH })).not.toThrow();
expect(artifact).not.toMatch(/^\s*(?:import|export)\s/m); expect(artifact).not.toMatch(/^\s*(?:import|export)\s/m);
expect(artifact).not.toMatch(/[ \t]+(?=\r?\n|$)/);
expect(artifact).toContain("BEGIN USER OPTIONS"); expect(artifact).toContain("BEGIN USER OPTIONS");
expect(artifact).toContain("disableVoteSubmission: false"); expect(artifact).toContain("disableVoteSubmission: false");
expect(artifact).not.toContain("data-ryd-userscript-version"); expect(artifact).not.toContain("data-ryd-userscript-version");
@@ -58,6 +76,8 @@ describe("generated userscript artifact", () => {
it("builds an unpublished live-test artifact with a runtime marker and no update URLs", async () => { 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-")); const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "ryd-userscript-live-build-"));
const receiptPath = path.join(REPOSITORY_ROOT, USERSCRIPT_LIVE_BUILD_RECEIPT_RELATIVE_PATH);
const receiptBefore = snapshotFile(receiptPath);
try { try {
const config = createUserscriptConfig({ liveTest: "true" }, { mode: "production" }); const config = createUserscriptConfig({ liveTest: "true" }, { mode: "production" });
config.output = { ...config.output, path: temporaryDirectory }; config.output = { ...config.output, path: temporaryDirectory };
@@ -67,13 +87,17 @@ describe("generated userscript artifact", () => {
expect(liveArtifact).toContain("// @name Return YouTube Dislike [Live Test]"); expect(liveArtifact).toContain("// @name Return YouTube Dislike [Live Test]");
expect(liveArtifact).not.toContain("// @downloadURL"); expect(liveArtifact).not.toContain("// @downloadURL");
expect(liveArtifact).not.toContain("// @updateURL"); expect(liveArtifact).not.toContain("// @updateURL");
expect(liveArtifact).not.toMatch(/[ \t]+(?=\r?\n|$)/);
} finally { } finally {
expectFileUnchanged(receiptPath, receiptBefore);
fs.rmSync(temporaryDirectory, { recursive: true, force: true }); fs.rmSync(temporaryDirectory, { recursive: true, force: true });
} }
}); });
it("rebuilds deterministically", async () => { it("rebuilds deterministically", async () => {
const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "ryd-userscript-build-")); const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "ryd-userscript-build-"));
const receiptPath = path.join(REPOSITORY_ROOT, USERSCRIPT_BUILD_RECEIPT_RELATIVE_PATH);
const receiptBefore = snapshotFile(receiptPath);
try { try {
const config = createUserscriptConfig({}, { mode: "production" }); const config = createUserscriptConfig({}, { mode: "production" });
config.output = { ...config.output, path: temporaryDirectory }; config.output = { ...config.output, path: temporaryDirectory };
@@ -81,6 +105,7 @@ describe("generated userscript artifact", () => {
const rebuilt = fs.readFileSync(path.join(temporaryDirectory, config.output.filename), "utf8"); const rebuilt = fs.readFileSync(path.join(temporaryDirectory, config.output.filename), "utf8");
expect(rebuilt).toBe(artifact); expect(rebuilt).toBe(artifact);
} finally { } finally {
expectFileUnchanged(receiptPath, receiptBefore);
fs.rmSync(temporaryDirectory, { recursive: true, force: true }); fs.rmSync(temporaryDirectory, { recursive: true, force: true });
} }
}); });
@@ -83,6 +83,9 @@
"patreonLogin": { "patreonLogin": {
"message": "Patreon ilə daxil ol" "message": "Patreon ilə daxil ol"
}, },
"patreonPrivacyNote": {
"message": "Patreon üzvlük məlumatları premium funksiyalara giriş hüququnu yoxlamaq üçün istifadə olunur. Kart və ya bank məlumatlarınızı almırıq."
},
"githubLoggedOutMessage": { "githubLoggedOutMessage": {
"message": "Premium features available for GitHub contributors" "message": "Premium features available for GitHub contributors"
}, },
@@ -548,6 +551,21 @@
"changelog_support_contact": { "changelog_support_contact": {
"message": "İcmadan soruş" "message": "İcmadan soruş"
}, },
"hideClutterButtons": {
"message": "Hide clutter buttons"
},
"hideClutterButtonsHelpLabel": {
"message": "Show before and after examples"
},
"hideClutterButtonsDescription": {
"message": "Keep only Like and Dislike visible. Share, Save, Thanks, and other optional actions stay available in YouTube's three-dot More menu."
},
"hideClutterButtonsBefore": {
"message": "Before: all action buttons are visible"
},
"hideClutterButtonsAfter": {
"message": "After: only Like and Dislike stay visible"
},
"changelog_brand_logo_alt": { "changelog_brand_logo_alt": {
"message": "Return YouTube Dislike loqosu" "message": "Return YouTube Dislike loqosu"
} }
@@ -83,6 +83,9 @@
"patreonLogin": { "patreonLogin": {
"message": "Přihlásit se pomocí Patreonu" "message": "Přihlásit se pomocí Patreonu"
}, },
"patreonPrivacyNote": {
"message": "Informace o členství na Patreonu slouží k ověření přístupu k prémiovým funkcím. Údaje o vaší kartě ani bankovním účtu nedostáváme."
},
"githubLoggedOutMessage": { "githubLoggedOutMessage": {
"message": "Premium features available for GitHub contributors" "message": "Premium features available for GitHub contributors"
}, },
@@ -548,6 +551,21 @@
"changelog_support_contact": { "changelog_support_contact": {
"message": "Zeptejte se komunity" "message": "Zeptejte se komunity"
}, },
"hideClutterButtons": {
"message": "Hide clutter buttons"
},
"hideClutterButtonsHelpLabel": {
"message": "Show before and after examples"
},
"hideClutterButtonsDescription": {
"message": "Keep only Like and Dislike visible. Share, Save, Thanks, and other optional actions stay available in YouTube's three-dot More menu."
},
"hideClutterButtonsBefore": {
"message": "Before: all action buttons are visible"
},
"hideClutterButtonsAfter": {
"message": "After: only Like and Dislike stay visible"
},
"changelog_brand_logo_alt": { "changelog_brand_logo_alt": {
"message": "Logo Return YouTube Dislike" "message": "Logo Return YouTube Dislike"
} }
@@ -83,6 +83,9 @@
"patreonLogin": { "patreonLogin": {
"message": "Mit Patreon anmelden" "message": "Mit Patreon anmelden"
}, },
"patreonPrivacyNote": {
"message": "Informationen zu deiner Patreon-Mitgliedschaft werden verwendet, um deinen Premium-Zugang zu überprüfen. Wir erhalten keine Karten- oder Bankdaten von dir."
},
"githubLoggedOutMessage": { "githubLoggedOutMessage": {
"message": "Premium features available for GitHub contributors" "message": "Premium features available for GitHub contributors"
}, },
@@ -548,6 +551,21 @@
"changelog_support_contact": { "changelog_support_contact": {
"message": "Frag die Community" "message": "Frag die Community"
}, },
"hideClutterButtons": {
"message": "Hide clutter buttons"
},
"hideClutterButtonsHelpLabel": {
"message": "Show before and after examples"
},
"hideClutterButtonsDescription": {
"message": "Keep only Like and Dislike visible. Share, Save, Thanks, and other optional actions stay available in YouTube's three-dot More menu."
},
"hideClutterButtonsBefore": {
"message": "Before: all action buttons are visible"
},
"hideClutterButtonsAfter": {
"message": "After: only Like and Dislike stay visible"
},
"changelog_brand_logo_alt": { "changelog_brand_logo_alt": {
"message": "Return YouTube Dislike-Logo" "message": "Return YouTube Dislike-Logo"
} }
@@ -83,6 +83,9 @@
"patreonLogin": { "patreonLogin": {
"message": "Σύνδεση με Patreon" "message": "Σύνδεση με Patreon"
}, },
"patreonPrivacyNote": {
"message": "Οι πληροφορίες συνδρομής στο Patreon χρησιμοποιούνται για την επαλήθευση της πρόσβασης στις premium λειτουργίες. Δεν λαμβάνουμε στοιχεία της κάρτας ή του τραπεζικού σας λογαριασμού."
},
"githubLoggedOutMessage": { "githubLoggedOutMessage": {
"message": "Premium features available for GitHub contributors" "message": "Premium features available for GitHub contributors"
}, },
@@ -548,6 +551,21 @@
"changelog_support_contact": { "changelog_support_contact": {
"message": "Ρωτήστε την κοινότητα" "message": "Ρωτήστε την κοινότητα"
}, },
"hideClutterButtons": {
"message": "Hide clutter buttons"
},
"hideClutterButtonsHelpLabel": {
"message": "Show before and after examples"
},
"hideClutterButtonsDescription": {
"message": "Keep only Like and Dislike visible. Share, Save, Thanks, and other optional actions stay available in YouTube's three-dot More menu."
},
"hideClutterButtonsBefore": {
"message": "Before: all action buttons are visible"
},
"hideClutterButtonsAfter": {
"message": "After: only Like and Dislike stay visible"
},
"changelog_brand_logo_alt": { "changelog_brand_logo_alt": {
"message": "Λογότυπο Return YouTube Dislike" "message": "Λογότυπο Return YouTube Dislike"
} }
@@ -83,6 +83,9 @@
"patreonLogin": { "patreonLogin": {
"message": "Login with Patreon" "message": "Login with Patreon"
}, },
"patreonPrivacyNote": {
"message": "Patreon membership information is used to verify premium access. We do not receive your card or bank details."
},
"githubLoggedOutMessage": { "githubLoggedOutMessage": {
"message": "Premium features available for GitHub contributors" "message": "Premium features available for GitHub contributors"
}, },
@@ -95,6 +98,27 @@
"githubLoginCompleteFailed": { "githubLoginCompleteFailed": {
"message": "Failed to complete GitHub login. Please try again." "message": "Failed to complete GitHub login. Please try again."
}, },
"githubLoginUnavailable": {
"message": "GitHub contributor sign-in is currently unavailable. The service needs an update or configuration change."
},
"githubLoginNotContributor": {
"message": "This GitHub account is not listed as a contributor to Return YouTube Dislike. Sign in with a contributing account. Recent contributions may take a few hours to appear."
},
"githubLoginBrowserUnavailable": {
"message": "GitHub sign-in is not configured for this browser's callback address. Please contact support."
},
"githubLoginExpired": {
"message": "This GitHub sign-in attempt expired or could not be verified. Please start a new sign-in."
},
"githubLoginUpdateRequired": {
"message": "Please update the extension to use GitHub contributor sign-in."
},
"githubLoginRateLimited": {
"message": "Too many sign-in attempts. Please wait a minute before trying again."
},
"githubLoginDenied": {
"message": "GitHub authorization was declined. Contributor sign-in requires authorization."
},
"patreonLoginCompleteFailed": { "patreonLoginCompleteFailed": {
"message": "Failed to complete login. Please try again." "message": "Failed to complete login. Please try again."
}, },
@@ -548,6 +572,21 @@
"changelog_support_contact": { "changelog_support_contact": {
"message": "Ask the community" "message": "Ask the community"
}, },
"hideClutterButtons": {
"message": "Hide clutter buttons"
},
"hideClutterButtonsHelpLabel": {
"message": "Show before and after examples"
},
"hideClutterButtonsDescription": {
"message": "Keep only Like and Dislike visible. Share, Save, Thanks, and other optional actions stay available in YouTube's three-dot More menu."
},
"hideClutterButtonsBefore": {
"message": "Before: all action buttons are visible"
},
"hideClutterButtonsAfter": {
"message": "After: only Like and Dislike stay visible"
},
"changelog_brand_logo_alt": { "changelog_brand_logo_alt": {
"message": "Return YouTube Dislike logo" "message": "Return YouTube Dislike logo"
} }
@@ -83,6 +83,9 @@
"patreonLogin": { "patreonLogin": {
"message": "Iniciar sesión con Patreon" "message": "Iniciar sesión con Patreon"
}, },
"patreonPrivacyNote": {
"message": "La información de tu membresía de Patreon se usa para verificar el acceso premium. No recibimos los datos de tu tarjeta ni de tu cuenta bancaria."
},
"githubLoggedOutMessage": { "githubLoggedOutMessage": {
"message": "Premium features available for GitHub contributors" "message": "Premium features available for GitHub contributors"
}, },
@@ -548,6 +551,21 @@
"changelog_support_contact": { "changelog_support_contact": {
"message": "Pregunta a la comunidad" "message": "Pregunta a la comunidad"
}, },
"hideClutterButtons": {
"message": "Hide clutter buttons"
},
"hideClutterButtonsHelpLabel": {
"message": "Show before and after examples"
},
"hideClutterButtonsDescription": {
"message": "Keep only Like and Dislike visible. Share, Save, Thanks, and other optional actions stay available in YouTube's three-dot More menu."
},
"hideClutterButtonsBefore": {
"message": "Before: all action buttons are visible"
},
"hideClutterButtonsAfter": {
"message": "After: only Like and Dislike stay visible"
},
"changelog_brand_logo_alt": { "changelog_brand_logo_alt": {
"message": "Logotipo de Return YouTube Dislike" "message": "Logotipo de Return YouTube Dislike"
} }
@@ -83,6 +83,9 @@
"patreonLogin": { "patreonLogin": {
"message": "Se connecter avec Patreon" "message": "Se connecter avec Patreon"
}, },
"patreonPrivacyNote": {
"message": "Les informations sur votre abonnement Patreon servent à vérifier votre accès premium. Nous ne recevons pas les données de votre carte ni vos coordonnées bancaires."
},
"githubLoggedOutMessage": { "githubLoggedOutMessage": {
"message": "Premium features available for GitHub contributors" "message": "Premium features available for GitHub contributors"
}, },
@@ -548,6 +551,21 @@
"changelog_support_contact": { "changelog_support_contact": {
"message": "Demander à la communauté" "message": "Demander à la communauté"
}, },
"hideClutterButtons": {
"message": "Hide clutter buttons"
},
"hideClutterButtonsHelpLabel": {
"message": "Show before and after examples"
},
"hideClutterButtonsDescription": {
"message": "Keep only Like and Dislike visible. Share, Save, Thanks, and other optional actions stay available in YouTube's three-dot More menu."
},
"hideClutterButtonsBefore": {
"message": "Before: all action buttons are visible"
},
"hideClutterButtonsAfter": {
"message": "After: only Like and Dislike stay visible"
},
"changelog_brand_logo_alt": { "changelog_brand_logo_alt": {
"message": "Logo de Return YouTube Dislike" "message": "Logo de Return YouTube Dislike"
} }
@@ -83,6 +83,9 @@
"patreonLogin": { "patreonLogin": {
"message": "Bejelentkezés Patreonnal" "message": "Bejelentkezés Patreonnal"
}, },
"patreonPrivacyNote": {
"message": "A Patreon-tagság adatait a prémium hozzáférés ellenőrzésére használjuk. Nem kapjuk meg a kártya- vagy banki adataidat."
},
"githubLoggedOutMessage": { "githubLoggedOutMessage": {
"message": "Premium features available for GitHub contributors" "message": "Premium features available for GitHub contributors"
}, },
@@ -548,6 +551,21 @@
"changelog_support_contact": { "changelog_support_contact": {
"message": "Kérdezd meg a közösséget" "message": "Kérdezd meg a közösséget"
}, },
"hideClutterButtons": {
"message": "Hide clutter buttons"
},
"hideClutterButtonsHelpLabel": {
"message": "Show before and after examples"
},
"hideClutterButtonsDescription": {
"message": "Keep only Like and Dislike visible. Share, Save, Thanks, and other optional actions stay available in YouTube's three-dot More menu."
},
"hideClutterButtonsBefore": {
"message": "Before: all action buttons are visible"
},
"hideClutterButtonsAfter": {
"message": "After: only Like and Dislike stay visible"
},
"changelog_brand_logo_alt": { "changelog_brand_logo_alt": {
"message": "Return YouTube Dislike logója" "message": "Return YouTube Dislike logója"
} }
@@ -83,6 +83,9 @@
"patreonLogin": { "patreonLogin": {
"message": "Masuk dengan Patreon" "message": "Masuk dengan Patreon"
}, },
"patreonPrivacyNote": {
"message": "Informasi keanggotaan Patreon digunakan untuk memverifikasi akses premium. Kami tidak menerima detail kartu atau rekening bank Anda."
},
"githubLoggedOutMessage": { "githubLoggedOutMessage": {
"message": "Premium features available for GitHub contributors" "message": "Premium features available for GitHub contributors"
}, },
@@ -548,6 +551,21 @@
"changelog_support_contact": { "changelog_support_contact": {
"message": "Tanyakan pada komunitas" "message": "Tanyakan pada komunitas"
}, },
"hideClutterButtons": {
"message": "Hide clutter buttons"
},
"hideClutterButtonsHelpLabel": {
"message": "Show before and after examples"
},
"hideClutterButtonsDescription": {
"message": "Keep only Like and Dislike visible. Share, Save, Thanks, and other optional actions stay available in YouTube's three-dot More menu."
},
"hideClutterButtonsBefore": {
"message": "Before: all action buttons are visible"
},
"hideClutterButtonsAfter": {
"message": "After: only Like and Dislike stay visible"
},
"changelog_brand_logo_alt": { "changelog_brand_logo_alt": {
"message": "Logo Return YouTube Dislike" "message": "Logo Return YouTube Dislike"
} }
@@ -83,6 +83,9 @@
"patreonLogin": { "patreonLogin": {
"message": "Accedi con Patreon" "message": "Accedi con Patreon"
}, },
"patreonPrivacyNote": {
"message": "Le informazioni sull'abbonamento a Patreon vengono usate per verificare l'accesso premium. Non riceviamo i dati della tua carta né quelli del tuo conto bancario."
},
"githubLoggedOutMessage": { "githubLoggedOutMessage": {
"message": "Premium features available for GitHub contributors" "message": "Premium features available for GitHub contributors"
}, },
@@ -548,6 +551,21 @@
"changelog_support_contact": { "changelog_support_contact": {
"message": "Chiedi alla community" "message": "Chiedi alla community"
}, },
"hideClutterButtons": {
"message": "Hide clutter buttons"
},
"hideClutterButtonsHelpLabel": {
"message": "Show before and after examples"
},
"hideClutterButtonsDescription": {
"message": "Keep only Like and Dislike visible. Share, Save, Thanks, and other optional actions stay available in YouTube's three-dot More menu."
},
"hideClutterButtonsBefore": {
"message": "Before: all action buttons are visible"
},
"hideClutterButtonsAfter": {
"message": "After: only Like and Dislike stay visible"
},
"changelog_brand_logo_alt": { "changelog_brand_logo_alt": {
"message": "Logo Return YouTube Dislike" "message": "Logo Return YouTube Dislike"
} }
@@ -83,6 +83,9 @@
"patreonLogin": { "patreonLogin": {
"message": "Patreonでログイン" "message": "Patreonでログイン"
}, },
"patreonPrivacyNote": {
"message": "Patreonのメンバーシップ情報は、プレミアム機能の利用資格を確認するために使用されます。カード情報や銀行口座情報は受け取りません。"
},
"githubLoggedOutMessage": { "githubLoggedOutMessage": {
"message": "Premium features available for GitHub contributors" "message": "Premium features available for GitHub contributors"
}, },
@@ -548,6 +551,21 @@
"changelog_support_contact": { "changelog_support_contact": {
"message": "コミュニティに質問する" "message": "コミュニティに質問する"
}, },
"hideClutterButtons": {
"message": "Hide clutter buttons"
},
"hideClutterButtonsHelpLabel": {
"message": "Show before and after examples"
},
"hideClutterButtonsDescription": {
"message": "Keep only Like and Dislike visible. Share, Save, Thanks, and other optional actions stay available in YouTube's three-dot More menu."
},
"hideClutterButtonsBefore": {
"message": "Before: all action buttons are visible"
},
"hideClutterButtonsAfter": {
"message": "After: only Like and Dislike stay visible"
},
"changelog_brand_logo_alt": { "changelog_brand_logo_alt": {
"message": "Return YouTube Dislike のロゴ" "message": "Return YouTube Dislike のロゴ"
} }
@@ -83,6 +83,9 @@
"patreonLogin": { "patreonLogin": {
"message": "Patreon으로 로그인" "message": "Patreon으로 로그인"
}, },
"patreonPrivacyNote": {
"message": "Patreon 멤버십 정보는 프리미엄 이용 자격을 확인하는 데 사용됩니다. 카드 또는 은행 계좌 정보는 전달받지 않습니다."
},
"githubLoggedOutMessage": { "githubLoggedOutMessage": {
"message": "Premium features available for GitHub contributors" "message": "Premium features available for GitHub contributors"
}, },
@@ -548,6 +551,21 @@
"changelog_support_contact": { "changelog_support_contact": {
"message": "커뮤니티에 물어보세요" "message": "커뮤니티에 물어보세요"
}, },
"hideClutterButtons": {
"message": "Hide clutter buttons"
},
"hideClutterButtonsHelpLabel": {
"message": "Show before and after examples"
},
"hideClutterButtonsDescription": {
"message": "Keep only Like and Dislike visible. Share, Save, Thanks, and other optional actions stay available in YouTube's three-dot More menu."
},
"hideClutterButtonsBefore": {
"message": "Before: all action buttons are visible"
},
"hideClutterButtonsAfter": {
"message": "After: only Like and Dislike stay visible"
},
"changelog_brand_logo_alt": { "changelog_brand_logo_alt": {
"message": "Return YouTube Dislike 로고" "message": "Return YouTube Dislike 로고"
} }
@@ -83,6 +83,9 @@
"patreonLogin": { "patreonLogin": {
"message": "Inloggen met Patreon" "message": "Inloggen met Patreon"
}, },
"patreonPrivacyNote": {
"message": "Informatie over je Patreon-lidmaatschap wordt gebruikt om je premiumtoegang te verifiëren. We ontvangen geen kaart- of bankgegevens van je."
},
"githubLoggedOutMessage": { "githubLoggedOutMessage": {
"message": "Premium features available for GitHub contributors" "message": "Premium features available for GitHub contributors"
}, },
@@ -548,6 +551,21 @@
"changelog_support_contact": { "changelog_support_contact": {
"message": "Vraag het aan de gemeenschap" "message": "Vraag het aan de gemeenschap"
}, },
"hideClutterButtons": {
"message": "Hide clutter buttons"
},
"hideClutterButtonsHelpLabel": {
"message": "Show before and after examples"
},
"hideClutterButtonsDescription": {
"message": "Keep only Like and Dislike visible. Share, Save, Thanks, and other optional actions stay available in YouTube's three-dot More menu."
},
"hideClutterButtonsBefore": {
"message": "Before: all action buttons are visible"
},
"hideClutterButtonsAfter": {
"message": "After: only Like and Dislike stay visible"
},
"changelog_brand_logo_alt": { "changelog_brand_logo_alt": {
"message": "Return YouTube Dislike-logo" "message": "Return YouTube Dislike-logo"
} }
@@ -83,6 +83,9 @@
"patreonLogin": { "patreonLogin": {
"message": "Zaloguj się przez Patreon" "message": "Zaloguj się przez Patreon"
}, },
"patreonPrivacyNote": {
"message": "Informacje o członkostwie w Patreon służą do weryfikacji dostępu do funkcji premium. Nie otrzymujemy danych Twojej karty ani konta bankowego."
},
"githubLoggedOutMessage": { "githubLoggedOutMessage": {
"message": "Premium features available for GitHub contributors" "message": "Premium features available for GitHub contributors"
}, },
@@ -548,6 +551,21 @@
"changelog_support_contact": { "changelog_support_contact": {
"message": "Zapytaj społeczność" "message": "Zapytaj społeczność"
}, },
"hideClutterButtons": {
"message": "Hide clutter buttons"
},
"hideClutterButtonsHelpLabel": {
"message": "Show before and after examples"
},
"hideClutterButtonsDescription": {
"message": "Keep only Like and Dislike visible. Share, Save, Thanks, and other optional actions stay available in YouTube's three-dot More menu."
},
"hideClutterButtonsBefore": {
"message": "Before: all action buttons are visible"
},
"hideClutterButtonsAfter": {
"message": "After: only Like and Dislike stay visible"
},
"changelog_brand_logo_alt": { "changelog_brand_logo_alt": {
"message": "Logo Return YouTube Dislike" "message": "Logo Return YouTube Dislike"
} }
@@ -83,6 +83,9 @@
"patreonLogin": { "patreonLogin": {
"message": "Entrar com o Patreon" "message": "Entrar com o Patreon"
}, },
"patreonPrivacyNote": {
"message": "As informações da sua assinatura no Patreon são usadas para verificar o acesso premium. Não recebemos os dados do seu cartão nem da sua conta bancária."
},
"githubLoggedOutMessage": { "githubLoggedOutMessage": {
"message": "Premium features available for GitHub contributors" "message": "Premium features available for GitHub contributors"
}, },
@@ -548,6 +551,21 @@
"changelog_support_contact": { "changelog_support_contact": {
"message": "Pergunte à comunidade" "message": "Pergunte à comunidade"
}, },
"hideClutterButtons": {
"message": "Hide clutter buttons"
},
"hideClutterButtonsHelpLabel": {
"message": "Show before and after examples"
},
"hideClutterButtonsDescription": {
"message": "Keep only Like and Dislike visible. Share, Save, Thanks, and other optional actions stay available in YouTube's three-dot More menu."
},
"hideClutterButtonsBefore": {
"message": "Before: all action buttons are visible"
},
"hideClutterButtonsAfter": {
"message": "After: only Like and Dislike stay visible"
},
"changelog_brand_logo_alt": { "changelog_brand_logo_alt": {
"message": "Logo do Return YouTube Dislike" "message": "Logo do Return YouTube Dislike"
} }
@@ -83,6 +83,9 @@
"patreonLogin": { "patreonLogin": {
"message": "Войти через Patreon" "message": "Войти через Patreon"
}, },
"patreonPrivacyNote": {
"message": "Информация о подписке на Patreon используется для проверки доступа к премиум-функциям. Мы не получаем данные вашей карты или банковского счёта."
},
"githubLoggedOutMessage": { "githubLoggedOutMessage": {
"message": "Premium features available for GitHub contributors" "message": "Premium features available for GitHub contributors"
}, },
@@ -548,6 +551,21 @@
"changelog_support_contact": { "changelog_support_contact": {
"message": "Спросить сообщество" "message": "Спросить сообщество"
}, },
"hideClutterButtons": {
"message": "Hide clutter buttons"
},
"hideClutterButtonsHelpLabel": {
"message": "Show before and after examples"
},
"hideClutterButtonsDescription": {
"message": "Keep only Like and Dislike visible. Share, Save, Thanks, and other optional actions stay available in YouTube's three-dot More menu."
},
"hideClutterButtonsBefore": {
"message": "Before: all action buttons are visible"
},
"hideClutterButtonsAfter": {
"message": "After: only Like and Dislike stay visible"
},
"changelog_brand_logo_alt": { "changelog_brand_logo_alt": {
"message": "Логотип Return YouTube Dislike" "message": "Логотип Return YouTube Dislike"
} }
@@ -83,6 +83,9 @@
"patreonLogin": { "patreonLogin": {
"message": "Logga in med Patreon" "message": "Logga in med Patreon"
}, },
"patreonPrivacyNote": {
"message": "Information om ditt Patreon-medlemskap används för att verifiera din premiumåtkomst. Vi får inte dina kort- eller bankuppgifter."
},
"githubLoggedOutMessage": { "githubLoggedOutMessage": {
"message": "Premium features available for GitHub contributors" "message": "Premium features available for GitHub contributors"
}, },
@@ -548,6 +551,21 @@
"changelog_support_contact": { "changelog_support_contact": {
"message": "Fråga samhället" "message": "Fråga samhället"
}, },
"hideClutterButtons": {
"message": "Hide clutter buttons"
},
"hideClutterButtonsHelpLabel": {
"message": "Show before and after examples"
},
"hideClutterButtonsDescription": {
"message": "Keep only Like and Dislike visible. Share, Save, Thanks, and other optional actions stay available in YouTube's three-dot More menu."
},
"hideClutterButtonsBefore": {
"message": "Before: all action buttons are visible"
},
"hideClutterButtonsAfter": {
"message": "After: only Like and Dislike stay visible"
},
"changelog_brand_logo_alt": { "changelog_brand_logo_alt": {
"message": "Return YouTube Dislike-logotyp" "message": "Return YouTube Dislike-logotyp"
} }
@@ -83,6 +83,9 @@
"patreonLogin": { "patreonLogin": {
"message": "Patreon ile giriş yap" "message": "Patreon ile giriş yap"
}, },
"patreonPrivacyNote": {
"message": "Patreon üyelik bilgileri, premium erişimi doğrulamak için kullanılır. Kart veya banka bilgilerinizi almayız."
},
"githubLoggedOutMessage": { "githubLoggedOutMessage": {
"message": "Premium features available for GitHub contributors" "message": "Premium features available for GitHub contributors"
}, },
@@ -548,6 +551,21 @@
"changelog_support_contact": { "changelog_support_contact": {
"message": "Topluluğa sorun" "message": "Topluluğa sorun"
}, },
"hideClutterButtons": {
"message": "Hide clutter buttons"
},
"hideClutterButtonsHelpLabel": {
"message": "Show before and after examples"
},
"hideClutterButtonsDescription": {
"message": "Keep only Like and Dislike visible. Share, Save, Thanks, and other optional actions stay available in YouTube's three-dot More menu."
},
"hideClutterButtonsBefore": {
"message": "Before: all action buttons are visible"
},
"hideClutterButtonsAfter": {
"message": "After: only Like and Dislike stay visible"
},
"changelog_brand_logo_alt": { "changelog_brand_logo_alt": {
"message": "Return YouTube Dislike logosu" "message": "Return YouTube Dislike logosu"
} }
@@ -83,6 +83,9 @@
"patreonLogin": { "patreonLogin": {
"message": "Увійти через Patreon" "message": "Увійти через Patreon"
}, },
"patreonPrivacyNote": {
"message": "Інформація про підписку на Patreon використовується для перевірки доступу до преміум-функцій. Ми не отримуємо дані вашої картки або банківського рахунку."
},
"githubLoggedOutMessage": { "githubLoggedOutMessage": {
"message": "Premium features available for GitHub contributors" "message": "Premium features available for GitHub contributors"
}, },
@@ -548,6 +551,21 @@
"changelog_support_contact": { "changelog_support_contact": {
"message": "Запитайте у спільноти" "message": "Запитайте у спільноти"
}, },
"hideClutterButtons": {
"message": "Hide clutter buttons"
},
"hideClutterButtonsHelpLabel": {
"message": "Show before and after examples"
},
"hideClutterButtonsDescription": {
"message": "Keep only Like and Dislike visible. Share, Save, Thanks, and other optional actions stay available in YouTube's three-dot More menu."
},
"hideClutterButtonsBefore": {
"message": "Before: all action buttons are visible"
},
"hideClutterButtonsAfter": {
"message": "After: only Like and Dislike stay visible"
},
"changelog_brand_logo_alt": { "changelog_brand_logo_alt": {
"message": "Логотип Return YouTube Dislike" "message": "Логотип Return YouTube Dislike"
} }
@@ -83,6 +83,9 @@
"patreonLogin": { "patreonLogin": {
"message": "Đăng nhập bằng Patreon" "message": "Đăng nhập bằng Patreon"
}, },
"patreonPrivacyNote": {
"message": "Thông tin thành viên Patreon được dùng để xác minh quyền truy cập các tính năng cao cấp. Chúng tôi không nhận thông tin thẻ hoặc tài khoản ngân hàng của bạn."
},
"githubLoggedOutMessage": { "githubLoggedOutMessage": {
"message": "Premium features available for GitHub contributors" "message": "Premium features available for GitHub contributors"
}, },
@@ -548,6 +551,21 @@
"changelog_support_contact": { "changelog_support_contact": {
"message": "Hỏi cộng đồng" "message": "Hỏi cộng đồng"
}, },
"hideClutterButtons": {
"message": "Hide clutter buttons"
},
"hideClutterButtonsHelpLabel": {
"message": "Show before and after examples"
},
"hideClutterButtonsDescription": {
"message": "Keep only Like and Dislike visible. Share, Save, Thanks, and other optional actions stay available in YouTube's three-dot More menu."
},
"hideClutterButtonsBefore": {
"message": "Before: all action buttons are visible"
},
"hideClutterButtonsAfter": {
"message": "After: only Like and Dislike stay visible"
},
"changelog_brand_logo_alt": { "changelog_brand_logo_alt": {
"message": "Logo Return YouTube Dislike" "message": "Logo Return YouTube Dislike"
} }
@@ -83,6 +83,9 @@
"patreonLogin": { "patreonLogin": {
"message": "使用 Patreon 登录" "message": "使用 Patreon 登录"
}, },
"patreonPrivacyNote": {
"message": "Patreon 会员信息用于验证高级功能的访问权限。我们不会接收您的银行卡或银行账户信息。"
},
"githubLoggedOutMessage": { "githubLoggedOutMessage": {
"message": "Premium features available for GitHub contributors" "message": "Premium features available for GitHub contributors"
}, },
@@ -548,6 +551,21 @@
"changelog_support_contact": { "changelog_support_contact": {
"message": "询问社区" "message": "询问社区"
}, },
"hideClutterButtons": {
"message": "Hide clutter buttons"
},
"hideClutterButtonsHelpLabel": {
"message": "Show before and after examples"
},
"hideClutterButtonsDescription": {
"message": "Keep only Like and Dislike visible. Share, Save, Thanks, and other optional actions stay available in YouTube's three-dot More menu."
},
"hideClutterButtonsBefore": {
"message": "Before: all action buttons are visible"
},
"hideClutterButtonsAfter": {
"message": "After: only Like and Dislike stay visible"
},
"changelog_brand_logo_alt": { "changelog_brand_logo_alt": {
"message": "Return YouTube Dislike 标志" "message": "Return YouTube Dislike 标志"
} }
@@ -83,6 +83,9 @@
"patreonLogin": { "patreonLogin": {
"message": "使用 Patreon 登入" "message": "使用 Patreon 登入"
}, },
"patreonPrivacyNote": {
"message": "Patreon 會員資訊用於驗證進階功能的存取權限。我們不會接收您的卡片或銀行帳戶資料。"
},
"githubLoggedOutMessage": { "githubLoggedOutMessage": {
"message": "Premium features available for GitHub contributors" "message": "Premium features available for GitHub contributors"
}, },
@@ -548,6 +551,21 @@
"changelog_support_contact": { "changelog_support_contact": {
"message": "詢問社區" "message": "詢問社區"
}, },
"hideClutterButtons": {
"message": "Hide clutter buttons"
},
"hideClutterButtonsHelpLabel": {
"message": "Show before and after examples"
},
"hideClutterButtonsDescription": {
"message": "Keep only Like and Dislike visible. Share, Save, Thanks, and other optional actions stay available in YouTube's three-dot More menu."
},
"hideClutterButtonsBefore": {
"message": "Before: all action buttons are visible"
},
"hideClutterButtonsAfter": {
"message": "After: only Like and Dislike stay visible"
},
"changelog_brand_logo_alt": { "changelog_brand_logo_alt": {
"message": "Return YouTube Dislike 標誌" "message": "Return YouTube Dislike 標誌"
} }
@@ -4,12 +4,6 @@
<meta content="text/html; charset=utf-8" /> <meta content="text/html; charset=utf-8" />
<title title="__MSG_extensionName__">__MSG_extensionName__</title> <title title="__MSG_extensionName__">__MSG_extensionName__</title>
<link rel="stylesheet" href="/changelog/changelog.css" /> <link rel="stylesheet" href="/changelog/changelog.css" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Roboto:wght@100;300;400;500;700;900&display=swap"
rel="stylesheet"
/>
</head> </head>
<body> <body>
<div class="container"> <div class="container">
@@ -4,12 +4,6 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>__MSG_changelog_title__</title> <title>__MSG_changelog_title__</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="changelog.css" /> <link rel="stylesheet" href="changelog.css" />
</head> </head>
<body> <body>
+100 -15
View File
@@ -20,11 +20,7 @@
#ryd-bar-container { #ryd-bar-container {
background: #737373; background: #737373;
background: color-mix( background: color-mix(in srgb, var(--yt-spec-text-primary, #f1f1f1) 55%, var(--yt-spec-base-background, #0f0f0f) 45%);
in srgb,
var(--yt-spec-text-primary, #f1f1f1) 55%,
var(--yt-spec-base-background, #0f0f0f) 45%
);
border-radius: 2px; border-radius: 2px;
} }
@@ -47,6 +43,7 @@
.ryd-tooltip-new-design { .ryd-tooltip-new-design {
position: absolute; position: absolute;
bottom: -10px; bottom: -10px;
inset-inline-start: 0;
} }
.ryd-tooltip-bar-container { .ryd-tooltip-bar-container {
@@ -67,6 +64,67 @@ ytd-menu-renderer.ytd-watch-metadata {
position: relative !important; position: relative !important;
} }
/*
* YouTube can retain a desktop minimum width for the stacked Watch metadata
* row after the viewport becomes phone-sized. Its current 390px layout has
* been observed with a roughly 427px #top-row, which places the reaction group
* past the left edge even after every optional action has moved to More.
* Constrain and center each action-allocation wrapper so the native menu fitter
* measures the usable viewport width instead of that oversized ancestor.
*/
@media (max-width: 480px) {
:is(ytd-watch-flexy, ytd-watch-grid)[video-id] #top-row #actions,
:is(ytd-watch-flexy, ytd-watch-grid)[video-id] #top-row #actions-inner,
:is(ytd-watch-flexy, ytd-watch-grid)[video-id] #top-row ytd-menu-renderer.ytd-watch-metadata {
box-sizing: border-box;
margin-inline: auto !important;
max-width: calc(100vw - 24px) !important;
min-width: 0 !important;
width: 100% !important;
}
}
.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 svg {
display: block;
fill: currentColor;
height: 24px;
pointer-events: none;
width: 24px;
}
.ryd-synthetic-shorts-dislike .ytSpecButtonShapeNextIcon,
.ryd-synthetic-shorts-dislike .yt-spec-button-shape-next__icon {
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-premium-analytics { .ryd-premium-analytics {
background: var(--yt-spec-base-background, #202020); background: var(--yt-spec-base-background, #202020);
border: 1px solid rgba(255, 255, 255, 0.08); border: 1px solid rgba(255, 255, 255, 0.08);
@@ -77,7 +135,9 @@ ytd-menu-renderer.ytd-watch-metadata {
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.22); box-shadow: 0 12px 24px rgba(0, 0, 0, 0.22);
font-family: Roboto, Arial, sans-serif; font-family: Roboto, Arial, sans-serif;
position: relative; position: relative;
transition: box-shadow 0.2s ease, transform 0.2s ease; transition:
box-shadow 0.2s ease,
transform 0.2s ease;
} }
.ryd-premium-analytics::before { .ryd-premium-analytics::before {
@@ -116,7 +176,11 @@ ytd-menu-renderer.ytd-watch-metadata {
color: var(--yt-spec-text-primary, #ffffff); color: var(--yt-spec-text-primary, #ffffff);
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.18); box-shadow: 0 8px 20px rgba(0, 0, 0, 0.18);
font-family: Roboto, Arial, sans-serif; font-family: Roboto, Arial, sans-serif;
transition: box-shadow 0.2s ease, transform 0.2s ease, opacity 0.2s ease, padding 0.2s ease; transition:
box-shadow 0.2s ease,
transform 0.2s ease,
opacity 0.2s ease,
padding 0.2s ease;
position: relative; position: relative;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -184,7 +248,9 @@ ytd-menu-renderer.ytd-watch-metadata {
font-weight: 600; font-weight: 600;
font-size: 13px; font-size: 13px;
text-decoration: none; text-decoration: none;
transition: background 0.2s ease, transform 0.2s ease; transition:
background 0.2s ease,
transform 0.2s ease;
} }
.ryd-premium-tier-notice__cta:hover { .ryd-premium-tier-notice__cta:hover {
@@ -243,7 +309,9 @@ ytd-menu-renderer.ytd-watch-metadata {
background: rgba(148, 163, 184, 0.16); background: rgba(148, 163, 184, 0.16);
color: var(--yt-spec-text-secondary, #94a3b8); color: var(--yt-spec-text-secondary, #94a3b8);
cursor: pointer; cursor: pointer;
transition: background 0.2s ease, color 0.2s ease; transition:
background 0.2s ease,
color 0.2s ease;
align-self: center; align-self: center;
margin: 8px auto 0; margin: 8px auto 0;
order: 3; order: 3;
@@ -284,7 +352,9 @@ ytd-menu-renderer.ytd-watch-metadata {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
cursor: pointer; cursor: pointer;
transition: background 0.2s ease, color 0.2s ease; transition:
background 0.2s ease,
color 0.2s ease;
position: absolute; position: absolute;
top: 0; top: 0;
right: 0; right: 0;
@@ -361,7 +431,10 @@ ytd-menu-renderer.ytd-watch-metadata {
border-radius: 999px; border-radius: 999px;
padding: 8px 18px; padding: 8px 18px;
cursor: pointer; cursor: pointer;
transition: background 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease; transition:
background 0.2s ease,
transform 0.2s ease,
box-shadow 0.2s ease;
} }
.ryd-premium-teaser__cta:hover { .ryd-premium-teaser__cta:hover {
@@ -436,7 +509,10 @@ html[dark] .ryd-premium-teaser__toggle:focus-visible {
padding: 6px 12px; padding: 6px 12px;
border-radius: 999px; border-radius: 999px;
cursor: pointer; cursor: pointer;
transition: background 0.2s ease, color 0.2s ease, box-shadow 0.2s ease; transition:
background 0.2s ease,
color 0.2s ease,
box-shadow 0.2s ease;
} }
.ryd-analytics__ranges button.is-active, .ryd-analytics__ranges button.is-active,
@@ -522,7 +598,9 @@ html[dark] .ryd-analytics__window-hint {
flex-direction: column; flex-direction: column;
gap: 12px; gap: 12px;
position: relative; position: relative;
transition: box-shadow 0.2s ease, transform 0.2s ease; transition:
box-shadow 0.2s ease,
transform 0.2s ease;
} }
.ryd-analytics__section-header { .ryd-analytics__section-header {
@@ -547,7 +625,11 @@ html[dark] .ryd-analytics__window-hint {
padding: 4px 12px; padding: 4px 12px;
border-radius: 999px; border-radius: 999px;
cursor: pointer; cursor: pointer;
transition: background 0.2s ease, color 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease; transition:
background 0.2s ease,
color 0.2s ease,
box-shadow 0.2s ease,
border-color 0.2s ease;
} }
.ryd-analytics__section-expand.is-active, .ryd-analytics__section-expand.is-active,
@@ -624,7 +706,10 @@ html[dark] .ryd-analytics__section-expand:hover {
padding: 6px 14px; padding: 6px 14px;
border-radius: 999px; border-radius: 999px;
cursor: pointer; cursor: pointer;
transition: background 0.2s ease, color 0.2s ease, box-shadow 0.2s ease; transition:
background 0.2s ease,
color 0.2s ease,
box-shadow 0.2s ease;
} }
.ryd-analytics__map-reset:hover { .ryd-analytics__map-reset:hover {
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

+19 -34
View File
@@ -4,56 +4,41 @@
"default_locale": "en", "default_locale": "en",
"version": "__RYD_VERSION__", "version": "__RYD_VERSION__",
"manifest_version": 2, "manifest_version": 2,
"incognito": "not_allowed",
"background": { "background": {
"scripts": [ "scripts": ["ryd.background.js"]
"ryd.background.js"
]
}, },
"icons": { "icons": {
"48": "icons/icon48.png", "48": "icons/icon48.png",
"128": "icons/icon128.png" "128": "icons/icon128.png"
}, },
"permissions": [ "permissions": ["activeTab", "*://*.youtube.com/*", "storage", "*://returnyoutubedislikeapi.com/*", "identity"],
"activeTab", "browser_specific_settings": {
"*://*.youtube.com/*", "gecko": {
"storage", "id": "{762f9885-5a13-4abd-9c77-433dcd38b8fd}",
"*://returnyoutubedislikeapi.com/*", "strict_min_version": "140.0",
"identity" "data_collection_permissions": {
], "required": ["personallyIdentifyingInfo", "browsingActivity", "websiteContent", "websiteActivity"],
"optional": ["authenticationInfo"]
}
}
},
"browser_action": { "browser_action": {
"default_popup": "popup.html" "default_popup": "popup.html"
}, },
"content_scripts": [ "content_scripts": [
{ {
"matches": [ "matches": ["*://*.youtube.com/*"],
"*://*.youtube.com/*" "exclude_matches": ["*://*.music.youtube.com/*"],
],
"exclude_matches": [
"*://*.music.youtube.com/*"
],
"run_at": "document_idle", "run_at": "document_idle",
"css": [ "css": ["content-style.css"],
"content-style.css" "js": ["ryd.content-script.js"]
],
"js": [
"ryd.content-script.js"
]
} }
], ],
"options_ui": { "options_ui": {
"page": "popup.html", "page": "popup.html",
"open_in_tab": false "open_in_tab": false
}, },
"web_accessible_resources": [ "web_accessible_resources": ["menu-fixer.js"]
"menu-fixer.js"
]
// uncomment this section for local storage to work in firefox locally,
// ,"browser_specific_settings": {
// "gecko": {
// "id": "addon@example.com",
// "strict_min_version": "42.0"
// }
// }
} }
+3 -7
View File
@@ -12,12 +12,7 @@
"48": "icons/icon48.png", "48": "icons/icon48.png",
"128": "icons/icon128.png" "128": "icons/icon128.png"
}, },
"permissions": [ "permissions": ["activeTab", "*://*.youtube.com/*", "storage", "*://returnyoutubedislikeapi.com/*"],
"activeTab",
"*://*.youtube.com/*",
"storage",
"*://returnyoutubedislikeapi.com/*"
],
"browser_action": { "browser_action": {
"default_popup": "popup.html" "default_popup": "popup.html"
}, },
@@ -32,5 +27,6 @@
], ],
"options_ui": { "options_ui": {
"page": "popup.html" "page": "popup.html"
} },
"web_accessible_resources": ["menu-fixer.js"]
} }
+499 -49
View File
@@ -1,61 +1,511 @@
function debounceAsync(func, wait, renderer) { (() => {
let timeout; const HIDE_CLUTTER_ATTRIBUTE = "data-ryd-hide-clutter-buttons";
let lastCallTime = 0; const SHORTS_HYDRATION_ATTRIBUTE = "data-ryd-shorts-action-bar-hydrated";
const SHORTS_HYDRATION_REQUEST_EVENT = "ryd-shorts-action-bar-hydration-request";
const MENU_UPDATE_WINDOW_MS = 100;
const CONTROLLER_STATE_KEY = Symbol.for("return-youtube-dislike.menu-fixer.controller-state");
const INSTALL_STATE_KEY = Symbol.for("return-youtube-dislike.menu-fixer.install-state");
return async function (...args) { function hideClutterButtonsEnabled() {
const context = this; return document.documentElement.getAttribute(HIDE_CLUTTER_ATTRIBUTE) === "true";
const now = Date.now(); }
if (!lastCallTime || now - lastCallTime > wait) { function reportShortsActionBarHydration(event) {
// If the last call was long enough ago or this is the first call, execute immediately const actionBar = event.target;
lastCallTime = now; if (!(actionBar instanceof Element) || !actionBar.matches("reel-action-bar-view-model")) return;
return await func.apply(context, args);
} else { // Content scripts run in an isolated JavaScript world and cannot read the
// Hide all optional menu items - prevents endless loop // page-owned `data` property directly. Reflect only the boolean readiness
if (renderer?.polymerController?.flexAsTopLevelButtons) { // result into the shared DOM for the synchronous requester.
renderer.polymerController.flexAsTopLevelButtons = []; actionBar.setAttribute(SHORTS_HYDRATION_ATTRIBUTE, String(!("data" in actionBar) || Boolean(actionBar.data)));
}
function getMenuLayoutKey(ytdMenuRenderer) {
const layoutTarget = ytdMenuRenderer?.closest?.("#actions") ?? ytdMenuRenderer;
const rect = layoutTarget?.getBoundingClientRect?.();
if (!rect) return null;
return `${Math.round(rect.width * 10) / 10}:${Math.round(rect.height * 10) / 10}`;
}
function hasMeasurableBox(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 menuNeedsMoreOverflow(
ytdMenuRenderer,
{ tolerance = 2, viewportWidth = window.innerWidth || document.documentElement.clientWidth } = {},
) {
const actionSurface = ytdMenuRenderer?.querySelector?.("#top-level-buttons-computed");
if (!hasMeasurableBox(actionSurface) || !Number.isFinite(viewportWidth) || viewportWidth <= 0) return false;
const actionSurfaceRect = actionSurface.getBoundingClientRect();
const flexibleActionHosts = [...ytdMenuRenderer.querySelectorAll("#flexible-item-buttons")].flatMap((container) =>
[...container.children].filter(hasMeasurableBox),
);
const moreButtons = [...ytdMenuRenderer.querySelectorAll("button")].filter(
(button) =>
!actionSurface.contains(button) && !button.closest("#flexible-item-buttons") && hasMeasurableBox(button),
);
const actionRects = [actionSurface, ...flexibleActionHosts, ...moreButtons].map((element) =>
element.getBoundingClientRect(),
);
if (actionRects.some((rect) => rect.left < -tolerance || rect.right > viewportWidth + tolerance)) return true;
// YouTube can report that every flexible action fits while painting those
// actions on a second row outside the menu's own box. That row intersects
// the absolutely positioned ratio bar, so require every visible action to
// share the reaction surface's row and overflow another item if it does not.
return actionRects.slice(1).some((rect) => Math.abs(rect.top - actionSurfaceRect.top) > tolerance);
}
function getFlexibleButtonCount(controller) {
return Array.isArray(controller?.flexAsTopLevelButtons) ? controller.flexAsTopLevelButtons.length : null;
}
function keepAtMostFlexibleButtons(controller, count) {
if (!Number.isInteger(count) || !Array.isArray(controller?.flexAsTopLevelButtons)) return;
if (controller.flexAsTopLevelButtons.length <= count) return;
// Keep the controller's current items rather than a saved array. YouTube can
// reuse a renderer across navigations while replacing the button models.
controller.flexAsTopLevelButtons = controller.flexAsTopLevelButtons.slice(0, count);
}
function containsReactionButtonModel(value, depth = 0) {
if (!value || typeof value !== "object" || depth > 8) return false;
for (const [key, child] of Object.entries(value)) {
const normalizedKey = key.toLowerCase();
if (normalizedKey.includes("likebutton") || normalizedKey.includes("dislikebutton")) return true;
if ((key === "iconName" || key === "iconType") && typeof child === "string" && /^(DIS)?LIKE(?:_|$)/.test(child)) {
return true;
}
if (key === "accessibilityId" && typeof child === "string" && /\.video\.(?:dis)?like\.button$/.test(child)) {
return true;
}
if (containsReactionButtonModel(child, depth + 1)) return true;
}
return false;
}
function getMenuItemText(button) {
if (button?.text && typeof button.text === "object") return button.text;
const label = [button?.title, button?.accessibilityText, button?.tooltip].find(
(value) => typeof value === "string" && value.trim(),
);
return label ? { runs: [{ text: label }] } : null;
}
function convertTopLevelButtonToFlexibleItem(topLevelButton) {
const button = topLevelButton?.buttonViewModel ?? topLevelButton?.buttonRenderer;
if (!button) return null;
const text = getMenuItemText(button);
const serviceEndpoint = button.onTap ?? button.serviceEndpoint ?? button.navigationEndpoint ?? button.command;
const iconType = button.iconName ?? button.icon?.iconType;
if (!text || !serviceEndpoint) return null;
const menuServiceItemRenderer = {
text,
serviceEndpoint,
...(iconType ? { icon: { iconType } } : {}),
...(button.trackingParams ? { trackingParams: button.trackingParams } : {}),
};
return {
menuFlexibleItemRenderer: {
menuItem: { menuServiceItemRenderer },
topLevelButton,
},
};
}
function createClutterMenuData(data) {
if (!data || !Array.isArray(data.topLevelButtons)) return null;
const reactionButtons = [];
const movedButtons = [];
for (const topLevelButton of data.topLevelButtons) {
if (containsReactionButtonModel(topLevelButton)) {
reactionButtons.push(topLevelButton);
continue;
} }
// Otherwise, delay the call const flexibleItem = convertTopLevelButtonToFlexibleItem(topLevelButton);
return new Promise((resolve) => { if (flexibleItem) movedButtons.push(flexibleItem);
clearTimeout(timeout); else reactionButtons.push(topLevelButton);
timeout = setTimeout(async () => {
lastCallTime = Date.now();
resolve(await func.apply(context, args));
}, wait);
});
} }
};
}
const fixYtdMenuRenderer = (ytdMenuRenderer) => { return {
if (!ytdMenuRenderer?.polymerController?.maybeUpdateFlexibleMenuImpl) { ...data,
return; topLevelButtons: reactionButtons,
flexibleItems: [...movedButtons, ...(Array.isArray(data.flexibleItems) ? data.flexibleItems : [])],
};
} }
const originalMaybeUpdateFlexibleMenuImpl = ytdMenuRenderer.polymerController.maybeUpdateFlexibleMenuImpl.bind(
ytdMenuRenderer.polymerController,
);
ytdMenuRenderer.polymerController.maybeUpdateFlexibleMenuImpl = debounceAsync( function createClutterModelManager(controller) {
let sourceData = null;
let clutterData = null;
return {
disable() {
// Restore only the exact transformed model we installed. If YouTube has
// replaced data during SPA navigation, the old video's model is stale.
if (sourceData && controller.data === clutterData) controller.data = sourceData;
sourceData = null;
clutterData = null;
},
isEnabled() {
return clutterData !== null && controller.data === clutterData;
},
enable() {
if (controller.data !== clutterData) {
const nextClutterData = createClutterMenuData(controller.data);
if (nextClutterData) {
sourceData = controller.data;
clutterData = nextClutterData;
controller.data = clutterData;
} else {
sourceData = null;
clutterData = null;
}
}
},
};
}
function createMenuUpdateGuard(
originalMaybeUpdateFlexibleMenuImpl, originalMaybeUpdateFlexibleMenuImpl,
100, controller,
ytdMenuRenderer, {
); getContextKey = () => null,
}; getLayoutKey = () => null,
isHideClutterEnabled = hideClutterButtonsEnabled,
needsMoreOverflow = () => false,
now = Date.now,
updateWindowMs = MENU_UPDATE_WINDOW_MS,
} = {},
) {
let lastCallTime = Number.NEGATIVE_INFINITY;
let observedButtonCounts = [];
let stabilizedButtonCount = null;
let latestArgs = [];
let flexibleButtonCandidates = null;
let flexibleButtonCandidatesContextKey;
let hasFlexibleButtonCandidatesContextKey = false;
let lastLayoutKey;
let hasLayoutKey = false;
let lastMenuData;
let hasMenuData = false;
const clutterModelManager = createClutterModelManager(controller);
const fixedYtdMenuRenderers = []; const resetBurst = () => {
const observer = new MutationObserver(() => { lastCallTime = Number.NEGATIVE_INFINITY;
const ytdMenuRenderers = [...document.querySelectorAll("ytd-menu-renderer")].filter( observedButtonCounts = [];
(el) => !fixedYtdMenuRenderers.includes(el), stabilizedButtonCount = null;
); };
if (!ytdMenuRenderers.length) return;
for (const el of ytdMenuRenderers) { // YouTube's fitter removes items from this array but does not add them back
fixYtdMenuRenderer(el); // before a later width change. Retain the widest prefix for the current
fixedYtdMenuRenderers.push(el); // video so a new layout burst can start from all known candidates. YouTube
// may replace the menu data object during a resize without changing videos,
// so data-object identity is not a safe context boundary here.
const rememberFlexibleButtonCandidates = () => {
const currentButtons = Array.isArray(controller?.flexAsTopLevelButtons) ? controller.flexAsTopLevelButtons : null;
if (!currentButtons) return;
const contextKey = getContextKey();
const contextChanged = hasFlexibleButtonCandidatesContextKey && contextKey !== flexibleButtonCandidatesContextKey;
const currentIsCandidatePrefix =
flexibleButtonCandidates !== null &&
currentButtons.every((button, index) => flexibleButtonCandidates[index] === button);
if (
flexibleButtonCandidates === null ||
contextChanged ||
!currentIsCandidatePrefix ||
currentButtons.length > flexibleButtonCandidates.length
) {
flexibleButtonCandidates = [...currentButtons];
}
flexibleButtonCandidatesContextKey = contextKey;
hasFlexibleButtonCandidatesContextKey = true;
};
const restoreFlexibleButtonCandidates = () => {
if (!flexibleButtonCandidates || !Array.isArray(controller?.flexAsTopLevelButtons)) return;
const currentButtons = controller.flexAsTopLevelButtons;
const currentIsCandidatePrefix = currentButtons.every(
(button, index) => flexibleButtonCandidates[index] === button,
);
if (!currentIsCandidatePrefix || currentButtons.length >= flexibleButtonCandidates.length) return;
controller.flexAsTopLevelButtons = [...flexibleButtonCandidates];
};
const enforceCurrentLayout = () => {
const currentButtonCount = getFlexibleButtonCount(controller);
if (!currentButtonCount || !needsMoreOverflow()) return false;
const nextButtonCount = currentButtonCount - 1;
stabilizedButtonCount =
stabilizedButtonCount === null ? nextButtonCount : Math.min(stabilizedButtonCount, nextButtonCount);
keepAtMostFlexibleButtons(controller, nextButtonCount);
return true;
};
function guardedMaybeUpdateFlexibleMenuImpl(...args) {
latestArgs = args;
// Treat every losslessly representable non-reaction button as flexible,
// not only the buttons YouTube happened to classify that way before RYD
// widened the reaction group. In the current Watch layout Share is a
// fixed top-level model while Save is flexible; at narrow widths that
// leaves Share impossible to move and makes the whole action row wrap
// and overflow. Keeping Share first in flexibleItems preserves its
// priority while allowing the native fitter to hide exactly what no
// longer fits.
if (!clutterModelManager.isEnabled()) {
rememberFlexibleButtonCandidates();
clutterModelManager.enable();
rememberFlexibleButtonCandidates();
}
if (isHideClutterEnabled()) {
keepAtMostFlexibleButtons(controller, 0);
return undefined;
}
const callTime = now();
const layoutKey = getLayoutKey();
const layoutChanged = hasLayoutKey && layoutKey !== lastLayoutKey;
const menuData = controller?.data;
const menuDataChanged = hasMenuData && menuData !== lastMenuData;
lastLayoutKey = layoutKey;
hasLayoutKey = true;
lastMenuData = menuData;
hasMenuData = true;
if (layoutChanged || menuDataChanged) {
resetBurst();
rememberFlexibleButtonCandidates();
restoreFlexibleButtonCandidates();
} else if (callTime - lastCallTime > updateWindowMs) {
// Start a new observation burst, but retain the safe prefix selected
// for this unchanged allocation. Restoring every candidate here lets a
// delayed native pass reopen a layout that was already fitted.
resetBurst();
rememberFlexibleButtonCandidates();
} else {
rememberFlexibleButtonCandidates();
}
lastCallTime = callTime;
if (stabilizedButtonCount !== null) {
keepAtMostFlexibleButtons(controller, stabilizedButtonCount);
return undefined;
}
const currentButtonCount = getFlexibleButtonCount(controller);
const measurableButtonCount = currentButtonCount ?? "unavailable";
const repeatedAt = observedButtonCounts.lastIndexOf(measurableButtonCount);
if (repeatedAt !== -1) {
const isFirstConsecutiveRepeat =
repeatedAt === observedButtonCounts.length - 1 &&
(repeatedAt === 0 || observedButtonCounts[repeatedAt - 1] !== measurableButtonCount);
if (!isFirstConsecutiveRepeat) {
const cycleCounts = observedButtonCounts.slice(repeatedAt).filter((count) => typeof count === "number");
stabilizedButtonCount = cycleCounts.length ? Math.min(...cycleCounts) : 0;
if (currentButtonCount !== null) keepAtMostFlexibleButtons(controller, stabilizedButtonCount);
return undefined;
}
}
observedButtonCounts.push(measurableButtonCount);
// Native passes may legitimately remove several actions one at a time.
// Continue while the visible flexible-action count changes monotonically;
// stop on an oscillation or after one retry makes no visible progress.
const buttonCountBeforeNativeUpdate = getFlexibleButtonCount(controller);
const enforceIfNativeLayoutWasUnchanged = () => {
if (getFlexibleButtonCount(controller) === buttonCountBeforeNativeUpdate) enforceCurrentLayout();
};
let result;
try {
result = originalMaybeUpdateFlexibleMenuImpl.apply(this, args);
} catch (error) {
throw error;
}
if (result && typeof result.then === "function") {
return result.then((value) => {
enforceIfNativeLayoutWasUnchanged();
return value;
});
}
// A native count change rerenders asynchronously. Let the child-list
// observer validate that updated geometry instead of measuring the old
// DOM and hiding an extra action preemptively.
enforceIfNativeLayoutWasUnchanged();
return result;
}
guardedMaybeUpdateFlexibleMenuImpl.applyPreference = ({ restoreCandidates = true } = {}) => {
if (!clutterModelManager.isEnabled()) {
rememberFlexibleButtonCandidates();
clutterModelManager.enable();
rememberFlexibleButtonCandidates();
}
if (isHideClutterEnabled()) {
keepAtMostFlexibleButtons(controller, 0);
return undefined;
}
resetBurst();
if (restoreCandidates) restoreFlexibleButtonCandidates();
return guardedMaybeUpdateFlexibleMenuImpl.apply(controller, latestArgs);
};
guardedMaybeUpdateFlexibleMenuImpl.enforceLayout = enforceCurrentLayout;
guardedMaybeUpdateFlexibleMenuImpl.refreshLayout = () =>
guardedMaybeUpdateFlexibleMenuImpl.apply(controller, latestArgs);
return guardedMaybeUpdateFlexibleMenuImpl;
} }
});
observer.observe(document.documentElement, { function fixYtdMenuRenderer(ytdMenuRenderer, { refresh = false, restoreCandidates = false } = {}) {
subtree: true, const controller = ytdMenuRenderer?.polymerController;
childList: true, if (!controller?.maybeUpdateFlexibleMenuImpl) return false;
});
const existingState = controller[CONTROLLER_STATE_KEY];
if (existingState) {
if (hideClutterButtonsEnabled() || restoreCandidates) {
existingState.guardedMethod.applyPreference({ restoreCandidates });
} else if (refresh) {
existingState.guardedMethod.refreshLayout();
} else {
existingState.guardedMethod.enforceLayout();
}
return true;
}
const originalMethod = controller.maybeUpdateFlexibleMenuImpl;
const guardedMethod = createMenuUpdateGuard(originalMethod, controller, {
getContextKey: () => {
const watchRoot = ytdMenuRenderer.closest("ytd-watch-flexy, ytd-watch-grid");
const videoId = watchRoot?.getAttribute("video-id");
return videoId ? `watch:${videoId}` : `${location.pathname}${location.search}`;
},
getLayoutKey: () => getMenuLayoutKey(ytdMenuRenderer),
needsMoreOverflow: () => menuNeedsMoreOverflow(ytdMenuRenderer),
});
controller.maybeUpdateFlexibleMenuImpl = guardedMethod;
Object.defineProperty(controller, CONTROLLER_STATE_KEY, {
configurable: true,
value: { guardedMethod, originalMethod },
});
// The initial native fit may have completed before the injected page-world
// patch was installed. Refit immediately so the enlarged Dislike count is
// accounted for even without a later resize.
guardedMethod.applyPreference();
return true;
}
function scanForMenuRenderers(options) {
for (const renderer of document.querySelectorAll("ytd-menu-renderer")) {
fixYtdMenuRenderer(renderer, options);
}
}
function installMenuFixer() {
const previousInstallation = window[INSTALL_STATE_KEY];
previousInstallation?.observer?.disconnect();
previousInstallation?.resizeObserver?.disconnect();
const resizeObserver =
typeof ResizeObserver === "function"
? new ResizeObserver(() => {
scanForMenuRenderers({ refresh: true });
})
: null;
const observeMenuLayouts = () => {
if (!resizeObserver) return;
for (const renderer of document.querySelectorAll("ytd-menu-renderer")) {
resizeObserver.observe(renderer.closest("#actions") ?? renderer);
}
};
document.addEventListener(SHORTS_HYDRATION_REQUEST_EVENT, reportShortsActionBarHydration, true);
scanForMenuRenderers();
observeMenuLayouts();
const observer = new MutationObserver((mutations) => {
const preferenceChanged = mutations.some(
(mutation) =>
mutation.type === "attributes" &&
mutation.target === document.documentElement &&
mutation.attributeName === HIDE_CLUTTER_ATTRIBUTE,
);
scanForMenuRenderers({
refresh: preferenceChanged,
restoreCandidates: preferenceChanged && !hideClutterButtonsEnabled(),
});
observeMenuLayouts();
});
observer.observe(document.documentElement, {
attributeFilter: [HIDE_CLUTTER_ATTRIBUTE],
attributes: true,
childList: true,
subtree: true,
});
const installation = {
observer,
resizeObserver,
stop() {
observer.disconnect();
resizeObserver?.disconnect();
document.removeEventListener(SHORTS_HYDRATION_REQUEST_EVENT, reportShortsActionBarHydration, true);
if (window[INSTALL_STATE_KEY] === installation) delete window[INSTALL_STATE_KEY];
},
};
window[INSTALL_STATE_KEY] = installation;
return installation;
}
if (typeof module === "object" && module.exports) {
module.exports = {
CONTROLLER_STATE_KEY,
HIDE_CLUTTER_ATTRIBUTE,
MENU_UPDATE_WINDOW_MS,
SHORTS_HYDRATION_ATTRIBUTE,
SHORTS_HYDRATION_REQUEST_EVENT,
containsReactionButtonModel,
convertTopLevelButtonToFlexibleItem,
createClutterMenuData,
createClutterModelManager,
createMenuUpdateGuard,
fixYtdMenuRenderer,
getFlexibleButtonCount,
getMenuLayoutKey,
hideClutterButtonsEnabled,
installMenuFixer,
keepAtMostFlexibleButtons,
menuNeedsMoreOverflow,
reportShortsActionBarHydration,
};
} else {
installMenuFixer();
}
})();
+101 -7
View File
@@ -24,7 +24,7 @@
border-radius: 4px; border-radius: 4px;
font-weight: 500; font-weight: 500;
border-radius: 100em; border-radius: 100em;
box-shadow: 0 5px 5px rgba(0,0,0,0.1); box-shadow: 0 5px 5px rgba(0, 0, 0, 0.1);
margin: 0.25em; margin: 0.25em;
} }
.m3-btn:hover { .m3-btn:hover {
@@ -58,6 +58,14 @@ h1 {
font-size: 26px; font-size: 26px;
} }
#patreon-privacy-note {
max-width: 21rem;
margin: 0.5rem auto 1rem;
color: var(--lightGrey);
font-size: 0.875rem;
line-height: 1.45;
}
#ext-version { #ext-version {
padding: 0.25rem 0.5rem; padding: 0.25rem 0.5rem;
} }
@@ -143,7 +151,7 @@ button {
z-index: 69; z-index: 69;
height: 2rem; height: 2rem;
width: 2rem; width: 2rem;
transition-duration: .25s; transition-duration: 0.25s;
} }
#advancedToggle:hover { #advancedToggle:hover {
@@ -269,11 +277,97 @@ input:checked + .slider::before {
display: inline-block; display: inline-block;
} }
#server-status { .setting-row {
height: 72px; position: relative;
width: 90px; display: grid;
/* filter: invert(21%) sepia(100%) saturate(3618%) hue-rotate(102deg) grid-template-columns: 30px minmax(0, 1fr) 24px;
brightness(96%) contrast(108%); */ column-gap: 0.5rem;
align-items: center;
margin-bottom: 1rem;
}
.setting-row .switch {
margin-bottom: 0;
}
.setting-row-label {
color: var(--primary);
cursor: pointer;
}
.setting-help {
position: relative;
z-index: 5;
}
.setting-help button {
display: grid;
width: 20px;
height: 20px;
padding: 0;
border: 1px solid var(--primary);
border-radius: 50%;
background: transparent;
color: var(--primary);
cursor: help;
font-size: 13px;
font-weight: 700;
line-height: 1;
place-items: center;
user-select: none;
}
.setting-help button:focus-visible {
outline: 2px solid var(--primary);
outline-offset: 2px;
}
.setting-help-popover {
display: none;
position: absolute;
z-index: 10;
top: calc(100% + 0.5rem);
right: 0;
box-sizing: border-box;
width: min(270px, calc(100vw - 3rem));
padding: 0.75rem;
border: 1px solid var(--secondary);
border-radius: 0.5rem;
background: var(--tertiary);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
color: var(--primary);
}
.setting-help:hover > .setting-help-popover,
.setting-help:focus-within > .setting-help-popover {
display: block;
}
.setting-help-popover p {
margin: 0 0 0.75rem;
line-height: 1.35;
}
.setting-help-popover figure {
margin: 0 0 0.75rem;
}
.setting-help-popover figure:last-child {
margin-bottom: 0;
}
.setting-help-popover img {
display: block;
width: 100%;
height: auto;
border: 1px solid var(--secondary);
border-radius: 0.35rem;
}
.setting-help-popover figcaption {
margin-top: 0.25rem;
color: var(--lightGrey);
font-size: 0.8rem;
} }
.container { .container {
+109 -114
View File
@@ -1,97 +1,94 @@
<!doctype html> <!doctype html>
<html> <html>
<!--- HEADER --> <!--- HEADER -->
<head> <head>
<meta content="text/html; charset=utf-8" /> <meta content="text/html; charset=utf-8" />
<title title="__MSG_extensionName__">__MSG_extensionName__</title> <title title="__MSG_extensionName__">__MSG_extensionName__</title>
<link rel="stylesheet" href="popup.css" /> <link rel="stylesheet" href="popup.css" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Roboto:wght@100;300;400;500;700;900&display=swap"
rel="stylesheet"
/>
</head> </head>
<!-- END HEADER --> <!-- END HEADER -->
<body> <body>
<center> <center>
<img src="icons/logo.svg" /> <img src="icons/logo.svg" />
<h1 style="margin-bottom: 0.75rem" title="__MSG_extensionName__"> <h1 style="margin-bottom: 0.75rem" title="__MSG_extensionName__">__MSG_extensionName__</h1>
__MSG_extensionName__ <p style="color: var(--lightGrey)" title="__MSG_textDeveloper__">__MSG_textDeveloper__</p>
</h1>
<p style="color: var(--lightGrey)" title="__MSG_textDeveloper__">
__MSG_textDeveloper__
</p>
<button class="m3-btn" id="link_website" title="__MSG_linkWebsite__"> <button class="m3-btn" id="link_website" title="__MSG_linkWebsite__">__MSG_linkWebsite__</button>
__MSG_linkWebsite__ <button class="m3-btn m3-primary" style="margin-top: 0.3em" id="link_donate" title="__MSG_linkDonate__">
</button>
<button
class="m3-btn m3-primary"
style="margin-top: 0.3em;"
id="link_donate"
title="__MSG_linkDonate__"
>
__MSG_linkDonate__ __MSG_linkDonate__
</button> </button>
<button class="m3-btn" id="link_discord" title="__MSG_linkDiscord__">__MSG_linkDiscord__</button> <button class="m3-btn" id="link_discord" title="__MSG_linkDiscord__">__MSG_linkDiscord__</button>
<br /> <br />
<!-- Patreon Login Section --> <!-- Patreon Login Section -->
<div id="patreon-section" style="margin-top: 1rem; padding: 0.5rem; border-top: 1px solid var(--lightGrey);"> <div id="patreon-section" style="margin-top: 1rem; padding: 0.5rem; border-top: 1px solid var(--lightGrey)">
<div id="patreon-logged-out" style="display: none;"> <div id="patreon-logged-out" style="display: none">
<p style="color: var(--lightGrey); font-size: 0.9rem; margin-bottom: 0.5rem;" title="__MSG_patreonLoggedOutMessage__">__MSG_patreonLoggedOutMessage__</p> <p
<button class="m3-btn m3-primary" id="patreon-login-btn" title="__MSG_patreonLogin__"> style="color: var(--lightGrey); font-size: 0.9rem; margin-bottom: 0.5rem"
<svg style="width: 16px; height: 16px; margin-right: 6px; vertical-align: middle;" viewBox="0 0 24 24" fill="currentColor"> title="__MSG_patreonLoggedOutMessage__"
<path d="M14.82 2.41c3.96 0 7.18 3.24 7.18 7.21c0 3.96-3.22 7.18-7.18 7.18c-3.97 0-7.21-3.22-7.21-7.18c0-3.97 3.24-7.21 7.21-7.21M2 21.6h3.5V2.41H2V21.6z"/> >
__MSG_patreonLoggedOutMessage__
</p>
<button
class="m3-btn m3-primary"
id="patreon-login-btn"
title="__MSG_patreonLogin__"
aria-describedby="patreon-privacy-note"
>
<svg
style="width: 16px; height: 16px; margin-right: 6px; vertical-align: middle"
viewBox="0 0 24 24"
fill="currentColor"
>
<path
d="M14.82 2.41c3.96 0 7.18 3.24 7.18 7.21c0 3.96-3.22 7.18-7.18 7.18c-3.97 0-7.21-3.22-7.21-7.18c0-3.97 3.24-7.21 7.21-7.21M2 21.6h3.5V2.41H2V21.6z"
/>
</svg> </svg>
__MSG_patreonLogin__ __MSG_patreonLogin__
</button> </button>
<p style="color: var(--lightGrey); font-size: 0.85rem; margin: 0.6rem 0 0.4rem;" title="__MSG_githubLoggedOutMessage__">__MSG_githubLoggedOutMessage__</p> <p id="patreon-privacy-note">__MSG_patreonPrivacyNote__</p>
<button class="m3-btn m3-primary" style="margin-top: 0.1rem;" id="github-login-btn" title="__MSG_githubLogin__"> <p
style="color: var(--lightGrey); font-size: 0.85rem; margin: 0.6rem 0 0.4rem"
title="__MSG_githubLoggedOutMessage__"
>
__MSG_githubLoggedOutMessage__
</p>
<button
class="m3-btn m3-primary"
style="margin-top: 0.1rem"
id="github-login-btn"
title="__MSG_githubLogin__"
>
__MSG_githubLogin__ __MSG_githubLogin__
</button> </button>
</div> </div>
<div id="patreon-logged-in" style="display: none;"> <div id="patreon-logged-in" style="display: none">
<div style="display: flex; align-items: center; margin-bottom: 0.5rem;"> <div style="display: flex; align-items: center; margin-bottom: 0.5rem">
<img id="patreon-user-avatar" src="" alt="" style="width: 32px; height: 32px; border-radius: 50%; margin-right: 8px; display: none;"> <img
id="patreon-user-avatar"
src=""
alt=""
style="width: 32px; height: 32px; border-radius: 50%; margin-right: 8px; display: none"
/>
<div> <div>
<p id="patreon-user-name" style="font-weight: 500; margin: 0;"></p> <p id="patreon-user-name" style="font-weight: 500; margin: 0"></p>
<p id="patreon-tier" style="color: var(--lightGrey); font-size: 0.8rem; margin: 0;"></p> <p id="patreon-tier" style="color: var(--lightGrey); font-size: 0.8rem; margin: 0"></p>
</div> </div>
</div> </div>
<button class="m3-btn" id="patreon-logout-btn" style="font-size: 0.9rem;" title="__MSG_patreonLogout__">__MSG_patreonLogout__</button> <button class="m3-btn" id="patreon-logout-btn" style="font-size: 0.9rem" title="__MSG_patreonLogout__">
__MSG_patreonLogout__
</button>
</div> </div>
</div> </div>
<button class="m3-btn" style="margin-top: 0.3rem" id="link_faq" title="__MSG_linkFAQ__"> <button class="m3-btn" style="margin-top: 0.3rem" id="link_faq" title="__MSG_linkFAQ__">__MSG_linkFAQ__</button>
__MSG_linkFAQ__
</button>
<button class="m3-btn" id="link_github" title="__MSG_linkGithub__">__MSG_linkGithub__</button> <button class="m3-btn" id="link_github" title="__MSG_linkGithub__">__MSG_linkGithub__</button>
<button class="m3-btn" style="margin-top: 0.3em" id="link_help" title="__MSG_linkHelp__"> <button class="m3-btn" style="margin-top: 0.3em" id="link_help" title="__MSG_linkHelp__">__MSG_linkHelp__</button>
__MSG_linkHelp__
</button>
<br /> <br />
<button <button class="m3-btn" style="margin-top: 0.3em" id="link_changelog" title="__MSG_linkChangelog__">
class="m3-btn"
style="margin-top: 0.3em"
id="link_changelog"
title="__MSG_linkChangelog__"
>
__MSG_linkChangelog__ __MSG_linkChangelog__
</button> </button>
<br />
<br />
<p style="display: none">__MSG_apiStatusLabel__ <b id="status"></b></p>
<img
id="server-status"
style="display: none; width: 0.75rem; height: 0.75rem"
src="./icons/server.svg"
alt=""
/>
<br /> <br />
<br /> <br />
</center> </center>
@@ -114,28 +111,22 @@
</button> </button>
<!-- bottom-right --> <!-- bottom-right -->
<div id="ext"> <div id="ext">
<a <a href="https://returnyoutubedislike.com/install" target="_blank" id="ext-update"></a>
href="https://returnyoutubedislike.com/install"
target="_blank"
id="ext-update"
></a>
<span id="ext-version"></span> <span id="ext-version"></span>
</div> </div>
<!-- dialog box --> <!-- dialog box -->
<fieldset id="advancedSettings"> <fieldset id="advancedSettings">
<label class="switch" data-hover="__MSG_disableLoggingHover__"> <label class="switch" data-hover="__MSG_disableLoggingHover__">
<input type="checkbox" id="disable_logging"/> <input type="checkbox" id="disable_logging" />
<span class="slider"/> <span class="slider" />
<span class="switchLabel">__MSG_disableLoggingLabel__</span> <span class="switchLabel">__MSG_disableLoggingLabel__</span>
</label> </label>
<br /> <br />
<label class="switch" data-hover="__MSG_textSettingsHover__"> <label class="switch" data-hover="__MSG_textSettingsHover__">
<input type="checkbox" id="disable_vote_submission" /> <input type="checkbox" id="disable_vote_submission" />
<span class="slider"></span> <span class="slider"></span>
<span class="switchLabel" title="__MSG_textSettings__"> <span class="switchLabel" title="__MSG_textSettings__"> __MSG_textSettings__ </span>
__MSG_textSettings__
</span>
</label> </label>
<br /> <br />
<label class="switch" data-hover="__MSG_reformatLikesHover__"> <label class="switch" data-hover="__MSG_reformatLikesHover__">
@@ -150,6 +141,43 @@
<span class="switchLabel">__MSG_hidePremiumTeaser__</span> <span class="switchLabel">__MSG_hidePremiumTeaser__</span>
</label> </label>
<br /> <br />
<div class="setting-row setting-row-with-help">
<label class="switch" for="hide_clutter_buttons">
<input type="checkbox" id="hide_clutter_buttons" />
<span class="slider"></span>
</label>
<label class="setting-row-label" for="hide_clutter_buttons">__MSG_hideClutterButtons__</label>
<div class="setting-help">
<button
type="button"
id="hide_clutter_buttons_help"
aria-label="__MSG_hideClutterButtonsHelpLabel__"
aria-describedby="hide_clutter_buttons_tooltip"
title="__MSG_hideClutterButtonsHelpLabel__"
>
?
</button>
<div id="hide_clutter_buttons_tooltip" class="setting-help-popover" role="tooltip">
<p>__MSG_hideClutterButtonsDescription__</p>
<figure>
<img
src="images/hide-clutter-before.png"
alt="__MSG_hideClutterButtonsBefore__"
data-hide-clutter-example="before"
/>
<figcaption>__MSG_hideClutterButtonsBefore__</figcaption>
</figure>
<figure>
<img
src="images/hide-clutter-after.png"
alt="__MSG_hideClutterButtonsAfter__"
data-hide-clutter-example="after"
/>
<figcaption>__MSG_hideClutterButtonsAfter__</figcaption>
</figure>
</div>
</div>
</div>
<div class="custom-select"> <div class="custom-select">
<label for="number_format">__MSG_numberFormat__</label> <label for="number_format">__MSG_numberFormat__</label>
<select name="number_format" id="number_format"> <select name="number_format" id="number_format">
@@ -178,74 +206,41 @@
<div class="custom-select"> <div class="custom-select">
<label for="color_theme">__MSG_colorTheme__</label> <label for="color_theme">__MSG_colorTheme__</label>
<select name="color_theme" id="color_theme"> <select name="color_theme" id="color_theme">
<option value="classic" id="color_theme_classic"> <option value="classic" id="color_theme_classic">__MSG_textColorTheme1__</option>
__MSG_textColorTheme1__ <option value="accessible" id="color_theme_accessible">__MSG_textColorTheme2__</option>
</option> <option value="neon" id="color_theme_neon">__MSG_textColorTheme3__</option>
<option value="accessible" id="color_theme_accessible">
__MSG_textColorTheme2__
</option>
<option value="neon" id="color_theme_neon">
__MSG_textColorTheme3__
</option>
</select> </select>
<span <span
id="color_theme_example_like" id="color_theme_example_like"
style=" style="display: inline-block; vertical-align: text-top; width: 1em; height: 1em"
display: inline-block;
vertical-align: text-top;
width: 1em;
height: 1em;
"
>&nbsp;</span >&nbsp;</span
> >
<span <span
id="color_theme_example_dislike" id="color_theme_example_dislike"
style=" style="display: inline-block; vertical-align: text-top; width: 1em; height: 1em"
display: inline-block;
vertical-align: text-top;
width: 1em;
height: 1em;
"
>&nbsp;</span >&nbsp;</span
> >
</div> </div>
<br /> <br />
<label <label class="switch" data-hover="__MSG_showTooltipPercentageHover__">
class="switch"
data-hover="__MSG_showTooltipPercentageHover__"
>
<input type="checkbox" id="show_tooltip_percentage" /> <input type="checkbox" id="show_tooltip_percentage" />
<span class="slider"></span> <span class="slider"></span>
<span class="switchLabel">__MSG_showTooltipPercentage__</span> <span class="switchLabel">__MSG_showTooltipPercentage__</span>
</label> </label>
<div class="custom-select"> <div class="custom-select">
<label <label for="tooltip_percentage_mode" data-hover="__MSG_tooltipPercentageModeHover__"
for="tooltip_percentage_mode"
data-hover="__MSG_tooltipPercentageModeHover__"
>__MSG_tooltipPercentageMode__</label >__MSG_tooltipPercentageMode__</label
> >
<select name="tooltip_percentage_mode" id="tooltip_percentage_mode"> <select name="tooltip_percentage_mode" id="tooltip_percentage_mode">
<option value="dash_like" id="tooltip_percentage_mode_dash_like"> <option value="dash_like" id="tooltip_percentage_mode_dash_like">
190&nbsp;/&nbsp;10&nbsp;&nbsp;-&nbsp;&nbsp;95% 190&nbsp;/&nbsp;10&nbsp;&nbsp;-&nbsp;&nbsp;95%
</option> </option>
<option <option value="dash_dislike" id="tooltip_percentage_mode_dash_dislike">
value="dash_dislike"
id="tooltip_percentage_mode_dash_dislike"
>
190&nbsp;/&nbsp;10&nbsp;&nbsp;-&nbsp;&nbsp;5% 190&nbsp;/&nbsp;10&nbsp;&nbsp;-&nbsp;&nbsp;5%
</option> </option>
<option value="both" id="tooltip_percentage_mode_both"> <option value="both" id="tooltip_percentage_mode_both">95%&nbsp;/&nbsp;5%</option>
95%&nbsp;/&nbsp;5% <option value="only_like" id="tooltip_percentage_mode_only_like">95%</option>
</option> <option value="only_dislike" id="tooltip_percentage_mode_only_dislike">5%</option>
<option value="only_like" id="tooltip_percentage_mode_only_like">
95%
</option>
<option
value="only_dislike"
id="tooltip_percentage_mode_only_dislike"
>
5%
</option>
</select> </select>
</div> </div>
</fieldset> </fieldset>
+135 -99
View File
@@ -1,4 +1,11 @@
import { config as appConfig, getApiUrl, getApiEndpoint } from "./src/config.js"; import { config as appConfig, getApiUrl, getApiEndpoint } from "./src/config.js";
import { getGitHubLoginErrorMessage } from "./src/github-login-error";
import {
hasAuthenticationDataPermission,
onAuthenticationDataPermissionRemoved,
requestAuthenticationDataPermission,
usesFirefoxDataCollectionConsent,
} from "./src/data-collection-permissions.js";
/* Config */ /* Config */
const config = { const config = {
@@ -13,6 +20,7 @@ const config = {
tooltipPercentageMode: "dash_like", tooltipPercentageMode: "dash_like",
numberDisplayReformatLikes: false, numberDisplayReformatLikes: false,
hidePremiumTeaser: false, hidePremiumTeaser: false,
hideClutterButtons: false,
showAdvancedMessage: showAdvancedMessage:
'<svg xmlns="http://www.w3.org/2000/svg" enable-background="new 0 0 24 24" height="24px" viewBox="0 0 24 24" width="24px" fill="currentColor"><rect fill="none" height="24" width="24"/><path d="M19.5,12c0-0.23-0.01-0.45-0.03-0.68l1.86-1.41c0.4-0.3,0.51-0.86,0.26-1.3l-1.87-3.23c-0.25-0.44-0.79-0.62-1.25-0.42 l-2.15,0.91c-0.37-0.26-0.76-0.49-1.17-0.68l-0.29-2.31C14.8,2.38,14.37,2,13.87,2h-3.73C9.63,2,9.2,2.38,9.14,2.88L8.85,5.19 c-0.41,0.19-0.8,0.42-1.17,0.68L5.53,4.96c-0.46-0.2-1-0.02-1.25,0.42L2.41,8.62c-0.25,0.44-0.14,0.99,0.26,1.3l1.86,1.41 C4.51,11.55,4.5,11.77,4.5,12s0.01,0.45,0.03,0.68l-1.86,1.41c-0.4,0.3-0.51,0.86-0.26,1.3l1.87,3.23c0.25,0.44,0.79,0.62,1.25,0.42 l2.15-0.91c0.37,0.26,0.76,0.49,1.17,0.68l0.29,2.31C9.2,21.62,9.63,22,10.13,22h3.73c0.5,0,0.93-0.38,0.99-0.88l0.29-2.31 c0.41-0.19,0.8-0.42,1.17-0.68l2.15,0.91c0.46,0.2,1,0.02,1.25-0.42l1.87-3.23c0.25-0.44,0.14-0.99-0.26-1.3l-1.86-1.41 C19.49,12.45,19.5,12.23,19.5,12z M12.04,15.5c-1.93,0-3.5-1.57-3.5-3.5s1.57-3.5,3.5-3.5s3.5,1.57,3.5,3.5S13.97,15.5,12.04,15.5z"/></svg>', '<svg xmlns="http://www.w3.org/2000/svg" enable-background="new 0 0 24 24" height="24px" viewBox="0 0 24 24" width="24px" fill="currentColor"><rect fill="none" height="24" width="24"/><path d="M19.5,12c0-0.23-0.01-0.45-0.03-0.68l1.86-1.41c0.4-0.3,0.51-0.86,0.26-1.3l-1.87-3.23c-0.25-0.44-0.79-0.62-1.25-0.42 l-2.15,0.91c-0.37-0.26-0.76-0.49-1.17-0.68l-0.29-2.31C14.8,2.38,14.37,2,13.87,2h-3.73C9.63,2,9.2,2.38,9.14,2.88L8.85,5.19 c-0.41,0.19-0.8,0.42-1.17,0.68L5.53,4.96c-0.46-0.2-1-0.02-1.25,0.42L2.41,8.62c-0.25,0.44-0.14,0.99,0.26,1.3l1.86,1.41 C4.51,11.55,4.5,11.77,4.5,12s0.01,0.45,0.03,0.68l-1.86,1.41c-0.4,0.3-0.51,0.86-0.26,1.3l1.87,3.23c0.25,0.44,0.79,0.62,1.25,0.42 l2.15-0.91c0.37,0.26,0.76,0.49,1.17,0.68l0.29,2.31C9.2,21.62,9.63,22,10.13,22h3.73c0.5,0,0.93-0.38,0.99-0.88l0.29-2.31 c0.41-0.19,0.8-0.42,1.17-0.68l2.15,0.91c0.46,0.2,1,0.02,1.25-0.42l1.87-3.23c0.25-0.44,0.14-0.99-0.26-1.3l-1.86-1.41 C19.49,12.45,19.5,12.23,19.5,12z M12.04,15.5c-1.93,0-3.5-1.57-3.5-3.5s1.57-3.5,3.5-3.5s3.5,1.57,3.5,3.5S13.97,15.5,12.04,15.5z"/></svg>',
hideAdvancedMessage: hideAdvancedMessage:
@@ -95,6 +103,10 @@ document.getElementById("hide_premium_teaser").addEventListener("click", (ev) =>
chrome.storage.sync.set({ hidePremiumTeaser: ev.target.checked }); chrome.storage.sync.set({ hidePremiumTeaser: ev.target.checked });
}); });
document.getElementById("hide_clutter_buttons").addEventListener("click", (ev) => {
chrome.storage.sync.set({ hideClutterButtons: ev.target.checked });
});
function initPatreonAuth() { function initPatreonAuth() {
const loggedOutView = document.getElementById("patreon-logged-out"); const loggedOutView = document.getElementById("patreon-logged-out");
const loggedInView = document.getElementById("patreon-logged-in"); const loggedInView = document.getElementById("patreon-logged-in");
@@ -104,16 +116,30 @@ function initPatreonAuth() {
const userAvatar = document.getElementById("patreon-user-avatar"); const userAvatar = document.getElementById("patreon-user-avatar");
const userName = document.getElementById("patreon-user-name"); const userName = document.getElementById("patreon-user-name");
const userTier = document.getElementById("patreon-tier"); const userTier = document.getElementById("patreon-tier");
let authenticationGeneration = 0;
const initialGeneration = authenticationGeneration;
chrome.storage.sync.get(["patreonUser", "patreonSessionToken"], (data) => { chrome.storage.sync.get(["patreonUser", "patreonSessionToken"], async (data) => {
if (initialGeneration !== authenticationGeneration) return;
const cachedUser = data.patreonUser; const cachedUser = data.patreonUser;
const sessionToken = data.patreonSessionToken; const sessionToken = data.patreonSessionToken;
if (sessionToken && cachedUser) { if (sessionToken && cachedUser) {
const granted = await hasAuthenticationDataPermission();
if (initialGeneration !== authenticationGeneration) return;
if (!granted) {
chrome.storage.sync.remove(["patreonAuthenticated", "patreonUser", "patreonSessionToken"]);
showLoggedOutView();
return;
}
// Show cached state immediately to avoid flicker on popup reopen. // Show cached state immediately to avoid flicker on popup reopen.
showLoggedInView(cachedUser); showLoggedInView(cachedUser);
verifySession(sessionToken).then((result) => { verifySession(sessionToken, initialGeneration).then(async (result) => {
if (initialGeneration !== authenticationGeneration) return;
const stillGranted = await hasAuthenticationDataPermission();
if (initialGeneration !== authenticationGeneration || !stillGranted) return;
if (result.status === "valid") { if (result.status === "valid") {
if (result.membershipTier && result.membershipTier !== cachedUser.membershipTier) { if (result.membershipTier && result.membershipTier !== cachedUser.membershipTier) {
const updatedUser = { ...cachedUser, membershipTier: result.membershipTier, hasActiveMembership: true }; const updatedUser = { ...cachedUser, membershipTier: result.membershipTier, hasActiveMembership: true };
@@ -147,6 +173,10 @@ function initPatreonAuth() {
function showLoggedOutView() { function showLoggedOutView() {
loggedOutView.style.display = "block"; loggedOutView.style.display = "block";
loggedInView.style.display = "none"; loggedInView.style.display = "none";
userAvatar.removeAttribute("src");
userAvatar.style.display = "none";
userName.textContent = "";
userTier.textContent = "";
} }
function showLoggedInView(user) { function showLoggedInView(user) {
@@ -179,8 +209,12 @@ function initPatreonAuth() {
} }
} }
async function verifySession(token) { async function verifySession(token, generation) {
try { try {
if (!(await hasAuthenticationDataPermission()) || generation !== authenticationGeneration) {
return { status: "consent_required" };
}
const response = await fetch(getApiEndpoint("/api/auth/verify"), { const response = await fetch(getApiEndpoint("/api/auth/verify"), {
method: "POST", method: "POST",
headers: { headers: {
@@ -311,63 +345,108 @@ function initPatreonAuth() {
} }
loginBtn.addEventListener("click", () => { loginBtn.addEventListener("click", () => {
// Request permission immediately within this user gesture const generation = ++authenticationGeneration;
ensureIdentityPermission(async (granted) => { const continueLogin = (consentGranted) => {
const identityNow = getIdentityApi(); if (generation !== authenticationGeneration) return;
if (!granted || !identityNow || typeof identityNow.getRedirectURL !== "function") { if (!consentGranted) {
alert(chrome.i18n.getMessage("patreonPermissionRequired")); alert(chrome.i18n.getMessage("patreonPermissionRequired"));
return; return;
} }
// Delegate OAuth to background so it persists if the popup closes
chrome.runtime.sendMessage({ message: "patreon_oauth_login" }, (resp) => {
if (chrome.runtime && chrome.runtime.lastError) {
console.error("Login failed:", chrome.runtime.lastError.message);
alert(chrome.i18n.getMessage("patreonLoginStartFailed"));
return;
}
if (resp && resp.success) {
const user = resp.user;
showLoggedInView(user);
} else {
console.error("Login failed:", resp && resp.error);
alert(chrome.i18n.getMessage("patreonLoginCompleteFailed"));
}
});
});
});
if (githubLoginBtn) { ensureIdentityPermission((granted) => {
githubLoginBtn.addEventListener("click", () => { if (generation !== authenticationGeneration) return;
ensureIdentityPermission(async (granted) => {
const identityNow = getIdentityApi(); const identityNow = getIdentityApi();
if (!granted || !identityNow || typeof identityNow.getRedirectURL !== "function") { if (!granted || !identityNow || typeof identityNow.getRedirectURL !== "function") {
alert(chrome.i18n.getMessage("patreonPermissionRequired")); alert(chrome.i18n.getMessage("patreonPermissionRequired"));
return; return;
} }
// Delegate OAuth to background so it persists if the popup closes
chrome.runtime.sendMessage({ message: "github_oauth_login" }, (resp) => { chrome.runtime.sendMessage({ message: "patreon_oauth_login" }, async (resp) => {
if (generation !== authenticationGeneration) return;
if (chrome.runtime && chrome.runtime.lastError) { if (chrome.runtime && chrome.runtime.lastError) {
console.error("GitHub login failed:", chrome.runtime.lastError.message); console.error("Login failed:", chrome.runtime.lastError.message);
alert(chrome.i18n.getMessage("githubLoginStartFailed")); alert(chrome.i18n.getMessage("patreonLoginStartFailed"));
return; return;
} }
if (resp && resp.success) { if (resp && resp.success) {
const stillGranted = await hasAuthenticationDataPermission();
if (generation !== authenticationGeneration || !stillGranted) return;
const user = resp.user; const user = resp.user;
showLoggedInView(user); showLoggedInView(user);
} else { } else {
console.error("GitHub login failed:", resp && resp.error); console.error("Login failed:", resp && resp.error);
alert(chrome.i18n.getMessage("githubLoginCompleteFailed")); alert(chrome.i18n.getMessage("patreonLoginCompleteFailed"));
} }
}); });
}); });
};
if (!usesFirefoxDataCollectionConsent()) {
continueLogin(true);
return;
}
requestAuthenticationDataPermission().then(continueLogin);
});
if (githubLoginBtn) {
githubLoginBtn.addEventListener("click", () => {
const generation = ++authenticationGeneration;
const continueLogin = (consentGranted) => {
if (generation !== authenticationGeneration) return;
if (!consentGranted) {
alert(chrome.i18n.getMessage("patreonPermissionRequired"));
return;
}
ensureIdentityPermission((granted) => {
if (generation !== authenticationGeneration) return;
const identityNow = getIdentityApi();
if (!granted || !identityNow || typeof identityNow.getRedirectURL !== "function") {
alert(chrome.i18n.getMessage("patreonPermissionRequired"));
return;
}
chrome.runtime.sendMessage({ message: "github_oauth_login" }, async (resp) => {
if (generation !== authenticationGeneration) return;
if (chrome.runtime && chrome.runtime.lastError) {
console.error("GitHub login failed:", chrome.runtime.lastError.message);
alert(chrome.i18n.getMessage("githubLoginStartFailed"));
return;
}
if (resp && resp.success) {
const stillGranted = await hasAuthenticationDataPermission();
if (generation !== authenticationGeneration || !stillGranted) return;
const user = resp.user;
showLoggedInView(user);
} else {
console.error("GitHub login failed:", resp && resp.error);
alert(getGitHubLoginErrorMessage(resp?.error, (key) => chrome.i18n.getMessage(key)));
}
});
});
};
if (!usesFirefoxDataCollectionConsent()) {
continueLogin(true);
return;
}
requestAuthenticationDataPermission().then(continueLogin);
}); });
} }
onAuthenticationDataPermissionRemoved(() => {
authenticationGeneration++;
chrome.storage.sync.remove(["patreonAuthenticated", "patreonUser", "patreonSessionToken"]);
showLoggedOutView();
});
logoutBtn.addEventListener("click", () => { logoutBtn.addEventListener("click", () => {
chrome.storage.sync.remove(["patreonUser", "patreonSessionToken"], () => { authenticationGeneration++;
showLoggedOutView(); showLoggedOutView();
chrome.runtime.sendMessage({ message: "patreon_logout" }); chrome.storage.sync.remove(["patreonAuthenticated", "patreonUser", "patreonSessionToken"]);
}); chrome.runtime.sendMessage({ message: "patreon_logout" });
}); });
} }
@@ -405,43 +484,12 @@ function initConfig() {
initializeTooltipPercentageMode(); initializeTooltipPercentageMode();
initializeNumberDisplayReformatLikes(); initializeNumberDisplayReformatLikes();
initializeHidePremiumTeaser(); initializeHidePremiumTeaser();
initializeHideClutterButtons();
} }
function initializeVersionNumber() { function initializeVersionNumber() {
const version = chrome.runtime.getManifest().version; const version = chrome.runtime.getManifest().version;
document.getElementById("ext-version").innerHTML = "v" + version; document.getElementById("ext-version").innerHTML = "v" + version;
fetch(
"https://raw.githubusercontent.com/Anarios/return-youtube-dislike/main/Extensions/combined/manifest-chrome.json",
)
.then((response) => response.json())
.then((json) => {
if (compareVersions(json.version, version)) {
document.getElementById("ext-update").innerHTML = chrome.i18n.getMessage("textUpdate") + " v" + json.version;
document.getElementById("ext-update").style.padding = ".25rem .5rem";
}
});
// .catch(console.error);
}
// returns whether current < latest
function compareVersions(latestStr, currentStr) {
let latestarr = latestStr.split(".");
let currentarr = currentStr.split(".");
let outdated = false;
// goes through version numbers from left to right from greatest to least significant
for (let i = 0; i < Math.max(latestarr.length, currentarr.length); i++) {
let latest = i < latestarr.length ? parseInt(latestarr[i]) : 0;
let current = i < currentarr.length ? parseInt(currentarr[i]) : 0;
if (latest > current) {
outdated = true;
break;
} else if (latest < current) {
outdated = false;
break;
}
}
return outdated;
} }
function initializeDisableVoteSubmission() { function initializeDisableVoteSubmission() {
@@ -513,6 +561,15 @@ function initializeHidePremiumTeaser() {
}); });
} }
function initializeHideClutterButtons() {
chrome.storage.sync.get(["hideClutterButtons"], (res) => {
if (res.hideClutterButtons === undefined) {
chrome.storage.sync.set({ hideClutterButtons: false });
}
handleHideClutterButtonsChangeEvent(res.hideClutterButtons);
});
}
chrome.storage.onChanged.addListener(storageChangeHandler); chrome.storage.onChanged.addListener(storageChangeHandler);
function storageChangeHandler(changes, area) { function storageChangeHandler(changes, area) {
@@ -543,6 +600,9 @@ function storageChangeHandler(changes, area) {
if (changes.hidePremiumTeaser !== undefined) { if (changes.hidePremiumTeaser !== undefined) {
handleHidePremiumTeaserChangeEvent(changes.hidePremiumTeaser.newValue); handleHidePremiumTeaserChangeEvent(changes.hidePremiumTeaser.newValue);
} }
if (changes.hideClutterButtons !== undefined) {
handleHideClutterButtonsChangeEvent(changes.hideClutterButtons.newValue);
}
} }
function handleDisableVoteSubmissionChangeEvent(value) { function handleDisableVoteSubmissionChangeEvent(value) {
@@ -609,6 +669,12 @@ function handleHidePremiumTeaserChangeEvent(value) {
document.getElementById("hide_premium_teaser").checked = normalized; document.getElementById("hide_premium_teaser").checked = normalized;
} }
function handleHideClutterButtonsChangeEvent(value) {
const normalized = value === true;
config.hideClutterButtons = normalized;
document.getElementById("hide_clutter_buttons").checked = normalized;
}
function getNumberFormatter(optionSelect) { function getNumberFormatter(optionSelect) {
let formatterNotation; let formatterNotation;
let formatterCompactDisplay; let formatterCompactDisplay;
@@ -642,24 +708,6 @@ function getNumberFormatter(optionSelect) {
return formatter; return formatter;
} }
(async function getStatus() {
let status = document.getElementById("status");
let serverStatus = document.getElementById("server-status");
let resp = await fetch("https://returnyoutubedislikeapi.com/votes?videoId=YbJOTdZBX1g");
let result = await resp.status;
if (result === 200) {
status.innerText = chrome.i18n.getMessage("apiStatusOnline");
status.style.color = "green";
serverStatus.style.filter =
"invert(58%) sepia(81%) saturate(2618%) hue-rotate(81deg) brightness(119%) contrast(129%)";
} else {
status.innerText = chrome.i18n.getMessage("apiStatusOffline");
status.style.color = "red";
serverStatus.style.filter =
"invert(11%) sepia(100%) saturate(6449%) hue-rotate(3deg) brightness(116%) contrast(115%)";
}
})();
function getColorFromTheme(colorTheme, voteIsLike) { function getColorFromTheme(colorTheme, voteIsLike) {
let colorString; let colorString;
switch (colorTheme) { switch (colorTheme) {
@@ -687,15 +735,3 @@ function getColorFromTheme(colorTheme, voteIsLike) {
} }
return colorString; return colorString;
} }
/* popup-script.js
document.querySelector('#login')
.addEventListener('click', function () {
chrome.runtime.sendMessage({ message: 'get_auth_token' });
});
document.querySelector("#log_off").addEventListener("click", function () {
chrome.runtime.sendMessage({ message: "log_off" });
});
*/
+124
View File
@@ -0,0 +1,124 @@
/** @jest-environment jsdom */
const fs = require("fs");
const path = require("path");
jest.mock("./src/data-collection-permissions", () => ({
hasAuthenticationDataPermission: jest.fn(),
onAuthenticationDataPermissionRemoved: jest.fn(),
requestAuthenticationDataPermission: jest.fn(),
usesFirefoxDataCollectionConsent: jest.fn(),
}));
const flushPromises = () => new Promise((resolve) => setTimeout(resolve, 0));
const cachedUser = { fullName: "Test User", imageUrl: "https://example.org/avatar", membershipTier: "none" };
describe("popup account consent lifecycle", () => {
let readCachedSession;
let removeConsent;
let consent;
beforeEach(() => {
jest.resetModules();
consent = true;
document.documentElement.innerHTML = fs.readFileSync(path.join(__dirname, "popup.html"), "utf8");
const permissions = require("./src/data-collection-permissions");
permissions.usesFirefoxDataCollectionConsent.mockReturnValue(true);
permissions.hasAuthenticationDataPermission.mockImplementation(() => Promise.resolve(consent));
permissions.requestAuthenticationDataPermission.mockImplementation(() => Promise.resolve(consent));
permissions.onAuthenticationDataPermissionRemoved.mockImplementation((listener) => (removeConsent = listener));
global.chrome = {
i18n: { getMessage: () => "" },
runtime: { getManifest: () => ({ version: "4.0.5" }), sendMessage: jest.fn() },
identity: { getRedirectURL: jest.fn() },
storage: {
sync: {
get: jest.fn((keys, callback) => {
if (keys.includes("patreonUser")) readCachedSession = callback;
}),
set: jest.fn(),
remove: jest.fn(),
},
onChanged: { addListener: jest.fn() },
},
};
global.fetch = jest.fn();
require("./popup");
});
afterEach(() => {
jest.restoreAllMocks();
delete global.chrome;
delete global.fetch;
});
test.each([
["github_not_configured", "githubLoginUnavailable"],
["github_unavailable", "githubLoginUnavailable"],
["not_contributor", "githubLoginNotContributor"],
["github_redirect_rejected", "githubLoginBrowserUnavailable"],
["github_invalid_state", "githubLoginExpired"],
["github_pkce_required", "githubLoginUpdateRequired"],
["github_rate_limited", "githubLoginRateLimited"],
["github_authorization_denied", "githubLoginDenied"],
["unexpected", "githubLoginCompleteFailed"],
])("shows the actionable GitHub message for %s", async (error, key) => {
const messages = require("./_locales/en/messages.json");
chrome.i18n.getMessage = (name) => messages[name]?.message || "";
const alert = jest.spyOn(window, "alert").mockImplementation(() => {});
jest.spyOn(console, "error").mockImplementation(() => {});
chrome.runtime.sendMessage.mockImplementation((request, callback) => callback({ success: false, error }));
document.getElementById("github-login-btn").click();
await flushPromises();
expect(alert).toHaveBeenCalledWith(messages[key].message);
});
function revoke() {
consent = false;
removeConsent();
}
function expectLoggedOut() {
expect(document.getElementById("patreon-logged-in").style.display).toBe("none");
expect(document.getElementById("patreon-user-avatar").hasAttribute("src")).toBe(false);
}
test("does not verify or show a cached session whose storage read completes after revocation", async () => {
revoke();
readCachedSession({ patreonUser: cachedUser, patreonSessionToken: "test-token" });
await flushPromises();
expect(fetch).not.toHaveBeenCalled();
expectLoggedOut();
});
test.each(["revocation", "logout"])(
"does not restore an account or avatar from verification after %s",
async (event) => {
let resolveVerify;
fetch.mockReturnValue(new Promise((resolve) => (resolveVerify = resolve)));
readCachedSession({ patreonUser: cachedUser, patreonSessionToken: "test-token" });
await flushPromises();
expect(fetch).toHaveBeenCalledTimes(1);
if (event === "revocation") revoke();
else document.getElementById("patreon-logout-btn").click();
resolveVerify({ json: async () => ({ valid: true, membershipTier: "premium" }) });
await flushPromises();
expect(chrome.storage.sync.set).not.toHaveBeenCalled();
expectLoggedOut();
},
);
test.each(["patreon-login-btn", "github-login-btn"])(
"ignores a successful %s callback after revocation",
async (button) => {
let loginComplete;
chrome.runtime.sendMessage.mockImplementation((request, callback) => (loginComplete = callback));
document.getElementById(button).click();
await flushPromises();
expect(typeof loginComplete).toBe("function");
revoke();
loginComplete({ success: true, user: cachedUser });
await flushPromises();
expectLoggedOut();
},
);
});
@@ -0,0 +1,172 @@
jest.mock("../common/vote-client", () => ({ createVoteClient: jest.fn() }));
jest.mock("./src/data-collection-permissions", () => ({
hasAuthenticationDataPermission: jest.fn(),
onAuthenticationDataPermissionRemoved: jest.fn(),
usesFirefoxDataCollectionConsent: jest.fn(),
}));
const flushCallbacks = () => new Promise((resolve) => setTimeout(resolve, 0));
const VERSION = "4.0.5";
const CHANGELOG_URL = "moz-extension://test/changelog/4/changelog_4.0.html";
describe("background changelog lifecycle", () => {
let installedListener;
let startupListener;
let stored;
beforeEach(() => {
jest.resetModules();
stored = {};
require("../common/vote-client").createVoteClient.mockReturnValue({ ensureRegistered: () => Promise.resolve({}) });
global.__RYD_LIVE_TEST_BUILD__ = false;
global.chrome = {
runtime: {
getManifest: () => ({ version: VERSION }),
getURL: (relativePath) => `moz-extension://test/${relativePath}`,
onMessage: { addListener: jest.fn() },
onInstalled: { addListener: (listener) => (installedListener = listener) },
onStartup: { addListener: (listener) => (startupListener = listener) },
},
storage: {
sync: { get: jest.fn(), set: jest.fn(), remove: jest.fn() },
local: {
get: jest.fn((keys, callback) => queueMicrotask(() => callback({ ...stored }))),
set: jest.fn((values, callback) =>
queueMicrotask(() => {
Object.assign(stored, values);
callback?.();
}),
),
remove: jest.fn((keys, callback) =>
queueMicrotask(() => {
for (const key of [].concat(keys)) delete stored[key];
callback?.();
}),
),
},
onChanged: { addListener: jest.fn() },
},
tabs: {
create: jest.fn((properties, callback) => queueMicrotask(() => callback?.({ id: 1, ...properties }))),
},
};
// Firefox exposes the chrome callback namespace alongside browser.
global.browser = global.chrome;
jest.spyOn(console, "debug").mockImplementation(() => {});
require("./ryd.background");
});
afterEach(() => {
jest.restoreAllMocks();
delete global.chrome;
delete global.browser;
delete global.__RYD_LIVE_TEST_BUILD__;
});
test.each([false, true])("opens the changelog on a fresh install (temporary=%s)", async (temporary) => {
installedListener({ reason: "install", temporary });
await flushCallbacks();
expect(chrome.tabs.create).toHaveBeenCalledTimes(1);
expect(chrome.tabs.create).toHaveBeenCalledWith({ url: CHANGELOG_URL }, expect.any(Function));
expect(stored).toEqual({ lastShownChangelogVersion: VERSION });
});
test.each(["install", "update"])("preserves already-seen suppression on %s", async (reason) => {
stored.lastShownChangelogVersion = "4.0.4";
installedListener({ reason, temporary: true, previousVersion: "4.0.4" });
await flushCallbacks();
expect(chrome.tabs.create).not.toHaveBeenCalled();
expect(stored).toEqual({ lastShownChangelogVersion: "4.0.4" });
});
test("queues an unseen update and opens it on browser startup", async () => {
installedListener({ reason: "update", previousVersion: "4.0.4" });
await flushCallbacks();
expect(chrome.tabs.create).not.toHaveBeenCalled();
expect(stored).toEqual({ pendingChangelogVersion: VERSION });
startupListener();
await flushCallbacks();
expect(chrome.tabs.create).toHaveBeenCalledTimes(1);
expect(stored).toEqual({ lastShownChangelogVersion: VERSION });
startupListener();
await flushCallbacks();
expect(chrome.tabs.create).toHaveBeenCalledTimes(1);
});
test("opens an unseen temporary update immediately after the shown marker is cleared", async () => {
installedListener({ reason: "update", temporary: true, previousVersion: VERSION });
await flushCallbacks();
expect(chrome.tabs.create).toHaveBeenCalledTimes(1);
expect(chrome.tabs.create).toHaveBeenCalledWith({ url: CHANGELOG_URL }, expect.any(Function));
expect(stored).toEqual({ lastShownChangelogVersion: VERSION });
});
test("consumes an existing pending changelog when a temporary update opens it", async () => {
stored.pendingChangelogVersion = VERSION;
installedListener({ reason: "update", temporary: true, previousVersion: VERSION });
await flushCallbacks();
expect(chrome.tabs.create).toHaveBeenCalledTimes(1);
expect(stored).toEqual({ lastShownChangelogVersion: VERSION });
});
test("does not reopen the changelog on the next already-seen temporary reload", async () => {
const details = { reason: "update", temporary: true, previousVersion: VERSION };
installedListener(details);
await flushCallbacks();
expect(chrome.tabs.create).toHaveBeenCalledTimes(1);
installedListener(details);
await flushCallbacks();
expect(chrome.tabs.create).toHaveBeenCalledTimes(1);
expect(stored).toEqual({ lastShownChangelogVersion: VERSION });
});
test("opens a previously persisted pending version on startup", async () => {
stored.pendingChangelogVersion = VERSION;
startupListener();
await flushCallbacks();
expect(chrome.tabs.create).toHaveBeenCalledWith({ url: CHANGELOG_URL }, expect.any(Function));
expect(stored).toEqual({ lastShownChangelogVersion: VERSION });
});
test("clears a redundant pending update when the changelog was already shown", async () => {
stored.lastShownChangelogVersion = "4.0.4";
stored.pendingChangelogVersion = VERSION;
startupListener();
await flushCallbacks();
expect(chrome.tabs.create).not.toHaveBeenCalled();
expect(stored).toEqual({ lastShownChangelogVersion: "4.0.4" });
});
test("does not mark the changelog as shown when tabs.create reports a callback error", async () => {
chrome.tabs.create.mockImplementationOnce((properties, callback) =>
queueMicrotask(() => {
chrome.runtime.lastError = { message: "No browser window is available" };
try {
callback(undefined);
} finally {
delete chrome.runtime.lastError;
}
}),
);
installedListener({ reason: "install", temporary: true });
await flushCallbacks();
expect(chrome.tabs.create).toHaveBeenCalledTimes(1);
expect(stored.lastShownChangelogVersion).toBeUndefined();
expect(chrome.storage.local.set).not.toHaveBeenCalledWith(
expect.objectContaining({ lastShownChangelogVersion: expect.anything() }),
expect.any(Function),
);
installedListener({ reason: "install", temporary: true });
await flushCallbacks();
expect(chrome.tabs.create).toHaveBeenCalledTimes(2);
expect(stored).toEqual({ lastShownChangelogVersion: VERSION });
const shownWrites = chrome.storage.local.set.mock.calls.filter(([values]) =>
Object.prototype.hasOwnProperty.call(values, "lastShownChangelogVersion"),
);
expect(shownWrites).toHaveLength(1);
});
});
+144 -58
View File
@@ -1,6 +1,12 @@
import { config, getApiUrl, getApiEndpoint, getChangelogUrl } from "./src/config"; import { config, getApiUrl, getApiEndpoint, getChangelogUrl } from "./src/config";
import { createVoteClient } from "../common/vote-client"; import { createVoteClient } from "../common/vote-client";
import { createBrowserCredentialStore } from "./src/vote-client-adapter"; import { createBrowserCredentialStore } from "./src/vote-client-adapter";
import { loginWithGitHub } from "./src/github-auth";
import {
hasAuthenticationDataPermission,
onAuthenticationDataPermissionRemoved,
usesFirefoxDataCollectionConsent,
} from "./src/data-collection-permissions";
const apiUrl = getApiUrl(); const apiUrl = getApiUrl();
const voteDisabledIconName = config.voteDisabledIconName; const voteDisabledIconName = config.voteDisabledIconName;
@@ -11,10 +17,20 @@ const PENDING_CHANGELOG_STORAGE_KEY = "pendingChangelogVersion";
/** stores extension's global config */ /** stores extension's global config */
let extConfig = { ...config.defaultExtConfig }; let extConfig = { ...config.defaultExtConfig };
let authenticationGeneration = 0;
let accountStorageQueue = Promise.resolve();
if (isChrome()) api = chrome; if (isChrome()) api = chrome;
else if (isFirefox()) api = browser; else if (isFirefox()) api = browser;
if (__RYD_LIVE_TEST_BUILD__) {
globalThis.__RYD_LIVE_EXTENSION_BUILD__ = Object.freeze({
buildId: __RYD_LIVE_BUILD_ID__,
runtime: "extension",
version: api.runtime.getManifest().version,
});
}
const voteClient = createVoteClient({ const voteClient = createVoteClient({
apiBaseUrl: apiUrl, apiBaseUrl: apiUrl,
fetchImpl: (...args) => fetch(...args), fetchImpl: (...args) => fetch(...args),
@@ -25,8 +41,9 @@ const voteClient = createVoteClient({
initExtConfig(); initExtConfig();
voteClient.ensureRegistered().catch((error) => console.error("Vote registration failed", error)); voteClient.ensureRegistered().catch((error) => console.error("Vote registration failed", error));
function broadcastPatreonStatus(authenticated, user, sessionToken) { function broadcastPatreonStatus(authenticated, user, sessionToken, generation = authenticationGeneration) {
chrome.tabs.query({}, (tabs) => { chrome.tabs.query({}, (tabs) => {
if (generation !== authenticationGeneration) return;
tabs tabs
.filter((tab) => tab.url && tab.url.includes("youtube.com")) .filter((tab) => tab.url && tab.url.includes("youtube.com"))
.forEach((tab) => { .forEach((tab) => {
@@ -54,23 +71,83 @@ function broadcastPatreonStatus(authenticated, user, sessionToken) {
}); });
} }
function handlePatreonAuthComplete(user, sessionToken, done) { function handlePatreonAuthComplete(user, sessionToken, generation, done) {
if (!user) { if (!user || !sessionToken || generation !== authenticationGeneration) {
done?.(); done?.(false);
return; return;
} }
chrome.storage.sync.set( if (usesFirefoxDataCollectionConsent()) {
{ hasAuthenticationDataPermission({ queryBackground: false }).then((granted) => {
patreonAuthenticated: true, if (granted && generation === authenticationGeneration) persistPatreonAuth(user, sessionToken, generation, done);
patreonUser: user, else done?.(false);
patreonSessionToken: sessionToken, });
}, return;
() => { }
broadcastPatreonStatus(true, user, sessionToken);
done?.(); persistPatreonAuth(user, sessionToken, generation, done);
}, }
function persistPatreonAuth(user, sessionToken, generation, done) {
queueAccountStorageMutation((complete) => {
if (generation !== authenticationGeneration) {
complete();
return;
}
chrome.storage.sync.set(
{
patreonAuthenticated: true,
patreonUser: user,
patreonSessionToken: sessionToken,
},
complete,
);
})
.then(() => {
if (generation !== authenticationGeneration) {
done?.(false);
return;
}
broadcastPatreonStatus(true, user, sessionToken, generation);
done?.(true);
})
.catch(() => done?.(false));
}
function queueAccountStorageMutation(operation) {
const pending = accountStorageQueue.then(
() =>
new Promise((resolve, reject) => {
operation(() => {
if (chrome.runtime.lastError) reject(new Error(chrome.runtime.lastError.message));
else resolve();
});
}),
); );
accountStorageQueue = pending.catch(() => {});
return pending;
}
function clearPatreonAuth(done) {
const generation = ++authenticationGeneration;
broadcastPatreonStatus(false, null, null, generation);
queueAccountStorageMutation((complete) => {
chrome.storage.sync.remove(["patreonAuthenticated", "patreonUser", "patreonSessionToken"], complete);
})
.then(() => done?.())
.catch((error) => console.error("Account session cleanup failed", error));
}
onAuthenticationDataPermissionRemoved(() => clearPatreonAuth());
async function requireCurrentAuthenticationConsent(generation) {
if (
generation !== authenticationGeneration ||
(usesFirefoxDataCollectionConsent() && !(await hasAuthenticationDataPermission({ queryBackground: false }))) ||
generation !== authenticationGeneration
) {
throw new Error("authentication data consent removed or sign-in cancelled");
}
} }
function getIdentityApi() { function getIdentityApi() {
@@ -115,22 +192,11 @@ function extractOAuthParams(responseUrl) {
} }
api.runtime.onMessage.addListener((request, sender, sendResponse) => { api.runtime.onMessage.addListener((request, sender, sendResponse) => {
if (request.message === "get_auth_token") { if (request.message === "patreon_logout") {
chrome.identity.getAuthToken({ interactive: true }, function (token) { clearPatreonAuth();
console.log(token); } else if (request.message === "ryd_has_authentication_consent") {
chrome.identity.getProfileUserInfo(function (userInfo) { hasAuthenticationDataPermission({ queryBackground: false }).then((granted) => sendResponse({ granted }));
console.log(JSON.stringify(userInfo)); return true;
});
});
} else if (request.message === "log_off") {
// chrome.identity.clearAllCachedAuthTokens(() => console.log("logged off"));
} else if (request.message === "patreon_auth_complete") {
handlePatreonAuthComplete(request.user, request.sessionToken);
} else if (request.message === "patreon_logout") {
// Clear Patreon authentication
chrome.storage.sync.remove(["patreonAuthenticated", "patreonUser", "patreonSessionToken"], () => {
broadcastPatreonStatus(false, null, null);
});
} else if (request.message === "ryd_open_tab") { } else if (request.message === "ryd_open_tab") {
const targetUrl = typeof request?.url === "string" ? request.url : null; const targetUrl = typeof request?.url === "string" ? request.url : null;
if (!targetUrl) { if (!targetUrl) {
@@ -197,8 +263,11 @@ api.runtime.onMessage.addListener((request, sender, sendResponse) => {
.catch((error) => sendResponse?.({ success: false, error: error.message })); .catch((error) => sendResponse?.({ success: false, error: error.message }));
return true; return true;
} else if (request.message === "patreon_oauth_login") { } else if (request.message === "patreon_oauth_login") {
const generation = ++authenticationGeneration;
(async () => { (async () => {
try { try {
await requireCurrentAuthenticationConsent(generation);
const idApi = getIdentityApi(); const idApi = getIdentityApi();
if ( if (
!idApi || !idApi ||
@@ -220,6 +289,7 @@ api.runtime.onMessage.addListener((request, sender, sendResponse) => {
); );
const startData = await startRes.json(); const startData = await startRes.json();
await requireCurrentAuthenticationConsent(generation);
const responseUrl = await launchWebAuthFlow(startData.authUrl); const responseUrl = await launchWebAuthFlow(startData.authUrl);
const { code, state } = extractOAuthParams(responseUrl); const { code, state } = extractOAuthParams(responseUrl);
if (!code) { if (!code) {
@@ -227,6 +297,8 @@ api.runtime.onMessage.addListener((request, sender, sendResponse) => {
return; return;
} }
await requireCurrentAuthenticationConsent(generation);
const exchangeRes = await fetch(getApiEndpoint("/api/auth/oauth/exchange"), { const exchangeRes = await fetch(getApiEndpoint("/api/auth/oauth/exchange"), {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
@@ -239,8 +311,12 @@ api.runtime.onMessage.addListener((request, sender, sendResponse) => {
}); });
const authData = await exchangeRes.json(); const authData = await exchangeRes.json();
if (authData && authData.success) { if (authData && authData.success) {
handlePatreonAuthComplete(authData.user, authData.sessionToken, () => { handlePatreonAuthComplete(authData.user, authData.sessionToken, generation, (stored) => {
sendResponse({ success: true, user: authData.user }); sendResponse(
stored
? { success: true, user: authData.user }
: { success: false, error: "authentication data consent removed" },
);
}); });
} else { } else {
sendResponse({ success: false, error: (authData && authData.error) || "OAuth exchange failed" }); sendResponse({ success: false, error: (authData && authData.error) || "OAuth exchange failed" });
@@ -252,8 +328,11 @@ api.runtime.onMessage.addListener((request, sender, sendResponse) => {
})(); })();
return true; return true;
} else if (request.message === "github_oauth_login") { } else if (request.message === "github_oauth_login") {
const generation = ++authenticationGeneration;
(async () => { (async () => {
try { try {
await requireCurrentAuthenticationConsent(generation);
const idApi = getIdentityApi(); const idApi = getIdentityApi();
if ( if (
!idApi || !idApi ||
@@ -271,32 +350,18 @@ api.runtime.onMessage.addListener((request, sender, sendResponse) => {
? chrome.identity.getRedirectURL() ? chrome.identity.getRedirectURL()
: ""; : "";
const startRes = await fetch( const authData = await loginWithGitHub({
getApiEndpoint(`/api/auth/github/login?redirectUri=${encodeURIComponent(redirectUri)}`), redirectUri,
); launchWebAuthFlow,
const startData = await startRes.json(); requireConsent: () => requireCurrentAuthenticationConsent(generation),
const responseUrl = await launchWebAuthFlow(startData.authUrl);
const { code, state } = extractOAuthParams(responseUrl);
if (!code) {
sendResponse({ success: false, error: "No authorization code received" });
return;
}
const exchangeRes = await fetch(getApiEndpoint("/api/auth/github/exchange"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
code,
state,
expectedState: startData.state,
redirectUri: startData.redirectUri || redirectUri,
}),
}); });
const authData = await exchangeRes.json();
if (authData && authData.success) { if (authData && authData.success) {
handlePatreonAuthComplete(authData.user, authData.sessionToken, () => { handlePatreonAuthComplete(authData.user, authData.sessionToken, generation, (stored) => {
sendResponse({ success: true, user: authData.user }); sendResponse(
stored
? { success: true, user: authData.user }
: { success: false, error: "authentication data consent removed" },
);
}); });
} else { } else {
sendResponse({ success: false, error: (authData && authData.error) || "OAuth exchange failed" }); sendResponse({ success: false, error: (authData && authData.error) || "OAuth exchange failed" });
@@ -316,6 +381,7 @@ function openChangelogTab(version) {
api.tabs.create({ url }, () => { api.tabs.create({ url }, () => {
if (api.runtime.lastError) { if (api.runtime.lastError) {
console.debug("Changelog tab could not open:", api.runtime.lastError.message); console.debug("Changelog tab could not open:", api.runtime.lastError.message);
return;
} }
persistChangelogVersion(version); persistChangelogVersion(version);
}); });
@@ -427,7 +493,8 @@ function maybeShowChangelog(details) {
const manifest = api.runtime.getManifest(); const manifest = api.runtime.getManifest();
const currentVersion = manifest?.version; const currentVersion = manifest?.version;
const storage = api?.storage?.local; const storage = api?.storage?.local;
const isInstall = reason === "install"; // Temporary add-ons do not survive a browser restart, so their updates cannot wait for onStartup.
const showImmediately = reason === "install" || details.temporary === true;
const showChangelog = () => { const showChangelog = () => {
openChangelogTab(currentVersion || null); openChangelogTab(currentVersion || null);
@@ -450,7 +517,7 @@ function maybeShowChangelog(details) {
const lastShownValue = result?.[CHANGELOG_STORAGE_KEY]; const lastShownValue = result?.[CHANGELOG_STORAGE_KEY];
const pendingValue = result?.[PENDING_CHANGELOG_STORAGE_KEY]; const pendingValue = result?.[PENDING_CHANGELOG_STORAGE_KEY];
if (isInstall) { if (showImmediately) {
if (hasStoredValue(pendingValue)) { if (hasStoredValue(pendingValue)) {
clearPendingChangelogVersion(); clearPendingChangelogVersion();
} }
@@ -536,6 +603,9 @@ function storageChangeHandler(changes, area) {
if (changes.hidePremiumTeaser !== undefined) { if (changes.hidePremiumTeaser !== undefined) {
handleHidePremiumTeaserChangeEvent(changes.hidePremiumTeaser.newValue); handleHidePremiumTeaserChangeEvent(changes.hidePremiumTeaser.newValue);
} }
if (changes.hideClutterButtons !== undefined) {
handleHideClutterButtonsChangeEvent(changes.hideClutterButtons.newValue);
}
} }
function handleDisableVoteSubmissionChangeEvent(value) { function handleDisableVoteSubmissionChangeEvent(value) {
@@ -595,6 +665,10 @@ function handleHidePremiumTeaserChangeEvent(value) {
extConfig.hidePremiumTeaser = value === true; extConfig.hidePremiumTeaser = value === true;
} }
function handleHideClutterButtonsChangeEvent(value) {
extConfig.hideClutterButtons = value === true;
}
api.storage.onChanged.addListener(storageChangeHandler); api.storage.onChanged.addListener(storageChangeHandler);
function initExtConfig() { function initExtConfig() {
@@ -608,6 +682,7 @@ function initExtConfig() {
initializeTooltipPercentage(); initializeTooltipPercentage();
initializeTooltipPercentageMode(); initializeTooltipPercentageMode();
initializeHidePremiumTeaser(); initializeHidePremiumTeaser();
initializeHideClutterButtons();
} }
function initializeDisableVoteSubmission() { function initializeDisableVoteSubmission() {
@@ -712,6 +787,17 @@ function initializeHidePremiumTeaser() {
}); });
} }
function initializeHideClutterButtons() {
api.storage.sync.get(["hideClutterButtons"], (res) => {
if (res.hideClutterButtons === undefined) {
api.storage.sync.set({ hideClutterButtons: false });
extConfig.hideClutterButtons = false;
} else {
extConfig.hideClutterButtons = res.hideClutterButtons === true;
}
});
}
function isChrome() { function isChrome() {
return typeof chrome !== "undefined" && typeof chrome.runtime !== "undefined"; return typeof chrome !== "undefined" && typeof chrome.runtime !== "undefined";
} }
+239
View File
@@ -0,0 +1,239 @@
jest.mock("../common/vote-client", () => ({ createVoteClient: jest.fn() }));
jest.mock("./src/data-collection-permissions", () => ({
hasAuthenticationDataPermission: jest.fn(),
onAuthenticationDataPermissionRemoved: jest.fn(),
usesFirefoxDataCollectionConsent: jest.fn(),
}));
const flushPromises = () => new Promise((resolve) => setTimeout(resolve, 0));
const startData = () => {
const loginUrl = fetch.mock.calls.filter(([url]) => url.includes("/github/login?")).at(-1)?.[0];
const params = new URLSearchParams({
state: "test",
redirect_uri: "https://extension.example/callback",
code_challenge: loginUrl ? new URL(loginUrl).searchParams.get("codeChallenge") : "unused",
code_challenge_method: "S256",
});
return {
authUrl: `https://github.com/login/oauth/authorize?${params}`,
state: "test",
redirectUri: "https://extension.example/callback",
};
};
const deferred = () => {
let resolve;
const promise = new Promise((done) => (resolve = done));
return { promise, resolve };
};
describe("background account consent lifecycle", () => {
let messageListener;
let removeConsent;
let consent;
let originalCrypto;
beforeEach(() => {
jest.resetModules();
consent = true;
originalCrypto = Object.getOwnPropertyDescriptor(globalThis, "crypto");
Object.defineProperty(globalThis, "crypto", {
configurable: true,
value: {
getRandomValues: (bytes) => bytes.fill(1),
subtle: {
digest: async (algorithm, bytes) => require("node:crypto").createHash("sha256").update(bytes).digest(),
},
},
});
const permissions = require("./src/data-collection-permissions");
permissions.usesFirefoxDataCollectionConsent.mockReturnValue(true);
permissions.hasAuthenticationDataPermission.mockImplementation(() => Promise.resolve(consent));
permissions.onAuthenticationDataPermissionRemoved.mockImplementation((listener) => (removeConsent = listener));
require("../common/vote-client").createVoteClient.mockReturnValue({ ensureRegistered: () => Promise.resolve({}) });
global.__RYD_LIVE_TEST_BUILD__ = false;
global.chrome = {
runtime: {
getManifest: () => ({ version: "4.0.5" }),
onMessage: { addListener: (listener) => (messageListener = listener) },
onInstalled: { addListener: jest.fn() },
},
identity: {
getRedirectURL: () => "https://extension.example/callback",
launchWebAuthFlow: jest.fn(() => Promise.resolve("https://extension.example/callback?code=test&state=test")),
},
storage: {
sync: {
get: jest.fn(),
set: jest.fn((data, callback) => callback?.()),
remove: jest.fn((keys, callback) => callback?.()),
},
onChanged: { addListener: jest.fn() },
},
tabs: {
query: jest.fn((query, callback) => callback([{ id: 1, url: "https://www.youtube.com/watch?v=video123456" }])),
sendMessage: jest.fn(),
},
};
global.browser = global.chrome;
global.fetch = jest.fn();
jest.spyOn(console, "error").mockImplementation(() => {});
require("./ryd.background");
});
afterEach(() => {
if (originalCrypto) Object.defineProperty(globalThis, "crypto", originalCrypto);
else delete globalThis.crypto;
jest.restoreAllMocks();
delete global.chrome;
delete global.browser;
delete global.fetch;
delete global.__RYD_LIVE_TEST_BUILD__;
});
function revoke() {
consent = false;
removeConsent();
}
test.each(["patreon_oauth_login", "github_oauth_login"])(
"%s sends nothing when consent is denied",
async (message) => {
consent = false;
const response = jest.fn();
messageListener({ message }, {}, response);
await flushPromises();
expect(fetch).not.toHaveBeenCalled();
expect(chrome.identity.launchWebAuthFlow).not.toHaveBeenCalled();
expect(response).toHaveBeenCalledWith(expect.objectContaining({ success: false }));
},
);
test.each(["patreon_oauth_login", "github_oauth_login"])(
"%s does not open OAuth after consent is revoked during startup",
async (message) => {
const start = deferred();
fetch.mockReturnValueOnce(start.promise);
messageListener({ message }, {}, jest.fn());
await flushPromises();
revoke();
start.resolve({ ok: true, json: async () => startData() });
await flushPromises();
expect(chrome.identity.launchWebAuthFlow).not.toHaveBeenCalled();
expect(fetch).toHaveBeenCalledTimes(1);
},
);
test.each(["patreon_oauth_login", "github_oauth_login"])(
"%s does not exchange an OAuth code after logout",
async (message) => {
const oauth = deferred();
fetch.mockResolvedValue({ ok: true, json: async () => startData() });
chrome.identity.launchWebAuthFlow.mockReturnValue(oauth.promise);
messageListener({ message }, {}, jest.fn());
await flushPromises();
messageListener({ message: "patreon_logout" }, {}, jest.fn());
oauth.resolve("https://extension.example/callback?code=test&state=test");
await flushPromises();
expect(fetch).toHaveBeenCalledTimes(1);
expect(chrome.storage.sync.set).not.toHaveBeenCalled();
},
);
test.each(["patreon_oauth_login", "github_oauth_login"])(
"%s discards an exchange result arriving after revocation",
async (message) => {
const exchange = deferred();
fetch.mockResolvedValueOnce({ ok: true, json: async () => startData() }).mockReturnValueOnce(exchange.promise);
const response = jest.fn();
messageListener({ message }, {}, response);
await flushPromises();
expect(fetch).toHaveBeenCalledTimes(2);
revoke();
exchange.resolve({
ok: true,
json: async () => ({ success: true, user: { fullName: "Test" }, sessionToken: "test-token" }),
});
await flushPromises();
expect(chrome.storage.sync.set).not.toHaveBeenCalled();
expect(response).toHaveBeenCalledWith(expect.objectContaining({ success: false }));
},
);
test.each(["logout", "revocation"])(
"clears a pending stored session after %s even if its save completes later",
async (event) => {
let saved;
const stored = {};
chrome.storage.sync.set.mockImplementation((data, callback) => {
saved = () => {
Object.assign(stored, data);
callback();
};
});
chrome.storage.sync.remove.mockImplementation((keys, callback) => {
for (const key of keys) delete stored[key];
callback();
});
fetch.mockResolvedValueOnce({ ok: true, json: async () => startData() }).mockResolvedValueOnce({
ok: true,
json: async () => ({ success: true, user: { fullName: "Test" }, sessionToken: "test-token" }),
});
const response = jest.fn();
messageListener({ message: "patreon_oauth_login" }, {}, response);
await flushPromises();
if (event === "revocation") revoke();
else messageListener({ message: "patreon_logout" }, {}, jest.fn());
await flushPromises();
expect(chrome.storage.sync.remove).not.toHaveBeenCalled();
saved();
await flushPromises();
expect(stored).toEqual({});
expect(chrome.storage.sync.remove).toHaveBeenCalledTimes(1);
expect(chrome.tabs.sendMessage.mock.calls.every(([, message]) => message.authenticated === false)).toBe(true);
expect(response).toHaveBeenCalledWith(expect.objectContaining({ success: false }));
},
);
test("keeps the new account when an old save finishes after logout and a new sign-in", async () => {
let completeOldSave;
const stored = {};
chrome.storage.sync.set
.mockImplementationOnce((data, callback) => {
completeOldSave = () => {
Object.assign(stored, data);
callback();
};
})
.mockImplementation((data, callback) => {
Object.assign(stored, data);
callback();
});
chrome.storage.sync.remove.mockImplementation((keys, callback) => {
for (const key of keys) delete stored[key];
callback();
});
const start = { ok: true, json: async () => startData() };
fetch
.mockResolvedValueOnce(start)
.mockResolvedValueOnce({
ok: true,
json: async () => ({ success: true, user: { fullName: "Old" }, sessionToken: "old-token" }),
})
.mockResolvedValueOnce(start)
.mockResolvedValueOnce({
ok: true,
json: async () => ({ success: true, user: { fullName: "New" }, sessionToken: "new-token" }),
});
messageListener({ message: "patreon_oauth_login" }, {}, jest.fn());
await flushPromises();
messageListener({ message: "patreon_logout" }, {}, jest.fn());
const newResponse = jest.fn();
messageListener({ message: "github_oauth_login" }, {}, newResponse);
await flushPromises();
completeOldSave();
await flushPromises();
expect(stored.patreonSessionToken).toBe("new-token");
expect(stored.patreonUser.fullName).toBe("New");
expect(newResponse).toHaveBeenCalledWith(expect.objectContaining({ success: true }));
});
});
+349 -44
View File
@@ -1,25 +1,49 @@
import { getButtons, getDislikeButton, getLikeButton, hasRenderedBox, markButtonsForVideo } from "./src/buttons"; import {
ensureSyntheticShortsDislikeButton,
getButtonControls,
getButtons,
getDislikeTextContainer,
getShortsCandidateVideoId,
getShortsControlSurfaceSignature,
hasRenderedBox,
markButtonsForVideo,
setSyntheticShortsDislikeEnabled,
shortsControlSurfaceIsReadyForMutation,
shortsNativeControlInventoryIsReadyForFallback,
} from "./src/buttons";
import { import {
hasLoadedStateForVideo, hasLoadedStateForVideo,
initExtConfig, initExtConfig,
isLikesDisabled, isLikesDisabled,
isShorts, isShorts,
clearRenderedVoteState,
restoreCurrentState, restoreCurrentState,
setInitialState, setInitialState,
} from "./src/state"; } from "./src/state";
import { getBrowser, getVideoId, isVideoLoaded } from "./src/utils"; import { getBrowser, getVideoId, isVideoLoaded } from "./src/utils";
import { addLikeDislikeEventListener, createSmartimationObserver, storageChangeHandler } from "./src/events"; import { addLikeDislikeEventListener, createSmartimationObserver, storageChangeHandler } from "./src/events";
import { createInitializationCycleRunner } from "./src/initialization-cycle"; import { hasUsableRateBar } from "./src/bar";
import {
createInitializationCycleRunner,
createPendingNavigationTracker,
pendingIncompleteShortsControlsCanInitialize,
pendingNavigationControlsAreReady,
reactionControlsCanInitialize,
} from "./src/initialization-cycle";
import { initPatreonFeatures } from "./src/patreon"; import { initPatreonFeatures } from "./src/patreon";
import { cancelObsoleteVoteDataRequests, clearVoteDataRequestCache } from "./src/vote-data-request";
import {
actionBarHasHydratedData,
captureShortsNativeControlInventory,
getShortsControlSurfaceStabilityMs,
shortsNativeControlInventoryMatches,
} from "../common/shorts-control-readiness";
if (__RYD_LIVE_TEST_BUILD__) { if (__RYD_LIVE_TEST_BUILD__) {
document.documentElement.setAttribute("data-ryd-extension-version", getBrowser().runtime.getManifest().version); document.documentElement.setAttribute("data-ryd-extension-version", getBrowser().runtime.getManifest().version);
document.documentElement.setAttribute("data-ryd-extension-build", __RYD_LIVE_BUILD_ID__); document.documentElement.setAttribute("data-ryd-extension-build", __RYD_LIVE_BUILD_ID__);
} }
await initExtConfig();
initPatreonFeatures();
let jsInitChecktimer = null; let jsInitChecktimer = null;
let isSetInitialStateDone = false; let isSetInitialStateDone = false;
let isStorageListenerRegistered = false; let isStorageListenerRegistered = false;
@@ -29,7 +53,52 @@ let initializedVideoId = null;
let initializedButtons = null; let initializedButtons = null;
let initializedLikeButton = null; let initializedLikeButton = null;
let initializedDislikeButton = null; let initializedDislikeButton = null;
let initializedNativeLikeButton = null;
let initializedNativeDislikeButton = null;
let initializationCheckRunning = false; let initializationCheckRunning = false;
let pendingShortsMutationSurface = null;
let shortsRouteEpoch = 0;
let observedRouteKey = null;
const pendingNavigation = createPendingNavigationTracker();
const SHORTS_HYDRATION_ATTRIBUTE = "data-ryd-shorts-action-bar-hydrated";
const SHORTS_HYDRATION_REQUEST_EVENT = "ryd-shorts-action-bar-hydration-request";
function injectPageWorldHelpers() {
return new Promise((resolve) => {
const script = document.createElement("script");
script.src = getBrowser().runtime.getURL("menu-fixer.js");
script.onload = function () {
this.remove();
resolve(true);
};
script.onerror = function () {
this.remove();
console.warn("Page-world helpers failed to load; Shorts initialization will retry.");
resolve(false);
};
(document.head || document.documentElement).appendChild(script);
});
}
function pageWorldActionBarHasHydratedData(actionBar) {
if (!actionBar?.matches?.("reel-action-bar-view-model")) return actionBarHasHydratedData(actionBar);
actionBar.removeAttribute(SHORTS_HYDRATION_ATTRIBUTE);
actionBar.dispatchEvent(new Event(SHORTS_HYDRATION_REQUEST_EVENT, { bubbles: false }));
const hydrated = actionBar.getAttribute(SHORTS_HYDRATION_ATTRIBUTE) === "true";
actionBar.removeAttribute(SHORTS_HYDRATION_ATTRIBUTE);
return hydrated;
}
const pageWorldHelpersReady = injectPageWorldHelpers();
await initExtConfig();
await pageWorldHelpersReady;
initPatreonFeatures();
// Settings that affect page-world helpers must continue to update even when
// YouTube's reaction controls have not finished initializing.
getBrowser().storage.onChanged.addListener(storageChangeHandler);
isStorageListenerRegistered = true;
function ensureShortsNavigationObserver() { function ensureShortsNavigationObserver() {
if (!isShorts()) { if (!isShorts()) {
@@ -38,18 +107,35 @@ function ensureShortsNavigationObserver() {
const shortsRoot = document.querySelector("ytd-shorts"); const shortsRoot = document.querySelector("ytd-shorts");
if (!shortsRoot) { if (!shortsRoot) {
if (shortsNavigationObserverTarget && !shortsNavigationObserverTarget.isConnected) {
shortsNavigationObserver?.disconnect();
shortsNavigationObserverTarget = null;
}
return; return;
} }
if (!shortsNavigationObserver) { if (!shortsNavigationObserver) {
shortsNavigationObserver = new MutationObserver((mutations) => { shortsNavigationObserver = new MutationObserver((mutations) => {
for (const mutation of mutations) { for (const mutation of mutations) {
if ( const activatedRenderer =
mutation.type === "attributes" && mutation.type === "attributes" &&
mutation.attributeName === "is-active" && mutation.attributeName === "is-active" &&
mutation.target.tagName === "YTD-REEL-VIDEO-RENDERER" && mutation.target.tagName === "YTD-REEL-VIDEO-RENDERER" &&
mutation.target.hasAttribute("is-active") mutation.target.hasAttribute("is-active");
) { const currentShortsLinkChanged =
mutation.type === "attributes" &&
mutation.attributeName === "href" &&
mutation.target.matches?.("a[href*='/shorts/']") &&
getShortsCandidateVideoId(getButtons()) === getVideoId(window.location.href);
const nativeActionTreeChanged =
mutation.type === "childList" &&
[...mutation.addedNodes, ...mutation.removedNodes].some(
(node) =>
node.nodeType === Node.ELEMENT_NODE &&
(node.matches?.("like-button-view-model, dislike-button-view-model, reel-action-bar-view-model") ||
node.querySelector?.("like-button-view-model, dislike-button-view-model, reel-action-bar-view-model")),
);
if (activatedRenderer || currentShortsLinkChanged || nativeActionTreeChanged) {
triggerInitializationCycle(); triggerInitializationCycle();
break; break;
} }
@@ -61,53 +147,196 @@ function ensureShortsNavigationObserver() {
shortsNavigationObserver.disconnect(); shortsNavigationObserver.disconnect();
shortsNavigationObserver.observe(shortsRoot, { shortsNavigationObserver.observe(shortsRoot, {
attributes: true, attributes: true,
childList: true,
subtree: true, subtree: true,
attributeFilter: ["is-active"], attributeFilter: ["href", "is-active"],
}); });
shortsNavigationObserverTarget = shortsRoot; shortsNavigationObserverTarget = shortsRoot;
} }
} }
function syncShortsRouteEpoch() {
const videoId = getVideoId(window.location.href);
const routeKey = `${isShorts() ? "shorts" : "other"}:${videoId ?? "none"}`;
if (observedRouteKey !== routeKey) {
cancelObsoleteVoteDataRequests(videoId);
observedRouteKey = routeKey;
shortsRouteEpoch += 1;
pendingShortsMutationSurface = null;
}
}
function getStableShortsControls(videoId) {
const buttons = getButtons();
const isHydrated = pageWorldActionBarHasHydratedData(buttons);
const needsSyntheticDislike =
buttons?.tagName === "REEL-ACTION-BAR-VIEW-MODEL" &&
(!buttons.querySelector("dislike-button-view-model, #dislike-button") ||
buttons.querySelector("[data-ryd-synthetic-shorts-dislike]"));
if (!needsSyntheticDislike && isHydrated) {
pendingShortsMutationSurface = null;
return buttons ? getButtonControls(buttons) : null;
}
const inventory = captureShortsNativeControlInventory(buttons);
if (
!buttons ||
!inventory ||
(!isHydrated && !shortsNativeControlInventoryIsReadyForFallback(inventory)) ||
!shortsControlSurfaceIsReadyForMutation(buttons, videoId, {
allowUnhydratedFallback: !isHydrated,
isHydrated,
isStable: true,
})
) {
pendingShortsMutationSurface = null;
return null;
}
const signature = getShortsControlSurfaceSignature(buttons);
const now = performance.now();
if (
pendingShortsMutationSurface?.buttons !== buttons ||
pendingShortsMutationSurface.routeEpoch !== shortsRouteEpoch ||
pendingShortsMutationSurface.videoId !== videoId ||
pendingShortsMutationSurface.signature !== signature ||
!shortsNativeControlInventoryMatches(pendingShortsMutationSurface.inventory, inventory)
) {
pendingShortsMutationSurface = {
buttons,
inventory,
observedAt: now,
routeEpoch: shortsRouteEpoch,
signature,
unhydratedObservedAt: isHydrated ? null : now,
videoId,
};
return null;
}
if (isHydrated) {
pendingShortsMutationSurface.unhydratedObservedAt = null;
} else if (pendingShortsMutationSurface.unhydratedObservedAt == null) {
pendingShortsMutationSurface.unhydratedObservedAt = now;
}
const stabilityObservedAt = isHydrated
? pendingShortsMutationSurface.observedAt
: pendingShortsMutationSurface.unhydratedObservedAt;
if (now - stabilityObservedAt < getShortsControlSurfaceStabilityMs(isHydrated)) {
return null;
}
const allowUnhydratedFallback = !isHydrated;
const preMutationButtons = getButtons();
const preMutationInventory = captureShortsNativeControlInventory(preMutationButtons);
const preMutationIsHydrated = pageWorldActionBarHasHydratedData(preMutationButtons);
const preMutationAllowsFallback = !preMutationIsHydrated && allowUnhydratedFallback;
if (
preMutationButtons !== buttons ||
!shortsNativeControlInventoryMatches(inventory, preMutationInventory) ||
(preMutationAllowsFallback && !shortsNativeControlInventoryIsReadyForFallback(preMutationInventory)) ||
getShortsControlSurfaceSignature(preMutationButtons) !== signature ||
!shortsControlSurfaceIsReadyForMutation(preMutationButtons, videoId, {
allowUnhydratedFallback: preMutationAllowsFallback,
isHydrated: preMutationIsHydrated,
isStable: true,
})
) {
pendingShortsMutationSurface = null;
return null;
}
if (needsSyntheticDislike) {
ensureSyntheticShortsDislikeButton(preMutationButtons, {
allowUnhydratedFallback: preMutationAllowsFallback,
currentVideoId: videoId,
isHydrated: preMutationIsHydrated,
isStable: true,
});
}
const confirmedButtons = getButtons();
const confirmedInventory = captureShortsNativeControlInventory(confirmedButtons);
const confirmedIsHydrated = pageWorldActionBarHasHydratedData(confirmedButtons);
if (
confirmedButtons !== buttons ||
!shortsNativeControlInventoryMatches(inventory, confirmedInventory) ||
(preMutationAllowsFallback && !shortsNativeControlInventoryIsReadyForFallback(confirmedInventory)) ||
getShortsControlSurfaceSignature(confirmedButtons) !== signature ||
!shortsControlSurfaceIsReadyForMutation(confirmedButtons, videoId, {
allowUnhydratedFallback: !confirmedIsHydrated && preMutationAllowsFallback,
isHydrated: confirmedIsHydrated,
isStable: true,
})
) {
pendingShortsMutationSurface = null;
return null;
}
pendingShortsMutationSurface = null;
return getButtonControls(confirmedButtons);
}
async function checkForInitialization() { async function checkForInitialization() {
if (initializationCheckRunning) return; if (initializationCheckRunning) return;
initializationCheckRunning = true; initializationCheckRunning = true;
try { try {
syncShortsRouteEpoch();
if (isShorts()) { if (isShorts()) {
ensureShortsNavigationObserver(); ensureShortsNavigationObserver();
} }
const buttons = getButtons();
const videoId = getVideoId(window.location.href); const videoId = getVideoId(window.location.href);
if ((isShorts() && isVideoLoaded()) || (hasRenderedBox(buttons) && isVideoLoaded())) { const pendingOrigin = pendingNavigation.get();
if (!buttons) return; if (pendingOrigin && (!pendingOrigin.videoId || pendingOrigin.videoId === videoId)) return;
const likeButton = getLikeButton(); const controls = isShorts() ? getStableShortsControls(videoId) : getButtonControls();
const dislikeButton = getDislikeButton(); if (!controls) return;
if (!likeButton || !dislikeButton) return; const { buttons, dislikeButton, likeButton, nativeDislikeButton, nativeLikeButton } = controls;
if (jsInitChecktimer !== null) { const shortsRoute = isShorts();
clearInterval(jsInitChecktimer); if (
jsInitChecktimer = null; controls.ready &&
} reactionControlsCanInitialize({
markButtonsForVideo(buttons, videoId); hasRenderedButtons: hasRenderedBox(buttons),
createSmartimationObserver(); isShortsRoute: shortsRoute,
addLikeDislikeEventListener(); isVideoLoaded: !shortsRoute && isVideoLoaded(),
})
) {
if (!getDislikeTextContainer(controls)) return;
createSmartimationObserver(buttons);
addLikeDislikeEventListener(likeButton, dislikeButton);
let rendered;
if (hasLoadedStateForVideo(videoId)) { if (hasLoadedStateForVideo(videoId)) {
restoreCurrentState(); rendered = restoreCurrentState();
} else { } else {
await setInitialState(); rendered = await setInitialState();
} }
if (rendered === false) return;
const currentControls = getButtonControls();
if ( if (
videoId !== getVideoId(window.location.href) || videoId !== getVideoId(window.location.href) ||
buttons !== getButtons() || !currentControls.ready ||
likeButton !== getLikeButton() || !currentControls.dislikeTextContainer ||
dislikeButton !== getDislikeButton() buttons !== currentControls.buttons ||
likeButton !== currentControls.likeButton ||
dislikeButton !== currentControls.dislikeButton ||
nativeLikeButton !== currentControls.nativeLikeButton ||
nativeDislikeButton !== currentControls.nativeDislikeButton
) { ) {
return; return;
} }
setSyntheticShortsDislikeEnabled(true, currentControls.dislikeButton);
markButtonsForVideo(buttons, videoId);
initializedVideoId = videoId; initializedVideoId = videoId;
initializedButtons = buttons; initializedButtons = buttons;
initializedLikeButton = likeButton; initializedLikeButton = likeButton;
initializedDislikeButton = dislikeButton; initializedDislikeButton = dislikeButton;
initializedNativeLikeButton = nativeLikeButton;
initializedNativeDislikeButton = nativeDislikeButton;
pendingNavigation.clear();
isSetInitialStateDone = true; isSetInitialStateDone = true;
if (jsInitChecktimer !== null) {
clearInterval(jsInitChecktimer);
jsInitChecktimer = null;
}
if (!isStorageListenerRegistered) { if (!isStorageListenerRegistered) {
getBrowser().storage.onChanged.addListener(storageChangeHandler); getBrowser().storage.onChanged.addListener(storageChangeHandler);
isStorageListenerRegistered = true; isStorageListenerRegistered = true;
@@ -151,44 +380,120 @@ async function setEventListeners() {
await triggerInitializationCycle(); await triggerInitializationCycle();
} }
await setEventListeners(); document.addEventListener("yt-navigate-start", function () {
clearVoteDataRequestCache();
shortsRouteEpoch += 1;
pendingShortsMutationSurface = null;
pendingNavigation.begin(() => {
let controls;
if (initializedButtons) {
controls = {
buttons: initializedButtons,
dislikeButton: initializedDislikeButton,
likeButton: initializedLikeButton,
nativeDislikeButton: initializedNativeDislikeButton,
nativeLikeButton: initializedNativeLikeButton,
};
} else {
const currentControls = getButtonControls();
controls = currentControls.ready ? currentControls : null;
}
return {
controls,
videoId: initializedVideoId ?? getVideoId(window.location.href),
};
});
if (jsInitChecktimer !== null) {
clearInterval(jsInitChecktimer);
jsInitChecktimer = null;
}
clearRenderedVoteState(initializedButtons ? getButtonControls(initializedButtons) : undefined);
initializedVideoId = null;
initializedButtons = null;
initializedLikeButton = null;
initializedDislikeButton = null;
initializedNativeLikeButton = null;
initializedNativeDislikeButton = null;
isSetInitialStateDone = false;
});
document.addEventListener("yt-navigate-finish", async function (event) { document.addEventListener("yt-navigate-finish", async function (event) {
pendingNavigation.clear();
syncShortsRouteEpoch();
await setEventListeners(); await setEventListeners();
}); });
function watchControlsNeedInitialization() { function watchControlsNeedInitialization() {
const videoId = getVideoId(window.location.href); const videoId = getVideoId(window.location.href);
if (!videoId || initializationCycle.isRunning() || jsInitChecktimer !== null) return false; if (!videoId) return false;
const buttons = getButtons();
if (!buttons) return true; let controls;
const likeButton = getLikeButton(); const pendingOrigin = pendingNavigation.get();
const dislikeButton = getDislikeButton(); if (pendingOrigin) {
if (!pendingOrigin.videoId || videoId === pendingOrigin.videoId) return false;
controls = getButtonControls();
if (!controls.ready) {
// getButtons() returns no surface when the visible Shorts candidates are
// ambiguous or when YouTube has replaced the Shorts root before mounting
// its action bar. Starting the cycle for a changed route is non-mutating;
// getStableShortsControls() still owns every identity, hydration,
// inventory, viewport, and stability guard.
return pendingIncompleteShortsControlsCanInitialize({
destinationVideoId: videoId,
isShortsRoute: isShorts(),
previousVideoId: pendingOrigin.videoId,
});
}
const pendingControlsReady = pendingNavigationControlsAreReady({
currentControls: controls,
destinationVideoId: videoId,
previousControls: pendingOrigin.controls,
shortsControlsVideoId: isShorts() ? getShortsCandidateVideoId(controls.buttons) : null,
});
if (!pendingControlsReady) {
return false;
}
}
if (initializationCycle.isRunning() || jsInitChecktimer !== null) return false;
controls ??= getButtonControls();
if (!controls.ready) return true;
const { buttons, dislikeButton, likeButton, nativeDislikeButton, nativeLikeButton } = controls;
const syntheticShortsDislikes = isShorts()
? buttons?.querySelectorAll("[data-ryd-synthetic-shorts-dislike]").length ?? 0
: 0;
const hasNativeShortsDislike = Boolean(
isShorts() && buttons?.querySelector("dislike-button-view-model, #dislike-button"),
);
return ( return (
initializedVideoId !== videoId || initializedVideoId !== videoId ||
initializedButtons !== buttons || initializedButtons !== buttons ||
initializedLikeButton !== likeButton || initializedLikeButton !== likeButton ||
initializedDislikeButton !== dislikeButton || initializedDislikeButton !== dislikeButton ||
initializedNativeLikeButton !== nativeLikeButton ||
initializedNativeDislikeButton !== nativeDislikeButton ||
!buttons?.isConnected || !buttons?.isConnected ||
!likeButton?.isConnected || !likeButton?.isConnected ||
!dislikeButton?.isConnected || !dislikeButton?.isConnected ||
!nativeLikeButton?.isConnected ||
!nativeDislikeButton?.isConnected ||
!buttons?.contains(likeButton) || !buttons?.contains(likeButton) ||
!buttons?.contains(dislikeButton) || !buttons?.contains(dislikeButton) ||
(!isShorts() && !likeButton?.contains(nativeLikeButton) ||
hasLoadedStateForVideo(videoId) && !dislikeButton?.contains(nativeDislikeButton) ||
!isLikesDisabled() && (isShorts() &&
!buttons.querySelector("#ryd-bar-container")) ((hasNativeShortsDislike && syntheticShortsDislikes > 0) ||
(!hasNativeShortsDislike && syntheticShortsDislikes !== 1))) ||
(!isShorts() && hasLoadedStateForVideo(videoId) && !isLikesDisabled() && !hasUsableRateBar(buttons, videoId))
); );
} }
setInterval(() => { setInterval(() => {
syncShortsRouteEpoch();
if (isShorts()) ensureShortsNavigationObserver();
if (watchControlsNeedInitialization()) void triggerInitializationCycle(); if (watchControlsNeedInitialization()) void triggerInitializationCycle();
}, 500); }, 500);
const s = document.createElement("script"); // Navigation must be observable even while the first count request is pending.
s.src = chrome.runtime.getURL("menu-fixer.js"); await setEventListeners();
s.onload = function () {
this.remove();
};
(document.head || document.documentElement).appendChild(s);
@@ -0,0 +1,38 @@
const fs = require("fs");
const path = require("path");
const extensionRoot = path.resolve(__dirname, "..");
function readExtensionFile(relativePath) {
return fs.readFileSync(path.join(extensionRoot, relativePath), "utf8");
}
describe("Firefox AMO policy declarations", () => {
test("declares every transmitted data category and the built-in consent minimum", () => {
const manifest = JSON.parse(readExtensionFile("manifest-firefox.json"));
const gecko = manifest.browser_specific_settings.gecko;
expect(manifest.incognito).toBe("not_allowed");
expect(gecko.id).toBe("{762f9885-5a13-4abd-9c77-433dcd38b8fd}");
expect(gecko.strict_min_version).toBe("140.0");
expect(gecko.data_collection_permissions).toEqual({
required: ["personallyIdentifyingInfo", "browsingActivity", "websiteContent", "websiteActivity"],
optional: ["authenticationInfo"],
});
expect(manifest.browser_specific_settings.gecko_android).toBeUndefined();
});
test("does not contact ancillary services when extension pages open", () => {
const popupHtml = readExtensionFile("popup.html");
const popupScript = readExtensionFile("popup.js");
const changelog3 = readExtensionFile("changelog/3/changelog_3.0.html");
const changelog4 = readExtensionFile("changelog/4/changelog_4.0.html");
for (const page of [popupHtml, changelog3, changelog4]) {
expect(page).not.toMatch(/<(?:link|script)[^>]+https?:\/\//i);
}
expect(popupScript).not.toContain("raw.githubusercontent.com");
expect(popupScript).not.toContain("videoId=YbJOTdZBX1g");
});
});
+87 -29
View File
@@ -1,6 +1,6 @@
import { getButtons, getDislikeButton, getLikeButton } from "./buttons"; import { getButtons, getDislikeButton, getLikeButton } from "./buttons";
import { extConfig, isMobile, isLikesDisabled, isNewDesign, isRoundedDesign, isShorts } from "./state"; import { extConfig, isMobile, isLikesDisabled, isNewDesign, isRoundedDesign, isShorts } from "./state";
import { getColorFromTheme, isInViewport, querySelector } from "./utils"; import { getColorFromTheme, getVideoId, isInViewport, querySelector } from "./utils";
function closestConfigured(element, selectors) { function closestConfigured(element, selectors) {
for (const selector of Array.isArray(selectors) ? selectors : [selectors]) { for (const selector of Array.isArray(selectors) ? selectors : [selectors]) {
@@ -18,16 +18,85 @@ function findInCurrentWatchTree(buttons, selectors, fallbackScope = null) {
return scope ? querySelector(selectors, scope) : undefined; return scope ? querySelector(selectors, scope) : undefined;
} }
function findRateBarOwner(buttons, rateBar) {
let owner = rateBar;
while (owner?.parentElement && owner.parentElement !== buttons) {
owner = owner.parentElement;
}
return owner?.parentElement === buttons ? owner : rateBar;
}
function hasHiddenOrCollapsedStyle(element) {
if (!element || element.hidden) return true;
const style = window.getComputedStyle(element);
if (
style.display === "none" ||
style.visibility === "hidden" ||
style.visibility === "collapse" ||
Number.parseFloat(style.opacity || "1") === 0
) {
return true;
}
const explicitWidth = element.style.width.trim();
return explicitWidth !== "" && Number.parseFloat(explicitWidth) === 0;
}
function getRateBarParts(buttons) {
const containers = buttons ? [...buttons.querySelectorAll("#ryd-bar-container")] : [];
const rateBar = containers[0] ?? null;
const owner = rateBar ? findRateBarOwner(buttons, rateBar) : null;
const wrapper = rateBar?.closest(".ryd-tooltip") ?? null;
return {
containers,
fill: rateBar?.querySelector("#ryd-bar") ?? null,
owner,
rateBar,
tooltip: owner?.querySelector("#ryd-dislike-tooltip") ?? null,
wrapper,
};
}
function hasUsableRateBar(buttons = getButtons(), videoId = getVideoId(window.location.href)) {
const { containers, fill, owner, rateBar, tooltip, wrapper } = getRateBarParts(buttons);
return Boolean(
videoId &&
containers.length === 1 &&
rateBar &&
fill &&
tooltip &&
wrapper &&
owner === wrapper &&
wrapper.parentElement === buttons &&
wrapper.getAttribute("data-ryd-video-id") === videoId &&
!hasHiddenOrCollapsedStyle(wrapper) &&
!hasHiddenOrCollapsedStyle(rateBar),
);
}
function removeRateBarParts(buttons) {
const owners = new Set(
[...(buttons?.querySelectorAll("#ryd-bar-container") ?? [])].map((rateBar) => findRateBarOwner(buttons, rateBar)),
);
for (const owner of owners) owner?.remove();
}
function createRateBar(likes, dislikes) { function createRateBar(likes, dislikes) {
const buttons = getButtons(); const buttons = getButtons();
const videoId = getVideoId(window.location.href);
for (const wrapper of document.querySelectorAll(".ryd-tooltip")) { for (const wrapper of document.querySelectorAll(".ryd-tooltip")) {
if (!buttons?.contains(wrapper)) wrapper.remove(); if (!buttons?.contains(wrapper)) wrapper.remove();
} }
let rateBar = buttons?.querySelector("#ryd-bar-container"); let rateBar = buttons?.querySelector("#ryd-bar-container");
if (!isShorts() && (!videoId || isMobile())) {
return;
}
if (!isLikesDisabled()) { if (!isLikesDisabled()) {
// sometimes rate bar is hidden // YouTube can leave an extension-owned subtree connected while hiding,
if (rateBar && !isInViewport(rateBar)) { // collapsing, or partially replacing it. Treat that as missing so the
(rateBar.closest(".ryd-tooltip") ?? rateBar).remove(); // periodic lifecycle check can rebuild a complete control.
if (rateBar && (!hasUsableRateBar(buttons, videoId) || !isInViewport(rateBar))) {
removeRateBarParts(buttons);
rateBar = null; rateBar = null;
} }
@@ -65,7 +134,7 @@ function createRateBar(likes, dislikes) {
} }
if (!isShorts()) { if (!isShorts()) {
if (!rateBar && !isMobile()) { if (!rateBar) {
let colorLikeStyle = ""; let colorLikeStyle = "";
let colorDislikeStyle = ""; let colorDislikeStyle = "";
if (extConfig.coloredBar) { if (extConfig.coloredBar) {
@@ -76,7 +145,7 @@ function createRateBar(likes, dislikes) {
(actions || querySelector(extConfig.selectors.rateBar.mobileActionBar)).insertAdjacentHTML( (actions || querySelector(extConfig.selectors.rateBar.mobileActionBar)).insertAdjacentHTML(
"beforeend", "beforeend",
` `
<div class="ryd-tooltip ryd-tooltip-${isNewDesign() ? "new" : "old"}-design" style="width: ${widthPx}px"> <div data-ryd-ratebar-wrapper class="ryd-tooltip ryd-tooltip-${isNewDesign() ? "new" : "old"}-design" style="width: ${widthPx}px">
<div class="ryd-tooltip-bar-container"> <div class="ryd-tooltip-bar-container">
<div <div
id="ryd-bar-container" id="ryd-bar-container"
@@ -92,9 +161,11 @@ function createRateBar(likes, dislikes) {
<!--css-build:shady-->${tooltipInnerHTML} <!--css-build:shady-->${tooltipInnerHTML}
</tp-yt-paper-tooltip> </tp-yt-paper-tooltip>
</div> </div>
`, `,
); );
getRateBarParts(buttons).wrapper?.setAttribute("data-ryd-video-id", videoId);
if (isNewDesign()) { if (isNewDesign()) {
// Add border between info and comments // Add border between info and comments
const descriptionAndActionsElement = findInCurrentWatchTree(buttons, extConfig.selectors.rateBar.topRow); const descriptionAndActionsElement = findInCurrentWatchTree(buttons, extConfig.selectors.rateBar.topRow);
@@ -102,31 +173,18 @@ function createRateBar(likes, dislikes) {
descriptionAndActionsElement.style.borderBottom = "1px solid var(--yt-spec-10-percent-layer)"; descriptionAndActionsElement.style.borderBottom = "1px solid var(--yt-spec-10-percent-layer)";
descriptionAndActionsElement.style.paddingBottom = "10px"; descriptionAndActionsElement.style.paddingBottom = "10px";
} }
// Fix like/dislike ratio bar offset in new UI
const actionsInner = findInCurrentWatchTree(
buttons,
extConfig.selectors.rateBar.actionsInner,
descriptionAndActionsElement,
);
if (actionsInner) actionsInner.style.width = "revert";
if (isRoundedDesign()) {
const actions = findInCurrentWatchTree(
buttons,
extConfig.selectors.rateBar.actions,
descriptionAndActionsElement,
);
if (actions) actions.style.flexDirection = "row-reverse";
}
} }
} else { } else {
buttons.querySelector(`.ryd-tooltip`).style.width = widthPx + "px"; const currentParts = getRateBarParts(buttons);
buttons.querySelector("#ryd-bar").style.width = widthPercent + "%"; currentParts.wrapper.setAttribute("data-ryd-video-id", videoId);
const tooltip = buttons.querySelector("#ryd-dislike-tooltip > #tooltip"); currentParts.wrapper.style.width = widthPx + "px";
currentParts.fill.style.width = widthPercent + "%";
const tooltipHost = buttons.querySelector("#ryd-dislike-tooltip");
const tooltip = tooltipHost?.querySelector("#tooltip") ?? tooltipHost;
if (tooltip) tooltip.innerHTML = tooltipInnerHTML; if (tooltip) tooltip.innerHTML = tooltipInnerHTML;
if (extConfig.coloredBar) { if (extConfig.coloredBar) {
buttons.querySelector("#ryd-bar-container").style.backgroundColor = getColorFromTheme(false); currentParts.rateBar.style.backgroundColor = getColorFromTheme(false);
buttons.querySelector("#ryd-bar").style.backgroundColor = getColorFromTheme(true); currentParts.fill.style.backgroundColor = getColorFromTheme(true);
} }
} }
} }
@@ -138,4 +196,4 @@ function createRateBar(likes, dislikes) {
} }
} }
export { createRateBar }; export { createRateBar, hasUsableRateBar };
+102 -4
View File
@@ -33,6 +33,7 @@ jest.mock("./state", () => ({
jest.mock("./utils", () => ({ jest.mock("./utils", () => ({
getColorFromTheme: jest.fn(() => "red"), getColorFromTheme: jest.fn(() => "red"),
getVideoId: jest.fn(() => "BBBBBBBBBBB"),
isInViewport: jest.fn(() => true), isInViewport: jest.fn(() => true),
querySelector: jest.fn((selectors, element) => { querySelector: jest.fn((selectors, element) => {
const scope = element ?? globalThis.document; const scope = element ?? globalThis.document;
@@ -45,8 +46,9 @@ jest.mock("./utils", () => ({
})); }));
const { getButtons, getDislikeButton, getLikeButton } = require("./buttons"); const { getButtons, getDislikeButton, getLikeButton } = require("./buttons");
const { createRateBar } = require("./bar"); const { createRateBar, hasUsableRateBar } = require("./bar");
const { isLikesDisabled, isMobile, isNewDesign, isRoundedDesign, isShorts } = require("./state"); const { isLikesDisabled, isMobile, isNewDesign, isRoundedDesign, isShorts } = require("./state");
const { getVideoId } = require("./utils");
describe("rate-bar stylesheet contrast", () => { describe("rate-bar stylesheet contrast", () => {
test("uses the same high-contrast negative track recipe as the userscript", () => { test("uses the same high-contrast negative track recipe as the userscript", () => {
@@ -58,6 +60,14 @@ describe("rate-bar stylesheet contrast", () => {
expect(rule).toContain("var(--yt-spec-base-background, #0f0f0f) 45%"); expect(rule).toContain("var(--yt-spec-base-background, #0f0f0f) 45%");
expect(rule).not.toContain("--yt-spec-icon-disabled"); expect(rule).not.toContain("--yt-spec-icon-disabled");
}); });
test("anchors the bar without overriding YouTube's responsive action-row layout", () => {
const stylesheet = fs.readFileSync(path.join(__dirname, "../content-style.css"), "utf8");
const rule = stylesheet.match(/\.ryd-tooltip-new-design\s*{([^}]*)}/)?.[1] ?? "";
expect(rule).toContain("position: absolute");
expect(rule).toContain("inset-inline-start: 0");
});
}); });
function watchTree(videoId) { function watchTree(videoId) {
@@ -84,6 +94,7 @@ describe("rate-bar visual ownership", () => {
isNewDesign.mockReturnValue(true); isNewDesign.mockReturnValue(true);
isRoundedDesign.mockReturnValue(true); isRoundedDesign.mockReturnValue(true);
isShorts.mockReturnValue(false); isShorts.mockReturnValue(false);
getVideoId.mockReturnValue("BBBBBBBBBBB");
const buttons = document.querySelector('[data-watch-buttons="BBBBBBBBBBB"]'); const buttons = document.querySelector('[data-watch-buttons="BBBBBBBBBBB"]');
getButtons.mockReturnValue(buttons); getButtons.mockReturnValue(buttons);
getLikeButton.mockReturnValue(buttons.querySelector("like-button-view-model")); getLikeButton.mockReturnValue(buttons.querySelector("like-button-view-model"));
@@ -108,9 +119,96 @@ describe("rate-bar visual ownership", () => {
expect(staleActionsInner.style.width).toBe("999px"); expect(staleActionsInner.style.width).toBe("999px");
expect(staleActions.style.flexDirection).toBe(""); expect(staleActions.style.flexDirection).toBe("");
expect(currentRow.style.paddingBottom).toBe("10px"); expect(currentRow.style.paddingBottom).toBe("10px");
expect(currentActionsInner.style.width).toBe("revert"); expect(currentActionsInner.style.width).toBe("999px");
expect(currentActions.style.flexDirection).toBe("row-reverse"); expect(currentActions.style.flexDirection).toBe("");
expect(document.querySelector('[data-watch-buttons="BBBBBBBBBBB"] > .ryd-tooltip')).not.toBeNull(); expect(
document.querySelector('[data-watch-buttons="BBBBBBBBBBB"] > .ryd-tooltip').getAttribute("data-ryd-video-id"),
).toBe("BBBBBBBBBBB");
expect(document.querySelector('[data-watch-buttons="AAAAAAAAAAA"] > .ryd-tooltip')).toBeNull(); expect(document.querySelector('[data-watch-buttons="AAAAAAAAAAA"] > .ryd-tooltip')).toBeNull();
}); });
test("refreshes an existing bar and tooltip when the same action host is reused for another video", () => {
const currentButtons = document.querySelector('[data-watch-buttons="BBBBBBBBBBB"]');
currentButtons.querySelector("like-button-view-model").style.width = "96px";
currentButtons.querySelector("dislike-button-view-model").style.width = "96px";
createRateBar(80, 20);
createRateBar(20, 80);
const normalizedTooltip = currentButtons
.querySelector("#ryd-dislike-tooltip")
.textContent.replace(/\s+/g, " ")
.trim();
expect(normalizedTooltip).toBe("20 / 80");
expect(currentButtons.querySelector("#ryd-bar").style.width).toBe("20%");
expect(currentButtons.querySelectorAll(".ryd-tooltip")).toHaveLength(1);
});
test("replaces a stale owned bar when reused controls keep the same compact dislike text", () => {
const currentButtons = document.querySelector('[data-watch-buttons="BBBBBBBBBBB"]');
currentButtons.querySelector("like-button-view-model").style.width = "96px";
currentButtons.querySelector("dislike-button-view-model").style.width = "96px";
const dislikeText = document.createElement("span");
dislikeText.id = "text";
currentButtons.querySelector("dislike-button-view-model button").appendChild(dislikeText);
dislikeText.textContent = "1.4K";
createRateBar(8_599, 1_401);
const staleWrapper = currentButtons.querySelector("[data-ryd-ratebar-wrapper]");
expect(staleWrapper.getAttribute("data-ryd-video-id")).toBe("BBBBBBBBBBB");
expect(currentButtons.querySelector("#ryd-bar").style.width).toBe("85.99%");
getVideoId.mockReturnValue("AAAAAAAAAAA");
expect(hasUsableRateBar(currentButtons)).toBe(false);
createRateBar(3_591, 1_409);
const currentWrapper = currentButtons.querySelector("[data-ryd-ratebar-wrapper]");
expect(dislikeText.textContent).toBe("1.4K");
expect(staleWrapper.isConnected).toBe(false);
expect(currentWrapper).not.toBe(staleWrapper);
expect(currentWrapper.getAttribute("data-ryd-video-id")).toBe("AAAAAAAAAAA");
expect(currentButtons.querySelector("#ryd-bar").style.width).toBe("71.82%");
expect(hasUsableRateBar(currentButtons)).toBe(true);
});
test.each([null, "AAAAAAAAAAA"])("rejects a rate bar whose video ownership is %s", (ownerVideoId) => {
const currentButtons = document.querySelector('[data-watch-buttons="BBBBBBBBBBB"]');
createRateBar(80, 20);
const wrapper = currentButtons.querySelector("[data-ryd-ratebar-wrapper]");
if (ownerVideoId === null) wrapper.removeAttribute("data-ryd-video-id");
else wrapper.setAttribute("data-ryd-video-id", ownerVideoId);
expect(hasUsableRateBar(currentButtons, "BBBBBBBBBBB")).toBe(false);
});
test("skips rate-bar rendering on mobile when no mobile bar exists", () => {
isMobile.mockReturnValue(true);
expect(() => createRateBar(100, 25)).not.toThrow();
expect(document.querySelector(".ryd-tooltip")).toBeNull();
expect(document.querySelector("#ryd-bar-container")).toBeNull();
});
test.each([
["hidden wrapper", (wrapper) => (wrapper.hidden = true)],
["collapsed wrapper", (wrapper) => (wrapper.style.width = "0px")],
["missing fill", (wrapper) => wrapper.querySelector("#ryd-bar").remove()],
["stripped wrapper class", (wrapper) => wrapper.classList.remove("ryd-tooltip")],
])("replaces a connected but unusable rate bar: %s", (_name, corrupt) => {
const currentButtons = document.querySelector('[data-watch-buttons="BBBBBBBBBBB"]');
createRateBar(80, 20);
const corruptedWrapper = currentButtons.querySelector("[data-ryd-ratebar-wrapper]");
corrupt(corruptedWrapper);
expect(corruptedWrapper.isConnected).toBe(true);
expect(hasUsableRateBar(currentButtons)).toBe(false);
createRateBar(20, 80);
expect(corruptedWrapper.isConnected).toBe(false);
expect(currentButtons.querySelectorAll(".ryd-tooltip")).toHaveLength(1);
expect(currentButtons.querySelector("#ryd-bar").style.width).toBe("20%");
expect(hasUsableRateBar(currentButtons)).toBe(true);
});
}); });
+472 -93
View File
@@ -1,7 +1,16 @@
import { isMobile, isShorts, extConfig } from "./state"; import { isMobile, isShorts, extConfig } from "./state";
import { getVideoId, isInViewport, querySelector, querySelectorAll } from "./utils"; import { getVideoId, querySelector, querySelectorAll } from "./utils";
import {
actionBarHasHydratedData,
getShortsIdentityLinkVideoIds,
isShortsControlSurfaceReadyForMutation as sharedShortsSurfaceIsReady,
shortsNativeControlInventoryIsReadyForFallback as sharedShortsNativeInventoryIsReadyForFallback,
} from "../../common/shorts-control-readiness";
const buttonsVideoOwnership = new WeakMap(); const buttonsVideoOwnership = new WeakMap();
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 hasRenderedBox(element) { function hasRenderedBox(element) {
if (!element?.isConnected || element.closest("[hidden], [aria-hidden='true'], [inert]")) { if (!element?.isConnected || element.closest("[hidden], [aria-hidden='true'], [inert]")) {
@@ -31,6 +40,16 @@ function intersectsViewport(element) {
); );
} }
function hasMeaningfulViewportPresence(element) {
const rect = element?.getBoundingClientRect?.();
if (!rect || rect.width <= 0 || rect.height <= 0) return false;
const height = innerHeight || document.documentElement.clientHeight;
const width = innerWidth || document.documentElement.clientWidth;
const visibleWidth = Math.max(0, Math.min(rect.right, width) - Math.max(rect.left, 0));
const visibleHeight = Math.max(0, Math.min(rect.bottom, height) - Math.max(rect.top, 0));
return (visibleWidth * visibleHeight) / (rect.width * rect.height) >= 0.5;
}
function configuredMatches(selectors) { function configuredMatches(selectors) {
const matches = []; const matches = [];
for (const selector of Array.isArray(selectors) ? selectors : [selectors]) { for (const selector of Array.isArray(selectors) ? selectors : [selectors]) {
@@ -68,69 +87,181 @@ function getButtonsVideoOwnership(candidate) {
function selectCurrentWatchButtons(candidates) { function selectCurrentWatchButtons(candidates) {
const videoId = getVideoId(window.location.href); const videoId = getVideoId(window.location.href);
const ranked = candidates return (
.map((candidate, index) => { candidates
const watchRoot = candidate.closest("ytd-watch-flexy, ytd-watch-grid"); .map((candidate, index) => {
const rootVideoId = watchRoot?.getAttribute("video-id"); const watchRoot = candidate.closest("ytd-watch-flexy, ytd-watch-grid");
const rootMatches = Boolean(videoId && rootVideoId === videoId); const rootVideoId = watchRoot?.getAttribute("video-id");
const rootConflicts = Boolean(videoId && rootVideoId && rootVideoId !== videoId); const rootMatches = Boolean(videoId && rootVideoId === videoId);
const controlsVideoId = getButtonsVideoOwnership(candidate); const rootConflicts = Boolean(videoId && rootVideoId && rootVideoId !== videoId);
const controlsMatch = Boolean(videoId && controlsVideoId === videoId); const controlsVideoId = getButtonsVideoOwnership(candidate);
const controlsConflict = Boolean(videoId && controlsVideoId && controlsVideoId !== videoId); const controlsMatch = Boolean(videoId && controlsVideoId === videoId);
const rendered = hasRenderedBox(candidate); const controlsConflict = Boolean(videoId && controlsVideoId && controlsVideoId !== videoId);
const inViewport = rendered && intersectsViewport(candidate); const rendered = hasRenderedBox(candidate);
const tier = const inViewport = rendered && intersectsViewport(candidate);
controlsMatch && inViewport const usable = hasUsableButtonStructure(candidate);
? 8 // Visible controls must win while YouTube is reusing a button tree across
: rootMatches && inViewport // videos. Ownership is supporting evidence, not a reason to prefer a
? 7 // hidden duplicate or an unrelated unowned menu group.
: inViewport const tier =
? 6 rootMatches && inViewport && !controlsConflict
: controlsMatch && rendered ? 10
? 5 : rootMatches && inViewport
: rootMatches && rendered ? 9
? 4 : inViewport && !controlsConflict
: rendered ? 8
? 3 : inViewport
: controlsMatch || rootMatches ? 7
? 2 : rootMatches && rendered && !controlsConflict
: 1; ? 6
return { candidate, controlsConflict, index, rootConflicts, tier }; : rootMatches && rendered
}) ? 5
// Do not bind outgoing controls to the destination video while YouTube is : rendered && !controlsConflict
// switching the current watch root during an SPA navigation. ? 4
.filter(({ rootConflicts }) => !rootConflicts); : rendered
const ownershipCompatible = ranked.filter(({ controlsConflict }) => !controlsConflict); ? 3
const selectionPool = ownershipCompatible.length > 0 ? ownershipCompatible : ranked; : (rootMatches || controlsMatch) && !controlsConflict
return selectionPool.sort((left, right) => right.tier - left.tier || left.index - right.index)[0]?.candidate; ? 2
: !controlsConflict
? 1
: 0;
return { candidate, index, rendered, rootConflicts, tier, usable };
})
// Do not bind outgoing controls to the destination video while YouTube is
// switching the current watch root during an SPA navigation.
.filter(({ rendered, rootConflicts, usable }) => rendered && !rootConflicts && usable)
.sort((left, right) => right.tier - left.tier || left.index - right.index)[0]?.candidate
);
} }
function getNativeButton(buttonContainer) { function getNativeButton(buttonContainer) {
if (!buttonContainer) return undefined;
return querySelector(extConfig.selectors.buttons.nativeButton, buttonContainer); return querySelector(extConfig.selectors.buttons.nativeButton, buttonContainer);
} }
function isSegmentedButtonLayout() { function isSegmentedButtonLayout(buttons = getButtons()) {
return querySelector(extConfig.selectors.buttons.segmentedContainer, getButtons()) !== undefined; return Boolean(buttons && querySelector(extConfig.selectors.buttons.segmentedContainer, buttons) !== undefined);
}
function getShortsRenderer(buttons) {
return buttons?.closest("ytd-reel-video-renderer, ytm-reel-video-renderer") ?? null;
}
function getShortsCandidateVideoIds(buttons) {
const renderer = getShortsRenderer(buttons);
const videoIds = new Set();
const rendererVideoId = renderer?.getAttribute("video-id");
if (rendererVideoId) videoIds.add(rendererVideoId);
for (const videoId of getShortsIdentityLinkVideoIds(renderer, window.location.href)) {
videoIds.add(videoId);
}
return videoIds;
}
function getShortsCandidateVideoId(buttons) {
const videoIds = getShortsCandidateVideoIds(buttons);
return videoIds.size === 1 ? videoIds.values().next().value : null;
}
function getShortsControlSurfaceSignature(buttons) {
return Array.from(getShortsCandidateVideoIds(buttons)).sort().join("|") || "identityless";
}
function getVisibleShortsControlSurfaceCount() {
const elements = isMobile()
? querySelectorAll(extConfig.selectors.buttons.shorts.mobile)
: querySelectorAll(extConfig.selectors.buttons.shorts.desktop);
return Array.from(elements).filter(
(candidate) => hasRenderedBox(candidate) && hasMeaningfulViewportPresence(candidate),
).length;
}
function shortsControlSurfaceIsReadyForMutation(
buttons,
currentVideoId,
{ allowUnhydratedFallback = false, isHydrated = false, isStable = false } = {},
) {
return sharedShortsSurfaceIsReady({
allowUnhydratedFallback,
candidateVideoIds: getShortsCandidateVideoIds(buttons),
currentVideoId,
isConnected: buttons?.isConnected,
isHydrated,
isRendered: hasRenderedBox(buttons),
isStable,
isViewportIntersecting: hasMeaningfulViewportPresence(buttons),
visibleCandidateCount: getVisibleShortsControlSurfaceCount(),
});
}
function shortsNativeControlInventoryIsReadyForFallback(inventory) {
return sharedShortsNativeInventoryIsReadyForFallback(inventory, {
getTopmostElement:
typeof document.elementFromPoint === "function" ? (x, y) => document.elementFromPoint(x, y) : null,
isMeaningfullyInViewport: hasMeaningfulViewportPresence,
isRendered: hasRenderedBox,
});
}
function selectCurrentShortsButtons(elements) {
const currentVideoId = getVideoId(window.location.href);
const candidates = Array.from(elements)
.map((buttons) => {
const renderer = getShortsRenderer(buttons);
const rendered = hasRenderedBox(buttons);
const videoIds = getShortsCandidateVideoIds(buttons);
return {
active: renderer?.hasAttribute("is-active") === true,
ambiguous: videoIds.size > 1,
buttons,
intersectsViewport: rendered && intersectsViewport(buttons),
rendered,
videoId: videoIds.size === 1 ? videoIds.values().next().value : null,
};
})
.filter(({ rendered }) => rendered);
const unambiguousCandidates = candidates.filter(({ ambiguous }) => !ambiguous);
const currentVideoCandidates = currentVideoId
? unambiguousCandidates.filter((candidate) => candidate.videoId === currentVideoId)
: [];
const exactActive = currentVideoCandidates.find(({ active }) => active);
if (exactActive) return exactActive.buttons;
const exactVisible = currentVideoCandidates.find(({ intersectsViewport: visible }) => visible);
if (exactVisible) return exactVisible.buttons;
const activeCandidate = unambiguousCandidates.find(({ active }) => active);
if (activeCandidate) {
// During a Shorts transition the URL can advance before YouTube switches
// the active reel. Do not write the destination count into the outgoing
// reel when both identities are known to differ.
if (currentVideoId && activeCandidate.videoId && activeCandidate.videoId !== currentVideoId) {
return undefined;
}
return activeCandidate.buttons;
}
if (currentVideoCandidates.length > 0) return currentVideoCandidates[0].buttons;
// Some layouts omit a usable video identity. In that case prefer any
// rendered control intersecting the viewport, then the first rendered
// fallback. Hidden preloaded reels have already been excluded above.
const visibleIdentitylessCandidates = unambiguousCandidates.filter(
({ intersectsViewport: visible, videoId }) => visible && !videoId,
);
return visibleIdentitylessCandidates.length === 1 ? visibleIdentitylessCandidates[0].buttons : undefined;
} }
function getButtons() { function getButtons() {
//--- If Watching Youtube Shorts: ---// //--- If Watching Youtube Shorts: ---//
if (isShorts()) { if (isShorts()) {
let elements = isMobile() const elements = isMobile()
? querySelectorAll(extConfig.selectors.buttons.shorts.mobile) ? querySelectorAll(extConfig.selectors.buttons.shorts.mobile)
: querySelectorAll(extConfig.selectors.buttons.shorts.desktop); : querySelectorAll(extConfig.selectors.buttons.shorts.desktop);
return selectCurrentShortsButtons(elements);
for (let element of elements) {
//YouTube Shorts can have multiple like/dislike buttons when scrolling through videos
//However, only one of them should be visible (no matter how you zoom)
if (isInViewport(element)) {
return element;
}
}
if (elements.length > 0) {
return elements[0];
}
} }
//--- If Watching On Mobile: ---// //--- If Watching On Mobile: ---//
if (isMobile()) { if (isMobile()) {
@@ -139,47 +270,284 @@ function getButtons() {
return selectCurrentWatchButtons(getDesktopWatchButtonCandidates()); return selectCurrentWatchButtons(getDesktopWatchButtonCandidates());
} }
function getLikeButton() { function getLikeButton(buttons = getButtons()) {
const buttons = getButtons(); if (!buttons) return undefined;
return isSegmentedButtonLayout() return isSegmentedButtonLayout(buttons)
? querySelector(extConfig.selectors.buttons.likeButton.segmented, buttons) ?? ? querySelector(extConfig.selectors.buttons.likeButton.segmented, buttons) ??
querySelector(extConfig.selectors.buttons.likeButton.segmentedGetButtons, buttons) querySelector(extConfig.selectors.buttons.likeButton.segmentedGetButtons, buttons)
: querySelector(extConfig.selectors.buttons.likeButton.notSegmented, buttons); : querySelector(extConfig.selectors.buttons.likeButton.notSegmented, buttons);
} }
function getLikeTextContainer() { function getLikeTextContainer(likeButton = getLikeButton()) {
return querySelector(extConfig.selectors.likeTextContainer, getLikeButton()); if (!likeButton) return undefined;
return querySelector(extConfig.selectors.likeTextContainer, likeButton);
} }
function getDislikeButton() { function isSyntheticShortsDislike(dislikeButton) {
const buttons = getButtons(); return dislikeButton?.matches?.(SYNTHETIC_SHORTS_DISLIKE_SELECTOR) === true;
if (isSegmentedButtonLayout()) { }
function setSyntheticShortsDislikePressed(pressed, dislikeButton = getDislikeButton()) {
if (!isSyntheticShortsDislike(dislikeButton)) return false;
dislikeButton.classList.toggle("style-default-active", pressed);
dislikeButton.classList.toggle("style-text", !pressed);
dislikeButton.querySelector("button")?.setAttribute("aria-pressed", String(pressed));
return true;
}
function setSyntheticShortsDislikeEnabled(enabled, dislikeButton = getDislikeButton()) {
if (!isSyntheticShortsDislike(dislikeButton)) return false;
const button = dislikeButton.querySelector("button");
if (!button) return false;
button.disabled = !enabled;
button.setAttribute("aria-disabled", String(!enabled));
return true;
}
function ensureSyntheticShortsDislikeButton(
buttons,
{
allowUnhydratedFallback = false,
currentVideoId = getVideoId(window.location.href),
isHydrated = false,
isStable = false,
} = {},
) {
if (!buttons || !isShorts() || isMobile()) return undefined;
const existingSynthetics = Array.from(buttons.querySelectorAll(SYNTHETIC_SHORTS_DISLIKE_SELECTOR));
const existing = existingSynthetics[0];
const nativeDislike = buttons.querySelector("dislike-button-view-model, #dislike-button");
const candidateVideoId = getShortsCandidateVideoId(buttons);
const readyForMutation = shortsControlSurfaceIsReadyForMutation(buttons, currentVideoId, {
allowUnhydratedFallback,
isHydrated: isHydrated && actionBarHasHydratedData(buttons),
isStable,
});
if (nativeDislike) {
if (readyForMutation) existingSynthetics.forEach((synthetic) => synthetic.remove());
return nativeDislike;
}
// YouTube routes to the next Short before activating its pre-rendered reel.
// Mutating that offscreen managed action tree can leave every native action
// with valid layout but no painted compositor layer when the reel activates.
if (!readyForMutation) return existing ?? undefined;
const videoId = candidateVideoId ?? currentVideoId;
if (existing) {
existingSynthetics.slice(1).forEach((synthetic) => synthetic.remove());
if (videoId && existing.getAttribute("data-ryd-video-id") !== videoId) {
existing.setAttribute("data-ryd-video-id", videoId);
setSyntheticShortsDislikePressed(false, existing);
setSyntheticShortsDislikeEnabled(false, existing);
const count = existing.querySelector("#text, [role='text']");
if (count) count.textContent = "";
}
return existing;
}
const likeButton = buttons.querySelector("like-button-view-model");
const nativeLikeButton = likeButton?.querySelector("button");
if (!likeButton || !nativeLikeButton) return undefined;
const dislikeButton = document.createElement("div");
dislikeButton.className = likeButton.getAttribute("class") || "";
dislikeButton.classList.add("ryd-synthetic-shorts-dislike", "style-text");
dislikeButton.setAttribute("data-ryd-role", "dislike");
dislikeButton.setAttribute("data-ryd-synthetic-shorts-dislike", "true");
if (videoId) dislikeButton.setAttribute("data-ryd-video-id", videoId);
const label = document.createElement("label");
label.className = nativeLikeButton.closest("label")?.getAttribute("class") || "ytSpecButtonShapeWithLabelHost";
label.classList.add("ryd-synthetic-shorts-dislike-label");
const button = document.createElement("button");
button.type = "button";
button.className = nativeLikeButton.getAttribute("class") || "ytSpecButtonShapeNextHost";
button.setAttribute("aria-label", "Dislike this video");
button.setAttribute("aria-pressed", "false");
button.setAttribute("aria-disabled", "true");
button.disabled = true;
const icon = document.createElement("span");
icon.className =
nativeLikeButton
.querySelector(".ytSpecButtonShapeNextIcon, .yt-spec-button-shape-next__icon")
?.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);
button.appendChild(icon);
const countContainer = document.createElement("div");
countContainer.className =
likeButton
.querySelector(".ytSpecButtonShapeWithLabelLabel, .yt-spec-button-shape-with-label__label")
?.getAttribute("class") || "ytSpecButtonShapeWithLabelLabel";
const count = document.createElement("span");
count.id = "text";
count.className =
likeButton.querySelector("span[role='text']")?.getAttribute("class") ||
"ytAttributedStringHost ytAttributedStringTextAlignmentCenter";
count.setAttribute("role", "text");
countContainer.appendChild(count);
label.append(button, countContainer);
dislikeButton.appendChild(label);
likeButton.insertAdjacentElement("afterend", dislikeButton);
return dislikeButton;
}
function getDislikeButton(buttons = getButtons()) {
if (!buttons) return undefined;
const syntheticShortsDislike = isShorts()
? buttons.querySelector(SYNTHETIC_SHORTS_DISLIKE_SELECTOR) ?? undefined
: undefined;
if (isSegmentedButtonLayout(buttons)) {
return ( return (
querySelector(extConfig.selectors.buttons.dislikeButton.segmented, buttons) ?? querySelector(extConfig.selectors.buttons.dislikeButton.segmented, buttons) ??
querySelector(extConfig.selectors.buttons.dislikeButton.segmentedGetButtons, buttons) querySelector(extConfig.selectors.buttons.dislikeButton.segmentedGetButtons, buttons)
); );
} }
if (isShorts()) {
const semanticSelectors = extConfig.selectors.buttons.dislikeButton.notSegmented.filter(
(selector) => selector !== ":nth-child(2)",
);
return (
querySelector(semanticSelectors, buttons) ??
querySelector(extConfig.selectors.buttons.dislikeButton.shortsFallback, buttons) ??
syntheticShortsDislike
);
}
const notSegmentedMatch = querySelector(extConfig.selectors.buttons.dislikeButton.notSegmented, buttons); const notSegmentedMatch = querySelector(extConfig.selectors.buttons.dislikeButton.notSegmented, buttons);
if (notSegmentedMatch != null) { if (notSegmentedMatch != null) {
return notSegmentedMatch; return notSegmentedMatch;
} }
if (isShorts()) {
return querySelector(extConfig.selectors.buttons.dislikeButton.shortsFallback, buttons);
}
return null; return null;
} }
function getTextContainerTemplate() { function getTextContainerTemplate(likeButton, buttons) {
const likeButton = getLikeButton(); if (!likeButton || !buttons || !buttons.contains(likeButton)) return undefined;
const parentTemplate = const parentTemplate =
querySelector(extConfig.selectors.likeTextContainerTemplateParent, likeButton) ?? querySelector(extConfig.selectors.likeTextContainerTemplateParent, likeButton) ??
querySelector(extConfig.selectors.likeTextContainerTemplateParent); querySelector(extConfig.selectors.likeTextContainerTemplateParent, buttons);
return querySelector(extConfig.selectors.likeTextContainerTemplate, likeButton) ?? parentTemplate?.parentNode; return (
querySelector(extConfig.selectors.likeTextContainerTemplate, likeButton) ??
querySelector(extConfig.selectors.likeTextContainerTemplate, buttons) ??
parentTemplate?.parentNode
);
}
function findDislikeTextContainer(dislikeButton, nativeDislikeButton) {
if (!dislikeButton) return undefined;
for (const selector of extConfig.selectors.dislikeTextContainer) {
const result = dislikeButton.querySelector(selector);
if (result !== null && result !== nativeDislikeButton) {
return matchesConfiguredSelector(result, extConfig.selectors.textContainerInner)
? result
: querySelector(extConfig.selectors.textContainerInner, result) ?? result;
}
}
}
function isReactionControlHost(element) {
const tagName = element?.tagName?.toLowerCase() ?? "";
return tagName === "button" || tagName.includes("-") || element?.hasAttribute("data-ryd-role");
}
function matchesConfiguredSelector(element, selectors) {
if (!element) return false;
return (Array.isArray(selectors) ? selectors : [selectors]).some((selector) => selector && element.matches(selector));
}
function getSemanticControlSelectors(role) {
const selectorConfig = extConfig.selectors.buttons[`${role}Button`];
return [...selectorConfig.segmented, ...selectorConfig.notSegmented].filter(
(selector) => selector && !selector.trim().startsWith(":"),
);
}
function hasSemanticReactionStructure(buttons, likeButton, dislikeButton, nativeLikeButton, nativeDislikeButton) {
const segmentedSelectors = extConfig.selectors.buttons.segmentedContainer;
const hasSegmentedContainer =
matchesConfiguredSelector(buttons, segmentedSelectors) || querySelector(segmentedSelectors, buttons) !== undefined;
const hasSemanticPair =
matchesConfiguredSelector(likeButton, getSemanticControlSelectors("like")) &&
matchesConfiguredSelector(dislikeButton, getSemanticControlSelectors("dislike"));
if (hasSegmentedContainer || hasSemanticPair) return true;
// Older/mobile layouts can expose the pair only by position. Permit that
// fallback solely inside a configured reaction root, and require both
// controls to behave like toggle buttons. This excludes adjacent menu
// groups such as Share/Download without relying on localized labels.
const positionalRootSelectors = [
...extConfig.selectors.buttons.regular.desktopNoMenu,
...extConfig.selectors.buttons.regular.mobile,
...extConfig.selectors.buttons.shorts.desktop,
...extConfig.selectors.buttons.shorts.mobile,
];
return (
matchesConfiguredSelector(buttons, positionalRootSelectors) &&
nativeLikeButton?.hasAttribute("aria-pressed") &&
nativeDislikeButton?.hasAttribute("aria-pressed")
);
}
function getButtonControls(buttons = getButtons()) {
const likeButton = getLikeButton(buttons);
const dislikeButton = getDislikeButton(buttons);
const nativeLikeButton = getNativeButton(likeButton);
const nativeDislikeButton = getNativeButton(dislikeButton);
const dislikeTextContainer = findDislikeTextContainer(dislikeButton, nativeDislikeButton);
const textContainerTemplate = getTextContainerTemplate(likeButton, buttons);
const ready = Boolean(
buttons?.isConnected &&
likeButton?.isConnected &&
dislikeButton?.isConnected &&
nativeLikeButton?.isConnected &&
nativeDislikeButton?.isConnected &&
isReactionControlHost(likeButton) &&
isReactionControlHost(dislikeButton) &&
buttons.contains(likeButton) &&
buttons.contains(dislikeButton) &&
likeButton.contains(nativeLikeButton) &&
dislikeButton.contains(nativeDislikeButton) &&
hasSemanticReactionStructure(buttons, likeButton, dislikeButton, nativeLikeButton, nativeDislikeButton) &&
(dislikeTextContainer || textContainerTemplate),
);
return {
buttons,
dislikeButton,
dislikeTextContainer,
likeButton,
nativeDislikeButton,
nativeLikeButton,
ready,
textContainerTemplate,
};
}
function hasUsableButtonStructure(buttons) {
return getButtonControls(buttons).ready;
} }
function updateDislikeButtonShape(dislikeButton) { function updateDislikeButtonShape(dislikeButton) {
@@ -192,38 +560,39 @@ function updateDislikeButtonShape(dislikeButton) {
} }
} }
function createDislikeTextContainer() { function createDislikeTextContainer(controls = getButtonControls()) {
const textNodeClone = getTextContainerTemplate().cloneNode(true); const { dislikeButton, dislikeTextContainer, nativeDislikeButton, textContainerTemplate } = controls;
const dislikeButton = getNativeButton(getDislikeButton()); if (dislikeTextContainer) return dislikeTextContainer;
const insertPreChild = dislikeButton; if (
insertPreChild.insertBefore(textNodeClone, null); !controls.ready ||
updateDislikeButtonShape(dislikeButton); !nativeDislikeButton?.isConnected ||
if (querySelector(extConfig.selectors.textContainerInner, textNodeClone) === undefined) { !dislikeButton?.contains(nativeDislikeButton) ||
const span = document.createElement("span"); !textContainerTemplate?.isConnected
span.setAttribute("role", "text"); ) {
return undefined;
}
const textNodeClone = textContainerTemplate.cloneNode(true);
let textContainer = matchesConfiguredSelector(textNodeClone, extConfig.selectors.textContainerInner)
? textNodeClone
: querySelector(extConfig.selectors.textContainerInner, textNodeClone);
if (textContainer === undefined) {
textContainer = document.createElement("span");
textContainer.setAttribute("role", "text");
while (textNodeClone.firstChild) { while (textNodeClone.firstChild) {
textNodeClone.removeChild(textNodeClone.firstChild); textNodeClone.removeChild(textNodeClone.firstChild);
} }
textNodeClone.appendChild(span); textNodeClone.appendChild(textContainer);
} }
textNodeClone.innerText = ""; if (!textContainer.id) textContainer.id = "text";
return textNodeClone; textContainer.innerText = "";
nativeDislikeButton.insertBefore(textNodeClone, null);
updateDislikeButtonShape(nativeDislikeButton);
return textContainer;
} }
function getDislikeTextContainer() { function getDislikeTextContainer(controls = getButtonControls()) {
let result; return controls.dislikeTextContainer ?? createDislikeTextContainer(controls);
const nativeDislikeButton = getNativeButton(getDislikeButton());
for (const selector of extConfig.selectors.dislikeTextContainer) {
result = getDislikeButton().querySelector(selector);
if (result !== null && result !== nativeDislikeButton) {
break;
}
result = null;
}
if (result == null) {
result = createDislikeTextContainer();
}
return result;
} }
function checkForSignInButton() { function checkForSignInButton() {
@@ -235,12 +604,22 @@ function checkForSignInButton() {
} }
export { export {
ensureSyntheticShortsDislikeButton,
getButtons, getButtons,
getButtonControls,
getLikeButton, getLikeButton,
getDislikeButton, getDislikeButton,
getLikeTextContainer, getLikeTextContainer,
getDislikeTextContainer, getDislikeTextContainer,
getShortsCandidateVideoId,
getShortsCandidateVideoIds,
getShortsControlSurfaceSignature,
checkForSignInButton, checkForSignInButton,
hasRenderedBox, hasRenderedBox,
isSyntheticShortsDislike,
markButtonsForVideo, markButtonsForVideo,
setSyntheticShortsDislikeEnabled,
setSyntheticShortsDislikePressed,
shortsControlSurfaceIsReadyForMutation,
shortsNativeControlInventoryIsReadyForFallback,
}; };
+605 -3
View File
@@ -1,15 +1,29 @@
/** @jest-environment jsdom */ /** @jest-environment jsdom */
const { extConfig } = require("./state"); const { extConfig } = require("./state");
const { getButtons, getDislikeButton, getLikeButton, hasRenderedBox, markButtonsForVideo } = require("./buttons"); const {
ensureSyntheticShortsDislikeButton,
getButtonControls,
getButtons,
getDislikeButton,
getDislikeTextContainer,
getLikeButton,
getShortsCandidateVideoId,
getShortsCandidateVideoIds,
hasRenderedBox,
isSyntheticShortsDislike,
markButtonsForVideo,
setSyntheticShortsDislikeEnabled,
setSyntheticShortsDislikePressed,
} = require("./buttons");
function controls(id, videoId, attributes = "") { function controls(id, videoId, attributes = "") {
return ` return `
<ytd-menu-renderer class="ytd-watch-metadata" ${attributes}> <ytd-menu-renderer class="ytd-watch-metadata" ${attributes}>
<div id="${id}" data-video-id="${videoId}"> <div id="${id}" data-video-id="${videoId}">
<segmented-like-dislike-button-view-model> <segmented-like-dislike-button-view-model>
<like-button-view-model id="segmented-like-button"><button aria-pressed="false"></button></like-button-view-model> <like-button-view-model id="segmented-like-button"><button aria-pressed="false"><span class="yt-spec-button-shape-next__button-text-content" role="text">100</span></button></like-button-view-model>
<dislike-button-view-model id="segmented-dislike-button"><button aria-pressed="false"></button></dislike-button-view-model> <dislike-button-view-model id="segmented-dislike-button"><button aria-pressed="false"><span class="yt-spec-button-shape-next__button-text-content" role="text"></span></button></dislike-button-view-model>
</segmented-like-dislike-button-view-model> </segmented-like-dislike-button-view-model>
</div> </div>
</ytd-menu-renderer>`; </ytd-menu-renderer>`;
@@ -148,4 +162,592 @@ describe("desktop watch button ownership", () => {
extConfig.selectors.buttons.segmentedContainer = originalSegmentedContainer; extConfig.selectors.buttons.segmentedContainer = originalSegmentedContainer;
} }
}); });
test("keeps visible reused reactions ahead of an unrelated unowned menu group and hidden duplicate", () => {
document.body.innerHTML = `
<ytd-watch-flexy video-id="BBBBBBBBBBB">
${controls("reused", "AAAAAAAAAAA")}
<ytd-menu-renderer class="ytd-watch-metadata">
<div id="flexible-item-buttons">
<button-view-model>
<button type="button" aria-label="Share"><span role="text">Share</span></button>
</button-view-model>
<button-view-model>
<button type="button" aria-label="Download"><span role="text">Download</span></button>
</button-view-model>
</div>
</ytd-menu-renderer>
${controls("hidden-duplicate", "BBBBBBBBBBB", "hidden")}
</ytd-watch-flexy>`;
const reused = document.querySelector("#reused");
const unrelated = document.querySelector("#flexible-item-buttons");
const hiddenDuplicate = document.querySelector("#hidden-duplicate");
[reused, unrelated, hiddenDuplicate].forEach((candidate) => setBox(candidate, { x: 40 }));
markButtonsForVideo(reused, "AAAAAAAAAAA");
expect(getButtonControls(unrelated).ready).toBe(false);
expect(hasRenderedBox(hiddenDuplicate)).toBe(false);
expect(getButtons()).toBe(reused);
});
test("accepts configured legacy segmented reaction controls", () => {
document.body.innerHTML = `
<ytd-watch-flexy video-id="BBBBBBBBBBB">
<ytd-menu-renderer class="ytd-watch-metadata">
<div id="legacy">
<ytd-segmented-like-dislike-button-renderer>
<ytd-toggle-button-renderer id="segmented-like-button">
<button aria-pressed="false"><span role="text">100</span></button>
</ytd-toggle-button-renderer>
<ytd-toggle-button-renderer id="segmented-dislike-button">
<button aria-pressed="false"><span role="text"></span></button>
</ytd-toggle-button-renderer>
</ytd-segmented-like-dislike-button-renderer>
</div>
</ytd-menu-renderer>
</ytd-watch-flexy>`;
const legacy = document.querySelector("#legacy");
setBox(legacy);
expect(getButtonControls(legacy).ready).toBe(true);
expect(getButtons()).toBe(legacy);
});
test("accepts positional legacy toggles only inside the configured reaction root", () => {
document.body.innerHTML = `
<ytd-watch-flexy video-id="BBBBBBBBBBB">
<ytd-menu-renderer class="ytd-watch-metadata">
<div id="top-level-buttons-computed">
<ytd-toggle-button-renderer>
<button aria-pressed="false"><span role="text">100</span></button>
</ytd-toggle-button-renderer>
<ytd-toggle-button-renderer>
<button aria-pressed="false"><span role="text"></span></button>
</ytd-toggle-button-renderer>
</div>
</ytd-menu-renderer>
</ytd-watch-flexy>`;
const legacy = document.querySelector("#top-level-buttons-computed");
setBox(legacy);
expect(getButtonControls(legacy).ready).toBe(true);
expect(getButtons()).toBe(legacy);
});
test("waits for native button hydration in place", () => {
document.body.innerHTML = `
<ytd-watch-flexy video-id="BBBBBBBBBBB">
${controls("current", "BBBBBBBBBBB")}
</ytd-watch-flexy>`;
const current = document.querySelector("#current");
const dislikeHost = current.querySelector("dislike-button-view-model");
dislikeHost.querySelector("button").remove();
setBox(current);
expect(getButtonControls(current).ready).toBe(false);
expect(getButtons()).toBeUndefined();
dislikeHost.innerHTML = '<button aria-pressed="false"><span role="text"></span></button>';
expect(getButtonControls(current).ready).toBe(true);
expect(getButtons()).toBe(current);
});
test("does not clone a text template from outside the selected reaction tree", () => {
document.body.innerHTML = `
<div id="stale-template"><div><span role="text">999</span></div></div>
<ytd-watch-flexy video-id="BBBBBBBBBBB">
<ytd-menu-renderer class="ytd-watch-metadata">
<div id="current">
<like-button-view-model><button aria-pressed="false"></button></like-button-view-model>
<dislike-button-view-model><button aria-pressed="false"></button></dislike-button-view-model>
</div>
</ytd-menu-renderer>
</ytd-watch-flexy>`;
const current = document.querySelector("#current");
setBox(current);
const snapshot = getButtonControls(current);
expect(snapshot.ready).toBe(false);
expect(snapshot.textContainerTemplate).toBeUndefined();
expect(getDislikeTextContainer(snapshot)).toBeUndefined();
expect(current.querySelector("dislike-button-view-model").textContent).toBe("");
});
test("creates at most one dislike text container when initialization repeats", () => {
document.body.innerHTML = `
<ytd-watch-flexy video-id="BBBBBBBBBBB">
${controls("current", "BBBBBBBBBBB")}
</ytd-watch-flexy>`;
const current = document.querySelector("#current");
current.querySelector("dislike-button-view-model span[role='text']").remove();
setBox(current);
const first = getDislikeTextContainer(getButtonControls(current));
const second = getDislikeTextContainer();
expect(second).toBe(first);
expect(current.querySelectorAll("dislike-button-view-model > button > span[role='text']")).toHaveLength(1);
});
test("preserves the semantic count node when hydrating an icon-only smartimation dislike", () => {
document.body.innerHTML = `
<ytd-watch-flexy video-id="BBBBBBBBBBB">
<ytd-menu-renderer class="ytd-watch-metadata">
<div id="current">
<segmented-like-dislike-button-view-model>
<yt-smartimation>
<div id="content">
<div id="wrapper">
<like-button-view-model class="style-text">
<toggle-button-view-model>
<button-view-model>
<button aria-label="100 likes" aria-pressed="false">
<svg></svg>
<div class="ytSpecButtonShapeNextButtonTextContent">
<span id="text" role="text">100</span>
</div>
<span data-touch-feedback></span>
</button>
</button-view-model>
</toggle-button-view-model>
</like-button-view-model>
<dislike-button-view-model class="style-text">
<toggle-button-view-model>
<button-view-model>
<button aria-label="Dislike this video" aria-pressed="false">
<svg></svg>
<span data-touch-feedback></span>
</button>
</button-view-model>
</toggle-button-view-model>
</dislike-button-view-model>
</div>
</div>
</yt-smartimation>
</segmented-like-dislike-button-view-model>
</div>
</ytd-menu-renderer>
</ytd-watch-flexy>`;
const current = document.querySelector("#current");
const dislike = current.querySelector("dislike-button-view-model");
setBox(current);
const snapshot = getButtonControls(current);
expect(snapshot.ready).toBe(true);
expect(snapshot.dislikeTextContainer).toBeUndefined();
const first = getDislikeTextContainer(snapshot);
first.innerText = "42";
const second = getDislikeTextContainer();
expect(first).toBe(dislike.querySelector("#text[role='text']"));
expect(second).toBe(first);
expect(first.innerText).toBe("42");
expect(dislike.querySelectorAll(".ytSpecButtonShapeNextButtonTextContent")).toHaveLength(1);
expect(dislike.querySelectorAll("#text[role='text']")).toHaveLength(1);
});
test("inserts one owned Shorts Dislike after Like instead of treating Comments as Dislike", () => {
history.replaceState({}, "", "/shorts/BBBBBBBBBBB");
document.body.innerHTML = `
<ytd-reel-video-renderer video-id="BBBBBBBBBBB" is-active>
<reel-action-bar-view-model>
<like-button-view-model class="ytLikeButtonViewModelHost">
<label class="ytSpecButtonShapeWithLabelHost">
<button class="ytSpecButtonShapeNextHost" aria-pressed="false">
<span class="ytSpecButtonShapeNextIcon"></span>
</button>
<div class="ytSpecButtonShapeWithLabelLabel"><span role="text">100</span></div>
</label>
</like-button-view-model>
<button-view-model><button type="button">Comments</button></button-view-model>
</reel-action-bar-view-model>
</ytd-reel-video-renderer>`;
const actionBar = document.querySelector("reel-action-bar-view-model");
setBox(actionBar);
expect(getDislikeButton(actionBar)).toBeUndefined();
expect(getButtonControls(actionBar).ready).toBe(false);
expect(actionBar.querySelector("[data-ryd-synthetic-shorts-dislike]")).toBeNull();
const dislike = ensureSyntheticShortsDislikeButton(actionBar, {
currentVideoId: "BBBBBBBBBBB",
isHydrated: true,
isStable: true,
});
expect(isSyntheticShortsDislike(dislike)).toBe(true);
expect(dislike.previousElementSibling).toBe(actionBar.querySelector("like-button-view-model"));
expect(dislike.nextElementSibling).toBe(actionBar.querySelector("button-view-model"));
expect(dislike.getAttribute("data-ryd-video-id")).toBe("BBBBBBBBBBB");
expect(dislike.querySelector("button").disabled).toBe(true);
expect(dislike.querySelector("button").getAttribute("aria-disabled")).toBe("true");
expect(dislike.querySelector("button").getAttribute("aria-pressed")).toBe("false");
expect(dislike.querySelector("svg path").getAttribute("d")).toMatch(/^m8\.482/);
expect(dislike.querySelector("#text").textContent).toBe("");
expect(getButtonControls(actionBar).ready).toBe(true);
expect(getDislikeButton(actionBar)).toBe(dislike);
expect(actionBar.querySelectorAll("[data-ryd-synthetic-shorts-dislike]")).toHaveLength(1);
expect(setSyntheticShortsDislikeEnabled(true, dislike)).toBe(true);
expect(setSyntheticShortsDislikePressed(true, dislike)).toBe(true);
expect(dislike.querySelector("button").disabled).toBe(false);
expect(dislike.querySelector("button").getAttribute("aria-disabled")).toBe("false");
expect(dislike.querySelector("button").getAttribute("aria-pressed")).toBe("true");
expect(dislike.classList.contains("style-default-active")).toBe(true);
});
test("does not mutate an offscreen URL-matching Shorts placeholder before activation", () => {
history.replaceState({}, "", "/shorts/BBBBBBBBBBB");
document.body.innerHTML = `
<ytd-shorts>
<ytd-reel-video-renderer video-id="BBBBBBBBBBB">
<a href="/shorts/BBBBBBBBBBB"></a>
<reel-action-bar-view-model>
<like-button-view-model>
<button aria-pressed="false"><span role="text">100</span></button>
</like-button-view-model>
<button-view-model><button type="button">Comments</button></button-view-model>
</reel-action-bar-view-model>
</ytd-reel-video-renderer>
</ytd-shorts>`;
const actionBar = document.querySelector("reel-action-bar-view-model");
setBox(actionBar, { height: 320, width: 48, x: 20, y: window.innerHeight + 80 });
expect(getButtons()).toBe(actionBar);
expect(getButtonControls().ready).toBe(false);
expect(actionBar.querySelector("[data-ryd-synthetic-shorts-dislike]")).toBeNull();
setBox(actionBar, { height: 320, width: 48, x: 20, y: window.innerHeight - 1 });
expect(
ensureSyntheticShortsDislikeButton(actionBar, {
currentVideoId: "BBBBBBBBBBB",
isHydrated: true,
isStable: true,
}),
).toBeUndefined();
expect(actionBar.querySelector("[data-ryd-synthetic-shorts-dislike]")).toBeNull();
// Exactly half visible is a meaningful activation boundary; a one-pixel
// intersection above is not.
setBox(actionBar, { height: 320, width: 48, x: 20, y: window.innerHeight - 160 });
expect(getButtonControls().ready).toBe(false);
ensureSyntheticShortsDislikeButton(actionBar, {
currentVideoId: "BBBBBBBBBBB",
isHydrated: true,
isStable: true,
});
expect(getButtonControls().ready).toBe(true);
expect(actionBar.querySelectorAll("[data-ryd-synthetic-shorts-dislike]")).toHaveLength(1);
});
test("recognizes destination ownership when Shorts reuses one visible renderer and changes only its canonical link", () => {
history.replaceState({}, "", "/shorts/AAAAAAAAAAA");
document.body.innerHTML = `
<ytd-shorts>
<ytd-reel-video-renderer>
<a id="canonical-short" class="ytp-title-link" href="/shorts/AAAAAAAAAAA"></a>
<reel-action-bar-view-model>
<like-button-view-model>
<button aria-pressed="false"><span role="text">100</span></button>
</like-button-view-model>
</reel-action-bar-view-model>
</ytd-reel-video-renderer>
</ytd-shorts>`;
const actionBar = document.querySelector("reel-action-bar-view-model");
setBox(actionBar, { height: 320, width: 48, x: 20, y: 20 });
ensureSyntheticShortsDislikeButton(actionBar, {
currentVideoId: "AAAAAAAAAAA",
isHydrated: true,
isStable: true,
});
const outgoing = getButtonControls();
history.replaceState({}, "", "/shorts/BBBBBBBBBBB");
document.querySelector("#canonical-short").setAttribute("href", "/shorts/BBBBBBBBBBB");
const destination = getButtonControls();
expect(destination.ready).toBe(true);
expect(destination.buttons).toBe(outgoing.buttons);
expect(destination.likeButton).toBe(outgoing.likeButton);
expect(destination.dislikeButton).toBe(outgoing.dislikeButton);
expect(getShortsCandidateVideoId(destination.buttons)).toBe("BBBBBBBBBBB");
expect(destination.dislikeButton.getAttribute("data-ryd-video-id")).toBe("AAAAAAAAAAA");
ensureSyntheticShortsDislikeButton(actionBar, {
currentVideoId: "BBBBBBBBBBB",
isHydrated: true,
isStable: true,
});
expect(destination.dislikeButton.getAttribute("data-ryd-video-id")).toBe("BBBBBBBBBBB");
});
test("removes an existing synthetic Shorts control only after a native Dislike arrives on a stable surface", () => {
history.replaceState({}, "", "/shorts/BBBBBBBBBBB");
document.body.innerHTML = `
<ytd-shorts>
<ytd-reel-video-renderer video-id="BBBBBBBBBBB" is-active>
<reel-action-bar-view-model>
<like-button-view-model>
<button aria-pressed="false"><span role="text">100</span></button>
</like-button-view-model>
<button-view-model><button type="button">Comments</button></button-view-model>
</reel-action-bar-view-model>
</ytd-reel-video-renderer>
</ytd-shorts>`;
const actionBar = document.querySelector("reel-action-bar-view-model");
const like = actionBar.querySelector("like-button-view-model");
setBox(actionBar, { height: 420, width: 48, x: 20, y: 20 });
const synthetic = ensureSyntheticShortsDislikeButton(actionBar, {
currentVideoId: "BBBBBBBBBBB",
isHydrated: true,
isStable: true,
});
actionBar.append(synthetic.cloneNode(true), synthetic.cloneNode(true));
const nativeDislike = document.createElement("dislike-button-view-model");
nativeDislike.innerHTML = '<button aria-pressed="false"><span role="text"></span></button>';
like.insertAdjacentElement("afterend", nativeDislike);
expect(getDislikeButton(actionBar)).toBe(nativeDislike);
expect(synthetic.isConnected).toBe(true);
expect(actionBar.querySelectorAll("[data-ryd-synthetic-shorts-dislike]")).toHaveLength(3);
expect(
ensureSyntheticShortsDislikeButton(actionBar, {
currentVideoId: "BBBBBBBBBBB",
isHydrated: true,
isStable: true,
}),
).toBe(nativeDislike);
expect(synthetic.isConnected).toBe(false);
expect(actionBar.querySelectorAll("[data-ryd-synthetic-shorts-dislike]")).toHaveLength(0);
});
test("waits for YouTube to restore action-bar data before explicit synthetic mutation", () => {
history.replaceState({}, "", "/shorts/BBBBBBBBBBB");
document.body.innerHTML = `
<ytd-shorts>
<ytd-reel-video-renderer video-id="BBBBBBBBBBB" is-active>
<reel-action-bar-view-model>
<like-button-view-model><button aria-pressed="false"><span role="text">100</span></button></like-button-view-model>
<button-view-model><button type="button">Comments</button></button-view-model>
</reel-action-bar-view-model>
</ytd-reel-video-renderer>
</ytd-shorts>`;
const actionBar = document.querySelector("reel-action-bar-view-model");
setBox(actionBar, { height: 420, width: 48, x: 20, y: 20 });
actionBar.data = null;
expect(
ensureSyntheticShortsDislikeButton(actionBar, {
currentVideoId: "BBBBBBBBBBB",
isHydrated: true,
isStable: true,
}),
).toBeUndefined();
expect(actionBar.querySelector("[data-ryd-synthetic-shorts-dislike]")).toBeNull();
expect(
isSyntheticShortsDislike(
ensureSyntheticShortsDislikeButton(actionBar, {
allowUnhydratedFallback: true,
currentVideoId: "BBBBBBBBBBB",
isHydrated: false,
isStable: true,
}),
),
).toBe(true);
actionBar.querySelector("[data-ryd-synthetic-shorts-dislike]").remove();
actionBar.data = { likeButtonViewModel: {} };
expect(
isSyntheticShortsDislike(
ensureSyntheticShortsDislikeButton(actionBar, {
currentVideoId: "BBBBBBBBBBB",
isHydrated: true,
isStable: true,
}),
),
).toBe(true);
});
test("normalizes duplicate synthetic Shorts controls only through the explicit stable mutation path", () => {
history.replaceState({}, "", "/shorts/BBBBBBBBBBB");
document.body.innerHTML = `
<ytd-shorts>
<ytd-reel-video-renderer video-id="BBBBBBBBBBB" is-active>
<reel-action-bar-view-model>
<like-button-view-model><button aria-pressed="false"><span role="text">100</span></button></like-button-view-model>
<button-view-model><button type="button">Comments</button></button-view-model>
</reel-action-bar-view-model>
</ytd-reel-video-renderer>
</ytd-shorts>`;
const actionBar = document.querySelector("reel-action-bar-view-model");
setBox(actionBar, { height: 420, width: 48, x: 20, y: 20 });
const synthetic = ensureSyntheticShortsDislikeButton(actionBar, {
currentVideoId: "BBBBBBBBBBB",
isHydrated: true,
isStable: true,
});
actionBar.append(synthetic.cloneNode(true), synthetic.cloneNode(true));
expect(getDislikeButton(actionBar)).toBe(synthetic);
expect(actionBar.querySelectorAll("[data-ryd-synthetic-shorts-dislike]")).toHaveLength(3);
ensureSyntheticShortsDislikeButton(actionBar, {
currentVideoId: "BBBBBBBBBBB",
isHydrated: true,
isStable: true,
});
expect(actionBar.querySelectorAll("[data-ryd-synthetic-shorts-dislike]")).toHaveLength(1);
});
test("keeps discovery pure while a Shorts href changes before activation", () => {
history.replaceState({}, "", "/shorts/BBBBBBBBBBB");
document.body.innerHTML = `
<ytd-shorts>
<ytd-reel-video-renderer>
<a id="canonical-short" class="ytp-title-link" href="/shorts/AAAAAAAAAAA"></a>
<reel-action-bar-view-model>
<like-button-view-model><button aria-pressed="false"><span role="text">100</span></button></like-button-view-model>
<button-view-model><button type="button">Comments</button></button-view-model>
</reel-action-bar-view-model>
</ytd-reel-video-renderer>
</ytd-shorts>`;
const actionBar = document.querySelector("reel-action-bar-view-model");
setBox(actionBar, { height: 320, width: 48, x: 20, y: 20 });
document.querySelector("#canonical-short").setAttribute("href", "/shorts/BBBBBBBBBBB");
expect(getButtons()).toBe(actionBar);
expect(getDislikeButton(actionBar)).toBeUndefined();
expect(getButtonControls(actionBar).ready).toBe(false);
expect(actionBar.querySelector("[data-ryd-synthetic-shorts-dislike]")).toBeNull();
});
test("rejects a Shorts renderer with conflicting attribute and link identities", () => {
history.replaceState({}, "", "/shorts/BBBBBBBBBBB");
document.body.innerHTML = `
<ytd-shorts>
<ytd-reel-video-renderer video-id="BBBBBBBBBBB" is-active>
<a class="ytp-title-link" href="/shorts/AAAAAAAAAAA"></a>
<reel-action-bar-view-model>
<like-button-view-model><button aria-pressed="false"><span role="text">100</span></button></like-button-view-model>
</reel-action-bar-view-model>
</ytd-reel-video-renderer>
</ytd-shorts>`;
const actionBar = document.querySelector("reel-action-bar-view-model");
setBox(actionBar, { height: 320, width: 48, x: 20, y: 20 });
expect(Array.from(getShortsCandidateVideoIds(actionBar)).sort()).toEqual(["AAAAAAAAAAA", "BBBBBBBBBBB"]);
expect(getShortsCandidateVideoId(actionBar)).toBeNull();
expect(getButtons()).toBeUndefined();
expect(
ensureSyntheticShortsDislikeButton(actionBar, {
currentVideoId: "BBBBBBBBBBB",
isHydrated: true,
isStable: true,
}),
).toBeUndefined();
expect(actionBar.querySelector("[data-ryd-synthetic-shorts-dislike]")).toBeNull();
});
test("uses the canonical player permalink when the current Short description links to another Short", () => {
history.replaceState({}, "", "/shorts/BBBBBBBBBBB");
document.body.innerHTML = `
<ytd-shorts>
<ytd-reel-video-renderer>
<div id="shorts-player">
<a class="ytp-title-link yt-uix-sessionlink" href="/shorts/BBBBBBBBBBB"></a>
</div>
<yt-reel-player-overlay-view-model>
<yt-reel-metapanel-view-model>
<button-view-model><a href="/shorts/AAAAAAAAAAA">Another Short</a></button-view-model>
</yt-reel-metapanel-view-model>
</yt-reel-player-overlay-view-model>
<reel-action-bar-view-model>
<like-button-view-model>
<button aria-pressed="false"><span role="text">100</span></button>
</like-button-view-model>
<button-view-model><button type="button">Comments</button></button-view-model>
</reel-action-bar-view-model>
</ytd-reel-video-renderer>
</ytd-shorts>`;
const actionBar = document.querySelector("reel-action-bar-view-model");
setBox(actionBar, { height: 320, width: 48, x: 20, y: 20 });
expect(Array.from(getShortsCandidateVideoIds(actionBar))).toEqual(["BBBBBBBBBBB"]);
expect(getShortsCandidateVideoId(actionBar)).toBe("BBBBBBBBBBB");
expect(getButtons()).toBe(actionBar);
const dislike = ensureSyntheticShortsDislikeButton(actionBar, {
currentVideoId: "BBBBBBBBBBB",
isHydrated: true,
isStable: true,
});
expect(isSyntheticShortsDislike(dislike)).toBe(true);
expect(dislike.getAttribute("data-ryd-video-id")).toBe("BBBBBBBBBBB");
});
test("accepts native modern Shorts reaction controls", () => {
history.replaceState({}, "", "/shorts/BBBBBBBBBBB");
document.body.innerHTML = `
<ytd-reel-video-renderer video-id="BBBBBBBBBBB" is-active>
<reel-action-bar-view-model>
<like-button-view-model><button aria-pressed="false"><span role="text">100</span></button></like-button-view-model>
<dislike-button-view-model><button aria-pressed="false"><span role="text"></span></button></dislike-button-view-model>
<button-view-model><button type="button">Comments</button></button-view-model>
</reel-action-bar-view-model>
</ytd-reel-video-renderer>`;
const actionBar = document.querySelector("reel-action-bar-view-model");
setBox(actionBar);
expect(getButtons()).toBe(actionBar);
expect(getButtonControls(actionBar).ready).toBe(true);
expect(actionBar.querySelector("[data-ryd-synthetic-shorts-dislike]")).toBeNull();
});
test("follows the active Shorts reel without falling back to the hidden first reel", () => {
history.replaceState({}, "", "/shorts/BBBBBBBBBBB");
document.body.innerHTML = `
<ytd-shorts>
<ytd-reel-video-renderer video-id="AAAAAAAAAAA" is-active>
<reel-action-bar-view-model id="short-a">
<like-button-view-model><button aria-pressed="false"><span role="text">100</span></button></like-button-view-model>
<dislike-button-view-model><button aria-pressed="false"><span role="text"></span></button></dislike-button-view-model>
</reel-action-bar-view-model>
</ytd-reel-video-renderer>
<ytd-reel-video-renderer video-id="BBBBBBBBBBB" hidden>
<reel-action-bar-view-model id="short-b">
<like-button-view-model><button aria-pressed="false"><span role="text">200</span></button></like-button-view-model>
<dislike-button-view-model><button aria-pressed="false"><span role="text"></span></button></dislike-button-view-model>
</reel-action-bar-view-model>
</ytd-reel-video-renderer>
</ytd-shorts>`;
const rendererA = document.querySelector('ytd-reel-video-renderer[video-id="AAAAAAAAAAA"]');
const rendererB = document.querySelector('ytd-reel-video-renderer[video-id="BBBBBBBBBBB"]');
const actionBarA = document.querySelector("#short-a");
const actionBarB = document.querySelector("#short-b");
setBox(actionBarA, { height: 320, width: 48, x: 20, y: 20 });
setBox(actionBarB, { height: 0, width: 0, x: 0, y: 0 });
// The route advances before the active reel does. Do not initialize B's
// state into the still-rendered A controls during this transition gap.
expect(getButtons()).toBeUndefined();
rendererA.removeAttribute("is-active");
rendererA.hidden = true;
rendererB.hidden = false;
rendererB.setAttribute("is-active", "");
setBox(actionBarA, { height: 0, width: 0, x: 0, y: 0 });
// The active action bar can be partially clipped by the viewport. The old
// full-containment check rejected it and fell back to hidden Short A.
setBox(actionBarB, { height: 320, width: 48, x: 20, y: window.innerHeight - 160 });
expect(getButtons()).toBe(actionBarB);
expect(getButtonControls(actionBarB).ready).toBe(true);
});
}); });
@@ -0,0 +1,19 @@
const HIDE_CLUTTER_BUTTONS_STORAGE_KEY = "hideClutterButtons";
const HIDE_CLUTTER_BUTTONS_ATTRIBUTE = "data-ryd-hide-clutter-buttons";
function normalizeHideClutterButtons(value) {
return value === true;
}
function publishHideClutterButtons(value, root = globalThis.document?.documentElement) {
const normalized = normalizeHideClutterButtons(value);
root?.setAttribute?.(HIDE_CLUTTER_BUTTONS_ATTRIBUTE, normalized ? "true" : "false");
return normalized;
}
export {
HIDE_CLUTTER_BUTTONS_ATTRIBUTE,
HIDE_CLUTTER_BUTTONS_STORAGE_KEY,
normalizeHideClutterButtons,
publishHideClutterButtons,
};
@@ -0,0 +1,40 @@
/**
* @jest-environment jsdom
*/
import {
HIDE_CLUTTER_BUTTONS_ATTRIBUTE,
HIDE_CLUTTER_BUTTONS_STORAGE_KEY,
normalizeHideClutterButtons,
publishHideClutterButtons,
} from "./clutter-button-setting";
describe("clutter button setting", () => {
test("uses the stable storage and page-world attribute contract", () => {
expect(HIDE_CLUTTER_BUTTONS_STORAGE_KEY).toBe("hideClutterButtons");
expect(HIDE_CLUTTER_BUTTONS_ATTRIBUTE).toBe("data-ryd-hide-clutter-buttons");
});
test.each([
[true, true],
[false, false],
[undefined, false],
[null, false],
["true", false],
[1, false],
])("normalizes %p to %p", (input, expected) => {
expect(normalizeHideClutterButtons(input)).toBe(expected);
});
test("publishes both enabled and disabled states for the injected menu fixer", () => {
expect(publishHideClutterButtons(true)).toBe(true);
expect(document.documentElement.getAttribute(HIDE_CLUTTER_BUTTONS_ATTRIBUTE)).toBe("true");
expect(publishHideClutterButtons(false)).toBe(false);
expect(document.documentElement.getAttribute(HIDE_CLUTTER_BUTTONS_ATTRIBUTE)).toBe("false");
});
test("still normalizes the value when no document root is available", () => {
expect(publishHideClutterButtons(true, null)).toBe(true);
});
});
+1
View File
@@ -35,6 +35,7 @@ const config = {
numberDisplayFormat: "compactShort", numberDisplayFormat: "compactShort",
numberDisplayReformatLikes: false, numberDisplayReformatLikes: false,
hidePremiumTeaser: false, hidePremiumTeaser: false,
hideClutterButtons: false,
}, },
}; };
@@ -0,0 +1,101 @@
const AUTHENTICATION_DATA_PERMISSION = "authenticationInfo";
const ACCOUNT_DATA_PERMISSIONS = Object.freeze([AUTHENTICATION_DATA_PERMISSION]);
const AUTHENTICATION_DATA_DESCRIPTOR = Object.freeze({
data_collection: ACCOUNT_DATA_PERMISSIONS,
});
function getRuntimeApi() {
if (typeof browser !== "undefined" && browser?.runtime) return browser.runtime;
if (typeof chrome !== "undefined" && chrome?.runtime) return chrome.runtime;
return null;
}
function getPermissionsApi() {
if (typeof browser !== "undefined" && browser?.permissions) return browser.permissions;
if (typeof chrome !== "undefined" && chrome?.permissions) return chrome.permissions;
return null;
}
function usesFirefoxDataCollectionConsent() {
const manifest = getRuntimeApi()?.getManifest?.();
return Boolean(manifest?.browser_specific_settings?.gecko?.data_collection_permissions);
}
function callFirefoxPermissionMethod(methodName) {
const permissions = getPermissionsApi();
const method = permissions?.[methodName];
if (typeof method !== "function") return Promise.resolve(false);
try {
return Promise.resolve(method.call(permissions, AUTHENTICATION_DATA_DESCRIPTOR)).then(Boolean, () => false);
} catch (_) {
return Promise.resolve(false);
}
}
function queryBackgroundForAuthenticationDataPermission() {
const runtime = getRuntimeApi();
if (typeof runtime?.sendMessage !== "function") return Promise.resolve(false);
if (typeof browser !== "undefined" && browser?.runtime === runtime) {
try {
return Promise.resolve(runtime.sendMessage({ message: "ryd_has_authentication_consent" }))
.then((response) => response?.granted === true)
.catch(() => false);
} catch (_) {
return Promise.resolve(false);
}
}
return new Promise((resolve) => {
try {
runtime.sendMessage({ message: "ryd_has_authentication_consent" }, (response) => {
if (runtime.lastError) {
resolve(false);
return;
}
resolve(response?.granted === true);
});
} catch (_) {
resolve(false);
}
});
}
function hasAuthenticationDataPermission({ queryBackground = true } = {}) {
if (!usesFirefoxDataCollectionConsent()) return Promise.resolve(true);
if (typeof getPermissionsApi()?.contains === "function") return callFirefoxPermissionMethod("contains");
return queryBackground ? queryBackgroundForAuthenticationDataPermission() : Promise.resolve(false);
}
function requestAuthenticationDataPermission() {
if (!usesFirefoxDataCollectionConsent()) return Promise.resolve(true);
// Keep this direct request as the first operation in the login click stack.
// Firefox requires optional data-collection consent requests to originate from a user gesture.
return callFirefoxPermissionMethod("request");
}
function authenticationDataPermissionWasRemoved(removedPermissions) {
return ACCOUNT_DATA_PERMISSIONS.some((permission) => removedPermissions?.data_collection?.includes(permission));
}
function onAuthenticationDataPermissionRemoved(listener) {
const event = getPermissionsApi()?.onRemoved;
if (!usesFirefoxDataCollectionConsent() || typeof event?.addListener !== "function") return () => {};
const wrapped = (removedPermissions) => {
if (authenticationDataPermissionWasRemoved(removedPermissions)) listener();
};
event.addListener(wrapped);
return () => event.removeListener?.(wrapped);
}
export {
AUTHENTICATION_DATA_PERMISSION,
authenticationDataPermissionWasRemoved,
hasAuthenticationDataPermission,
onAuthenticationDataPermissionRemoved,
requestAuthenticationDataPermission,
usesFirefoxDataCollectionConsent,
};
@@ -0,0 +1,142 @@
import {
authenticationDataPermissionWasRemoved,
hasAuthenticationDataPermission,
onAuthenticationDataPermissionRemoved,
requestAuthenticationDataPermission,
usesFirefoxDataCollectionConsent,
} from "./data-collection-permissions";
const firefoxManifest = {
browser_specific_settings: {
gecko: {
data_collection_permissions: {
required: ["personallyIdentifyingInfo", "browsingActivity", "websiteContent", "websiteActivity"],
optional: ["authenticationInfo"],
},
},
},
};
describe("Firefox data-collection permissions", () => {
afterEach(() => {
delete global.browser;
delete global.chrome;
});
it("requests only authentication consent within the login gesture", async () => {
let requestStarted = false;
const request = jest.fn(() => {
requestStarted = true;
return Promise.resolve(true);
});
global.browser = {
runtime: { getManifest: () => firefoxManifest },
permissions: { request },
};
const result = requestAuthenticationDataPermission();
expect(requestStarted).toBe(true);
expect(request).toHaveBeenCalledWith({ data_collection: ["authenticationInfo"] });
await expect(result).resolves.toBe(true);
});
it("checks the existing Firefox grant without requesting it", async () => {
const contains = jest.fn(() => Promise.resolve(false));
const request = jest.fn();
global.browser = {
runtime: { getManifest: () => firefoxManifest },
permissions: { contains, request },
};
await expect(hasAuthenticationDataPermission()).resolves.toBe(false);
expect(contains).toHaveBeenCalledWith({ data_collection: ["authenticationInfo"] });
expect(request).not.toHaveBeenCalled();
});
it("leaves Chrome behavior unchanged", async () => {
const request = jest.fn();
global.chrome = {
runtime: { getManifest: () => ({ manifest_version: 3 }) },
permissions: { request },
};
expect(usesFirefoxDataCollectionConsent()).toBe(false);
await expect(hasAuthenticationDataPermission()).resolves.toBe(true);
await expect(requestAuthenticationDataPermission()).resolves.toBe(true);
expect(request).not.toHaveBeenCalled();
});
it("ends account access only when authentication consent is removed", () => {
expect(authenticationDataPermissionWasRemoved({ data_collection: ["authenticationInfo"] })).toBe(true);
expect(authenticationDataPermissionWasRemoved({ data_collection: ["financialAndPaymentInfo"] })).toBe(false);
expect(authenticationDataPermissionWasRemoved({ data_collection: ["websiteContent"] })).toBe(false);
expect(authenticationDataPermissionWasRemoved({ permissions: ["identity"] })).toBe(false);
expect(authenticationDataPermissionWasRemoved(undefined)).toBe(false);
});
it.each([
{ granted: ["authenticationInfo"], expected: true },
{ granted: ["financialAndPaymentInfo"], expected: false },
])(
"checks authentication consent independently of obsolete financial grants: $granted",
async ({ granted, expected }) => {
global.browser = {
runtime: { getManifest: () => firefoxManifest },
permissions: {
contains: ({ data_collection }) =>
Promise.resolve(data_collection.every((permission) => granted.includes(permission))),
},
};
await expect(hasAuthenticationDataPermission()).resolves.toBe(expected);
},
);
it("does not end the account session when only an obsolete financial grant is removed", () => {
let removedListener;
const listener = jest.fn();
const removeListener = jest.fn();
global.browser = {
runtime: { getManifest: () => firefoxManifest },
permissions: {
onRemoved: {
addListener: (callback) => {
removedListener = callback;
},
removeListener,
},
},
};
const unsubscribe = onAuthenticationDataPermissionRemoved(listener);
removedListener({ data_collection: ["financialAndPaymentInfo"] });
expect(listener).not.toHaveBeenCalled();
removedListener({ data_collection: ["authenticationInfo"] });
expect(listener).toHaveBeenCalledTimes(1);
unsubscribe();
expect(removeListener).toHaveBeenCalledWith(removedListener);
});
it("queries the background from a content script without the permissions API", async () => {
const sendMessage = jest.fn().mockResolvedValue({ granted: true });
global.browser = { runtime: { getManifest: () => firefoxManifest, sendMessage } };
await expect(hasAuthenticationDataPermission()).resolves.toBe(true);
expect(sendMessage).toHaveBeenCalledWith({ message: "ryd_has_authentication_consent" });
await expect(hasAuthenticationDataPermission({ queryBackground: false })).resolves.toBe(false);
});
it("fails closed on permission API and background failures", async () => {
global.browser = {
runtime: { getManifest: () => firefoxManifest, sendMessage: () => Promise.reject(new Error("unavailable")) },
};
await expect(hasAuthenticationDataPermission()).resolves.toBe(false);
await expect(requestAuthenticationDataPermission()).resolves.toBe(false);
global.browser.permissions = {
contains: () => Promise.reject(new Error("unavailable")),
request: () => {
throw new Error("outside gesture");
},
};
await expect(hasAuthenticationDataPermission()).resolves.toBe(false);
await expect(requestAuthenticationDataPermission()).resolves.toBe(false);
});
});
+87 -14
View File
@@ -1,14 +1,32 @@
import { getBrowser, getVideoId, numberFormat, createObserver, querySelector } from "./utils"; import { getBrowser, getVideoId, numberFormat, createObserver, querySelector } from "./utils";
import { checkForSignInButton, getButtons, getDislikeButton, getLikeButton } from "./buttons"; import {
import { setDislikes, extConfig, storedData, setLikes, getLikeCountFromButton } from "./state"; checkForSignInButton,
getButtons,
getDislikeButton,
getLikeButton,
isSyntheticShortsDislike,
setSyntheticShortsDislikePressed,
} from "./buttons";
import {
setDislikes,
extConfig,
storedData,
setLikes,
getLikeCountFromButton,
persistSyntheticShortsDislikeState,
} from "./state";
import { publishHideClutterButtons } from "./clutter-button-setting";
import { createRateBar } from "./bar"; import { createRateBar } from "./bar";
import { import {
LIKE_ACTION, LIKE_ACTION,
DISLIKE_ACTION, DISLIKE_ACTION,
DISLIKED_STATE,
LIKED_STATE,
resolveVoteTransition, resolveVoteTransition,
applyVoteTransitionCounts, applyVoteTransitionCounts,
shouldSubmitVote, shouldSubmitVote,
} from "../../common/vote-transition"; } from "../../common/vote-transition";
import { resolveDelegatedVoteActivation } from "../../common/delegated-vote-activation";
function sendVote(vote) { function sendVote(vote) {
if (shouldSubmitVote({ disableVoteSubmission: extConfig.disableVoteSubmission })) { if (shouldSubmitVote({ disableVoteSubmission: extConfig.disableVoteSubmission })) {
@@ -24,18 +42,51 @@ function sendVote(vote) {
} }
function updateDOMDislikes() { function updateDOMDislikes() {
const videoId = getVideoId(window.location.href);
if (!videoId || storedData.videoId !== videoId) return false;
setDislikes(numberFormat(storedData.dislikes)); setDislikes(numberFormat(storedData.dislikes));
createRateBar(storedData.likes, storedData.dislikes); createRateBar(storedData.likes, storedData.dislikes);
return true;
}
let suppressNextLikeActivation = false;
function clearNativeLikeForSyntheticDislike(action, stateBeforeActivation, syntheticShortsDislike) {
if (!syntheticShortsDislike || action !== DISLIKE_ACTION || stateBeforeActivation !== LIKED_STATE) return;
const likeButton = getLikeButton();
const nativeLikeButton = likeButton?.querySelector("button");
if (!nativeLikeButton) return;
suppressNextLikeActivation = true;
try {
nativeLikeButton.click();
} finally {
suppressNextLikeActivation = false;
}
nativeLikeButton.setAttribute("aria-pressed", "false");
for (const className of extConfig.selectors.activeButtonClasses) likeButton.classList.remove(className);
} }
function handleVoteAction(action) { function handleVoteAction(action) {
if (checkForSignInButton() === false) { if (checkForSignInButton() === false) {
const transition = resolveVoteTransition(storedData.previousState, action); const videoId = getVideoId(window.location.href);
if (!videoId || storedData.videoId !== videoId) return;
const previousState = storedData.previousState;
const transition = resolveVoteTransition(previousState, action);
const counts = applyVoteTransitionCounts(storedData.likes, storedData.dislikes, transition); const counts = applyVoteTransitionCounts(storedData.likes, storedData.dislikes, transition);
const syntheticShortsDislike = isSyntheticShortsDislike(getDislikeButton());
clearNativeLikeForSyntheticDislike(action, previousState, syntheticShortsDislike);
sendVote(transition.value); sendVote(transition.value);
storedData.likes = counts.likes; storedData.likes = counts.likes;
storedData.dislikes = counts.dislikes; storedData.dislikes = counts.dislikes;
storedData.previousState = transition.nextState; storedData.previousState = transition.nextState;
if (syntheticShortsDislike) {
setSyntheticShortsDislikePressed(transition.nextState === DISLIKED_STATE);
void persistSyntheticShortsDislikeState(videoId, transition.nextState === DISLIKED_STATE).catch((error) =>
console.error("Could not persist the synthetic Shorts Dislike state.", error),
);
}
updateDOMDislikes(); updateDOMDislikes();
if (extConfig.numberDisplayReformatLikes === true) { if (extConfig.numberDisplayReformatLikes === true) {
@@ -48,6 +99,7 @@ function handleVoteAction(action) {
} }
function likeClicked() { function likeClicked() {
if (suppressNextLikeActivation) return;
handleVoteAction(LIKE_ACTION); handleVoteAction(LIKE_ACTION);
} }
@@ -55,18 +107,31 @@ function dislikeClicked() {
handleVoteAction(DISLIKE_ACTION); handleVoteAction(DISLIKE_ACTION);
} }
const boundLikeButtons = new WeakSet();
const boundDislikeButtons = new WeakSet(); const boundDislikeButtons = new WeakSet();
let delegatedReactionListenerRegistered = false;
function addLikeDislikeEventListener() { function delegatedReactionClicked(event) {
const likeButton = getLikeButton(); const buttons = getButtons();
const dislikeButton = getDislikeButton(); const likeButton = getLikeButton(buttons);
if (likeButton && !boundLikeButtons.has(likeButton)) { const dislikeButton = getDislikeButton(buttons);
likeButton.addEventListener("click", likeClicked); const activation = resolveDelegatedVoteActivation({ buttons, dislikeButton, event, likeButton });
boundLikeButtons.add(likeButton); if (activation?.action === DISLIKE_ACTION) {
dislikeClicked();
} else if (activation?.action === LIKE_ACTION) {
likeClicked();
} }
if (dislikeButton && !boundDislikeButtons.has(dislikeButton)) { }
dislikeButton.addEventListener("click", dislikeClicked);
function ensureDelegatedReactionListener() {
if (delegatedReactionListenerRegistered) return;
document.addEventListener("click", delegatedReactionClicked, true);
delegatedReactionListenerRegistered = true;
}
function addLikeDislikeEventListener(likeButton = getLikeButton(), dislikeButton = getDislikeButton()) {
if (!likeButton || !dislikeButton) return;
ensureDelegatedReactionListener();
if (!boundDislikeButtons.has(dislikeButton)) {
dislikeButton.addEventListener("focusin", updateDOMDislikes); dislikeButton.addEventListener("focusin", updateDOMDislikes);
dislikeButton.addEventListener("focusout", updateDOMDislikes); dislikeButton.addEventListener("focusout", updateDOMDislikes);
boundDislikeButtons.add(dislikeButton); boundDislikeButtons.add(dislikeButton);
@@ -75,7 +140,7 @@ function addLikeDislikeEventListener() {
let smartimationObserver = null; let smartimationObserver = null;
function createSmartimationObserver() { function createSmartimationObserver(buttons = getButtons()) {
if (!smartimationObserver) { if (!smartimationObserver) {
smartimationObserver = createObserver( smartimationObserver = createObserver(
{ {
@@ -88,7 +153,7 @@ function createSmartimationObserver() {
smartimationObserver.container = null; smartimationObserver.container = null;
} }
const smartimationContainer = querySelector(extConfig.selectors.buttons.smartimation, getButtons()); const smartimationContainer = querySelector(extConfig.selectors.buttons.smartimation, buttons);
if (smartimationContainer && smartimationObserver.container != smartimationContainer) { if (smartimationContainer && smartimationObserver.container != smartimationContainer) {
console.log("Initializing smartimation mutation observer"); console.log("Initializing smartimation mutation observer");
smartimationObserver.disconnect(); smartimationObserver.disconnect();
@@ -119,6 +184,9 @@ function storageChangeHandler(changes, area) {
if (changes.hidePremiumTeaser !== undefined) { if (changes.hidePremiumTeaser !== undefined) {
handleHidePremiumTeaserChangeEvent(changes.hidePremiumTeaser.newValue); handleHidePremiumTeaserChangeEvent(changes.hidePremiumTeaser.newValue);
} }
if (changes.hideClutterButtons !== undefined) {
handleHideClutterButtonsChangeEvent(changes.hideClutterButtons.newValue);
}
} }
function handleDisableVoteSubmissionChangeEvent(value) { function handleDisableVoteSubmissionChangeEvent(value) {
@@ -150,6 +218,10 @@ function handleHidePremiumTeaserChangeEvent(value) {
extConfig.hidePremiumTeaser = value === true; extConfig.hidePremiumTeaser = value === true;
} }
function handleHideClutterButtonsChangeEvent(value) {
extConfig.hideClutterButtons = publishHideClutterButtons(value);
}
export { export {
sendVote, sendVote,
likeClicked, likeClicked,
@@ -157,4 +229,5 @@ export {
addLikeDislikeEventListener, addLikeDislikeEventListener,
createSmartimationObserver, createSmartimationObserver,
storageChangeHandler, storageChangeHandler,
updateDOMDislikes,
}; };
+168
View File
@@ -0,0 +1,168 @@
/**
* @jest-environment jsdom
* @jest-environment-options {"url":"https://www.youtube.com/watch?v=abcdefghijk"}
*/
const mockSendMessage = jest.fn();
jest.mock("./utils", () => ({
createObserver: () => ({ disconnect: jest.fn(), observe: jest.fn() }),
getBrowser: jest.fn(),
getVideoId: (url) => new URL(url).searchParams.get("v"),
numberFormat: (value) => String(value),
querySelector: () => undefined,
}));
jest.mock("./buttons", () => ({
checkForSignInButton: () => false,
getButtons: jest.fn(),
getDislikeButton: jest.fn(),
getLikeButton: jest.fn(),
isSyntheticShortsDislike: () => false,
setSyntheticShortsDislikePressed: jest.fn(),
}));
jest.mock("./state", () => ({
extConfig: {
activeButtonClasses: ["style-default-active"],
disableVoteSubmission: false,
selectors: { buttons: { smartimation: ["yt-smartimation"] } },
},
getLikeCountFromButton: jest.fn(),
persistSyntheticShortsDislikeState: jest.fn(async () => undefined),
setDislikes: jest.fn(),
setLikes: jest.fn(),
storedData: { dislikes: 100, likes: 300, previousState: "neutral", videoId: "abcdefghijk" },
}));
jest.mock("./bar", () => ({
createRateBar: jest.fn(),
}));
import { createRateBar } from "./bar";
import { getButtons, getDislikeButton, getLikeButton } from "./buttons";
import { extConfig, setDislikes, storedData } from "./state";
import { addLikeDislikeEventListener, storageChangeHandler, updateDOMDislikes } from "./events";
import { getBrowser } from "./utils";
beforeEach(() => {
createRateBar.mockClear();
setDislikes.mockClear();
mockSendMessage.mockReset();
getBrowser.mockReturnValue({ runtime: { sendMessage: mockSendMessage } });
getButtons.mockReset();
getDislikeButton.mockReset();
getLikeButton.mockReset();
storedData.dislikes = 100;
storedData.likes = 300;
storedData.previousState = "NEUTRAL_STATE";
storedData.videoId = "abcdefghijk";
history.replaceState({}, "", "/watch?v=abcdefghijk");
});
test("renders cached counts only while they belong to the current video", () => {
expect(updateDOMDislikes()).toBe(true);
expect(setDislikes).toHaveBeenCalledWith("100");
expect(createRateBar).toHaveBeenCalledWith(300, 100);
});
test("a smartimation callback cannot restore outgoing counts after navigation", () => {
history.replaceState({}, "", "/watch?v=zyxwvutsrqp");
expect(updateDOMDislikes()).toBe(false);
expect(setDislikes).not.toHaveBeenCalled();
expect(createRateBar).not.toHaveBeenCalled();
});
test("does not render cached counts when the current URL has no video identity", () => {
history.replaceState({}, "", "/feed/subscriptions");
expect(updateDOMDislikes()).toBe(false);
expect(setDislikes).not.toHaveBeenCalled();
expect(createRateBar).not.toHaveBeenCalled();
});
test("publishes hide-clutter setting changes to the page-world menu fixer", () => {
storageChangeHandler({ hideClutterButtons: { newValue: true } }, "sync");
expect(extConfig.hideClutterButtons).toBe(true);
expect(document.documentElement.getAttribute("data-ryd-hide-clutter-buttons")).toBe("true");
storageChangeHandler({ hideClutterButtons: { newValue: false } }, "sync");
expect(extConfig.hideClutterButtons).toBe(false);
expect(document.documentElement.getAttribute("data-ryd-hide-clutter-buttons")).toBe("false");
});
function createReactionControls(name) {
const buttons = document.createElement("div");
buttons.dataset.controls = name;
const likeButton = document.createElement("like-button-view-model");
const nativeLikeButton = document.createElement("button");
nativeLikeButton.setAttribute("aria-pressed", "false");
likeButton.appendChild(nativeLikeButton);
const dislikeButton = document.createElement("dislike-button-view-model");
const nativeDislikeButton = document.createElement("button");
nativeDislikeButton.setAttribute("aria-pressed", "false");
dislikeButton.appendChild(nativeDislikeButton);
buttons.append(likeButton, dislikeButton);
return { buttons, dislikeButton, likeButton, nativeDislikeButton };
}
test("delegated reaction activation survives hydrated host replacement and ignores retained outgoing controls", () => {
const outgoing = createReactionControls("outgoing");
const destination = createReactionControls("destination");
document.body.replaceChildren(outgoing.buttons, destination.buttons);
let current = outgoing;
getButtons.mockImplementation(() => current.buttons);
getLikeButton.mockImplementation(() => current.likeButton);
getDislikeButton.mockImplementation(() => current.dislikeButton);
addLikeDislikeEventListener(outgoing.likeButton, outgoing.dislikeButton);
history.replaceState({}, "", "/watch?v=zyxwvutsrqp");
storedData.videoId = "zyxwvutsrqp";
current = destination;
outgoing.nativeDislikeButton.click();
expect(mockSendMessage).not.toHaveBeenCalled();
destination.nativeDislikeButton.click();
expect(mockSendMessage).toHaveBeenCalledTimes(1);
expect(mockSendMessage).toHaveBeenCalledWith({
message: "send_vote",
videoId: "zyxwvutsrqp",
vote: -1,
});
expect(storedData).toMatchObject({ dislikes: 101, likes: 300, previousState: "DISLIKED_STATE" });
expect(setDislikes).toHaveBeenLastCalledWith("101");
expect(createRateBar).toHaveBeenLastCalledWith(300, 101);
addLikeDislikeEventListener(destination.likeButton, destination.dislikeButton);
addLikeDislikeEventListener(destination.likeButton, destination.dislikeButton);
destination.nativeDislikeButton.click();
expect(mockSendMessage).toHaveBeenCalledTimes(2);
expect(mockSendMessage).toHaveBeenLastCalledWith({
message: "send_vote",
videoId: "zyxwvutsrqp",
vote: 0,
});
});
test("delegated reaction activation does not vote before state belongs to the current video", () => {
const destination = createReactionControls("destination");
document.body.replaceChildren(destination.buttons);
getButtons.mockReturnValue(destination.buttons);
getLikeButton.mockReturnValue(destination.likeButton);
getDislikeButton.mockReturnValue(destination.dislikeButton);
addLikeDislikeEventListener(destination.likeButton, destination.dislikeButton);
history.replaceState({}, "", "/watch?v=zyxwvutsrqp");
storedData.videoId = "abcdefghijk";
destination.nativeDislikeButton.click();
expect(mockSendMessage).not.toHaveBeenCalled();
expect(storedData).toMatchObject({ dislikes: 100, likes: 300, previousState: "NEUTRAL_STATE" });
});

Some files were not shown because too many files have changed in this diff Show More