From e500088e65e53b8631152d8ce31d8cfdb18131e8 Mon Sep 17 00:00:00 2001 From: Anarios Date: Sun, 6 Sep 2026 12:51:12 +0200 Subject: [PATCH] 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. --- .github/workflows/build.yaml | 14 +- AMO_SUBMISSION_NOTES.md | 105 + Docs/Privacy Policy | 65 +- Docs/SECURITY-FAQ.md | 28 +- .../UserScript/Return Youtube Dislike.user.js | 724 ++- .../e2e/fixtures/navigation-page.html | 348 +- .../UserScript/e2e/fixtures/watch-page.html | 204 +- Extensions/UserScript/e2e/harness.js | 51 +- Extensions/UserScript/e2e/live/README.md | 246 +- .../e2e/live/live-artifact-readiness.js | 43 + .../e2e/live/live-authenticated-context.js | 357 ++ .../UserScript/e2e/live/live-diagnostics.js | 475 +- .../e2e/live/live-extension-reloader.js | 297 ++ .../e2e/live/live-full-run-contract.js | 94 + .../e2e/live/live-interactive-readonly.js | 104 + .../e2e/live/live-interactive-runner.js | 603 ++- .../live-interactive-scenario-contract.js | 73 + .../UserScript/e2e/live/live-scenarios.js | 653 ++- .../e2e/live/live-youtube-driver.js | 4011 +++++++++++++++-- .../e2e/live/live-youtube.live.e2e.js | 197 +- .../e2e/live/reload-live-extension.js | 63 + .../UserScript/e2e/live/watch-ratio-audit.js | 236 + .../UserScript/e2e/navigation-matrix.js | 2201 ++++++++- .../userscript-delegated-activation.e2e.js | 77 + .../userscript-navigation-lifecycle.e2e.js | 64 +- .../e2e/userscript-navigation-matrix.e2e.js | 108 +- .../e2e/userscript-render-oracle.e2e.js | 43 + .../e2e/userscript-runtime-resilience.e2e.js | 25 + .../userscript-shorts-placeholder-pool.e2e.js | 64 + .../userscript-state-visual-contract.e2e.js | 450 +- .../live/live-artifact-readiness.spec.js | 76 + .../live/live-authenticated-context.spec.js | 390 ++ .../UserScript/live/live-cdp-endpoint.js | 233 + .../UserScript/live/live-cdp-endpoint.spec.js | 226 + .../UserScript/live/live-diagnostics.spec.js | 438 ++ .../live/live-extension-reloader.spec.js | 397 ++ .../live/live-full-run-contract.spec.js | 137 + .../live/live-interactive-readonly.spec.js | 212 + .../live/live-interactive-runner.spec.js | 452 ++ .../live/live-navigation-constants.js | 9 + Extensions/UserScript/live/live-options.js | 69 +- .../UserScript/live/live-options.spec.js | 106 +- .../live/live-readonly-guard.spec.js | 61 +- .../live/live-runtime-traffic-ledger.spec.js | 120 + .../UserScript/live/live-scenarios.spec.js | 1289 +++++- .../live/live-vote-traffic-recorder.spec.js | 39 +- .../live/live-youtube-driver.spec.js | 3776 +++++++++++++++- .../UserScript/live/watch-ratio-audit.spec.js | 132 + Extensions/UserScript/src/userscript-entry.js | 365 +- .../UserScript/userscript-artifact.spec.js | 25 + Extensions/combined/_locales/az/messages.json | 18 + Extensions/combined/_locales/cs/messages.json | 18 + Extensions/combined/_locales/de/messages.json | 18 + Extensions/combined/_locales/el/messages.json | 18 + Extensions/combined/_locales/en/messages.json | 39 + Extensions/combined/_locales/es/messages.json | 18 + Extensions/combined/_locales/fr/messages.json | 18 + Extensions/combined/_locales/hu/messages.json | 18 + Extensions/combined/_locales/id/messages.json | 18 + Extensions/combined/_locales/it/messages.json | 18 + Extensions/combined/_locales/ja/messages.json | 18 + Extensions/combined/_locales/ko/messages.json | 18 + Extensions/combined/_locales/nl/messages.json | 18 + Extensions/combined/_locales/pl/messages.json | 18 + .../combined/_locales/pt_BR/messages.json | 18 + Extensions/combined/_locales/ru/messages.json | 18 + .../combined/_locales/sv_SE/messages.json | 18 + Extensions/combined/_locales/tr/messages.json | 18 + Extensions/combined/_locales/uk/messages.json | 18 + Extensions/combined/_locales/vi/messages.json | 18 + .../combined/_locales/zh_CN/messages.json | 18 + .../combined/_locales/zh_TW/messages.json | 18 + .../combined/changelog/3/changelog_3.0.html | 6 - .../combined/changelog/4/changelog_4.0.html | 6 - Extensions/combined/content-style.css | 115 +- .../combined/images/hide-clutter-after.png | Bin 0 -> 3552 bytes .../combined/images/hide-clutter-before.png | Bin 0 -> 6320 bytes Extensions/combined/manifest-firefox.json | 53 +- Extensions/combined/manifest-safari.json | 10 +- Extensions/combined/menu-fixer.js | 548 ++- Extensions/combined/popup.css | 108 +- Extensions/combined/popup.html | 223 +- Extensions/combined/popup.js | 234 +- Extensions/combined/popup.spec.js | 124 + .../combined/ryd.background-changelog.spec.js | 172 + Extensions/combined/ryd.background.js | 202 +- Extensions/combined/ryd.background.spec.js | 239 + Extensions/combined/ryd.content-script.js | 393 +- Extensions/combined/src/amo-policy.spec.js | 38 + Extensions/combined/src/bar.js | 116 +- Extensions/combined/src/bar.spec.js | 106 +- Extensions/combined/src/buttons.js | 565 ++- Extensions/combined/src/buttons.spec.js | 608 ++- .../combined/src/clutter-button-setting.js | 19 + .../src/clutter-button-setting.spec.js | 40 + Extensions/combined/src/config.js | 1 + .../src/data-collection-permissions.js | 101 + .../src/data-collection-permissions.spec.js | 142 + Extensions/combined/src/events.js | 101 +- Extensions/combined/src/events.spec.js | 168 + Extensions/combined/src/github-auth.js | 102 + Extensions/combined/src/github-auth.spec.js | 144 + Extensions/combined/src/github-login-error.js | 17 + .../combined/src/initialization-cycle.js | 61 +- .../combined/src/initialization-cycle.spec.js | 138 +- Extensions/combined/src/menu-fixer.spec.js | 745 +++ Extensions/combined/src/patreon.js | 95 +- Extensions/combined/src/patreon.spec.js | 150 + .../src/popup-clutter-setting.spec.js | 63 + .../src/premiumAnalytics/index.spec.js | 64 +- .../src/premiumAnalytics/lists/index.js | 19 +- .../src/premiumAnalytics/lists/index.spec.js | 26 + .../src/premiumAnalytics/map/index.js | 9 +- .../src/premiumAnalytics/map/index.spec.js | 19 + .../combined/src/premiumAnalytics/requests.js | 43 +- .../src/premiumAnalytics/teaser/index.js | 23 +- .../src/premiumAnalytics/teaser/index.spec.js | 80 +- Extensions/combined/src/state.js | 260 +- Extensions/combined/src/state.spec.js | 267 +- Extensions/combined/src/utils.js | 69 + Extensions/combined/src/utils.spec.js | 136 +- .../combined/src/vote-client-adapter.js | 38 +- .../combined/src/vote-client-adapter.spec.js | 69 +- Extensions/combined/src/vote-data-request.js | 149 + .../combined/src/vote-data-request.spec.js | 250 + .../common/delegated-vote-activation.js | 73 + .../common/delegated-vote-activation.spec.js | 99 + Extensions/common/shorts-control-readiness.js | 163 + .../common/shorts-control-readiness.spec.js | 290 ++ Extensions/common/vote-transition-core.js | 57 + Extensions/common/vote-transition.js | 56 +- Extensions/e2e/README.md | 60 +- Extensions/e2e/action-overflow-contract.js | 111 + .../e2e/action-overflow-contract.spec.js | 50 + .../extension-action-overflow-fixture.js | 563 +++ .../extension-action-overflow.e2e.js | 247 + .../extension-artifact-navigation.e2e.js | 950 ++++ .../extension-artifact-visual.e2e.js | 179 + .../extension-hide-clutter-popup.e2e.js | 157 + .../extension-initialization-races.e2e.js | 307 ++ .../extension-mobile-artifact.e2e.js | 299 ++ .../extension/extension-navigation-fixture.js | 493 ++ .../extension/extension-render-oracle.e2e.js | 62 + .../extension-runtime-resilience.e2e.js | 30 + .../extension-startup-resilience.e2e.js | 53 + .../extension/extension-worker-signals.e2e.js | 39 + Extensions/e2e/extension/github-login.e2e.js | 128 + Extensions/e2e/firefox-changelog-lifecycle.js | 256 ++ Extensions/e2e/firefox-consent-smoke.js | 596 +++ Extensions/e2e/firefox-install-consent.js | 193 + .../generate-hide-clutter-tooltip-assets.js | 82 + Extensions/e2e/hermetic-api-contract.js | 49 + Extensions/e2e/hermetic-api-contract.spec.js | 35 + Extensions/e2e/hermetic-artifact-smoke.js | 1080 ++++- .../e2e/hermetic-artifact-smoke.spec.js | 528 ++- .../e2e/hide-clutter-tooltip-assets.spec.js | 26 + Extensions/e2e/live-runtime-adapter.js | 76 +- Extensions/e2e/navigation-runtime-contract.js | 261 ++ .../e2e/navigation-runtime-contract.spec.js | 426 ++ .../e2e/playwright-extension-global-setup.js | 35 + .../playwright-extension-global-setup.spec.js | 69 + .../e2e/playwright-userscript-global-setup.js | 19 + ...playwright-userscript-global-setup.spec.js | 108 + Extensions/e2e/runtime-resilience-contract.js | 256 ++ Extensions/e2e/shared-browser-parity.spec.js | 270 ++ Extensions/e2e/shared-live-scenarios.js | 74 +- Extensions/e2e/shared-live-scenarios.spec.js | 152 +- .../e2e/shorts-placeholder-pool-contract.js | 1335 ++++++ .../shorts-placeholder-pool-contract.spec.js | 340 ++ Extensions/e2e/shorts-visual-contract.js | 444 ++ Extensions/e2e/systematic-coverage.spec.js | 41 + Extensions/e2e/verify-extension-artifact.js | 255 ++ .../e2e/verify-extension-artifact.spec.js | 108 + Extensions/e2e/verify-userscript-artifact.js | 76 + Extensions/e2e/visual-evidence.js | 46 + Extensions/e2e/visual-evidence.spec.js | 44 + Extensions/e2e/vote-protocol-contract.js | 42 + Extensions/e2e/vote-protocol-contract.spec.js | 47 + Extensions/e2e/watch-visual-contract.js | 504 +++ Extensions/e2e/watch-visual-scenarios.js | 127 + Extensions/e2e/watch-visual-scenarios.spec.js | 100 + README_AMO_SOURCE.md | 22 +- extension-build-receipt.js | 98 + extension-build-receipt.spec.js | 99 + jest.config.js | 9 +- package-lock.json | 8 +- package.json | 26 +- playwright.extension.config.js | 26 + playwright.userscript.config.js | 5 +- scripts/build-firefox-source.mjs | 100 +- scripts/package-amo-release.cjs | 374 ++ scripts/package-amo-release.spec.js | 105 + userscript-build-receipt.js | 204 + userscript-build-receipt.spec.js | 205 + webpack.config.js | 35 + webpack.userscript.config.js | 52 +- 196 files changed, 39706 insertions(+), 2694 deletions(-) create mode 100644 AMO_SUBMISSION_NOTES.md create mode 100644 Extensions/UserScript/e2e/live/live-artifact-readiness.js create mode 100644 Extensions/UserScript/e2e/live/live-authenticated-context.js create mode 100644 Extensions/UserScript/e2e/live/live-extension-reloader.js create mode 100644 Extensions/UserScript/e2e/live/live-full-run-contract.js create mode 100644 Extensions/UserScript/e2e/live/live-interactive-readonly.js create mode 100644 Extensions/UserScript/e2e/live/live-interactive-scenario-contract.js create mode 100644 Extensions/UserScript/e2e/live/reload-live-extension.js create mode 100644 Extensions/UserScript/e2e/live/watch-ratio-audit.js create mode 100644 Extensions/UserScript/e2e/userscript-delegated-activation.e2e.js create mode 100644 Extensions/UserScript/e2e/userscript-render-oracle.e2e.js create mode 100644 Extensions/UserScript/e2e/userscript-runtime-resilience.e2e.js create mode 100644 Extensions/UserScript/e2e/userscript-shorts-placeholder-pool.e2e.js create mode 100644 Extensions/UserScript/live/live-artifact-readiness.spec.js create mode 100644 Extensions/UserScript/live/live-authenticated-context.spec.js create mode 100644 Extensions/UserScript/live/live-cdp-endpoint.js create mode 100644 Extensions/UserScript/live/live-cdp-endpoint.spec.js create mode 100644 Extensions/UserScript/live/live-extension-reloader.spec.js create mode 100644 Extensions/UserScript/live/live-full-run-contract.spec.js create mode 100644 Extensions/UserScript/live/live-interactive-readonly.spec.js create mode 100644 Extensions/UserScript/live/live-interactive-runner.spec.js create mode 100644 Extensions/UserScript/live/live-navigation-constants.js create mode 100644 Extensions/UserScript/live/live-runtime-traffic-ledger.spec.js create mode 100644 Extensions/UserScript/live/watch-ratio-audit.spec.js create mode 100644 Extensions/combined/images/hide-clutter-after.png create mode 100644 Extensions/combined/images/hide-clutter-before.png create mode 100644 Extensions/combined/popup.spec.js create mode 100644 Extensions/combined/ryd.background-changelog.spec.js create mode 100644 Extensions/combined/ryd.background.spec.js create mode 100644 Extensions/combined/src/amo-policy.spec.js create mode 100644 Extensions/combined/src/clutter-button-setting.js create mode 100644 Extensions/combined/src/clutter-button-setting.spec.js create mode 100644 Extensions/combined/src/data-collection-permissions.js create mode 100644 Extensions/combined/src/data-collection-permissions.spec.js create mode 100644 Extensions/combined/src/events.spec.js create mode 100644 Extensions/combined/src/github-auth.js create mode 100644 Extensions/combined/src/github-auth.spec.js create mode 100644 Extensions/combined/src/github-login-error.js create mode 100644 Extensions/combined/src/menu-fixer.spec.js create mode 100644 Extensions/combined/src/patreon.spec.js create mode 100644 Extensions/combined/src/popup-clutter-setting.spec.js create mode 100644 Extensions/combined/src/premiumAnalytics/lists/index.spec.js create mode 100644 Extensions/combined/src/vote-data-request.js create mode 100644 Extensions/combined/src/vote-data-request.spec.js create mode 100644 Extensions/common/delegated-vote-activation.js create mode 100644 Extensions/common/delegated-vote-activation.spec.js create mode 100644 Extensions/common/shorts-control-readiness.js create mode 100644 Extensions/common/shorts-control-readiness.spec.js create mode 100644 Extensions/common/vote-transition-core.js create mode 100644 Extensions/e2e/action-overflow-contract.js create mode 100644 Extensions/e2e/action-overflow-contract.spec.js create mode 100644 Extensions/e2e/extension/extension-action-overflow-fixture.js create mode 100644 Extensions/e2e/extension/extension-action-overflow.e2e.js create mode 100644 Extensions/e2e/extension/extension-artifact-navigation.e2e.js create mode 100644 Extensions/e2e/extension/extension-artifact-visual.e2e.js create mode 100644 Extensions/e2e/extension/extension-hide-clutter-popup.e2e.js create mode 100644 Extensions/e2e/extension/extension-initialization-races.e2e.js create mode 100644 Extensions/e2e/extension/extension-mobile-artifact.e2e.js create mode 100644 Extensions/e2e/extension/extension-navigation-fixture.js create mode 100644 Extensions/e2e/extension/extension-render-oracle.e2e.js create mode 100644 Extensions/e2e/extension/extension-runtime-resilience.e2e.js create mode 100644 Extensions/e2e/extension/extension-startup-resilience.e2e.js create mode 100644 Extensions/e2e/extension/extension-worker-signals.e2e.js create mode 100644 Extensions/e2e/extension/github-login.e2e.js create mode 100644 Extensions/e2e/firefox-changelog-lifecycle.js create mode 100644 Extensions/e2e/firefox-consent-smoke.js create mode 100644 Extensions/e2e/firefox-install-consent.js create mode 100644 Extensions/e2e/generate-hide-clutter-tooltip-assets.js create mode 100644 Extensions/e2e/hermetic-api-contract.js create mode 100644 Extensions/e2e/hermetic-api-contract.spec.js create mode 100644 Extensions/e2e/hide-clutter-tooltip-assets.spec.js create mode 100644 Extensions/e2e/navigation-runtime-contract.js create mode 100644 Extensions/e2e/navigation-runtime-contract.spec.js create mode 100644 Extensions/e2e/playwright-extension-global-setup.js create mode 100644 Extensions/e2e/playwright-extension-global-setup.spec.js create mode 100644 Extensions/e2e/playwright-userscript-global-setup.js create mode 100644 Extensions/e2e/playwright-userscript-global-setup.spec.js create mode 100644 Extensions/e2e/runtime-resilience-contract.js create mode 100644 Extensions/e2e/shared-browser-parity.spec.js create mode 100644 Extensions/e2e/shorts-placeholder-pool-contract.js create mode 100644 Extensions/e2e/shorts-placeholder-pool-contract.spec.js create mode 100644 Extensions/e2e/shorts-visual-contract.js create mode 100644 Extensions/e2e/verify-extension-artifact.js create mode 100644 Extensions/e2e/verify-extension-artifact.spec.js create mode 100644 Extensions/e2e/verify-userscript-artifact.js create mode 100644 Extensions/e2e/visual-evidence.js create mode 100644 Extensions/e2e/visual-evidence.spec.js create mode 100644 Extensions/e2e/vote-protocol-contract.js create mode 100644 Extensions/e2e/vote-protocol-contract.spec.js create mode 100644 Extensions/e2e/watch-visual-contract.js create mode 100644 Extensions/e2e/watch-visual-scenarios.js create mode 100644 Extensions/e2e/watch-visual-scenarios.spec.js create mode 100644 extension-build-receipt.js create mode 100644 extension-build-receipt.spec.js create mode 100644 playwright.extension.config.js create mode 100644 scripts/package-amo-release.cjs create mode 100644 scripts/package-amo-release.spec.js create mode 100644 userscript-build-receipt.js create mode 100644 userscript-build-receipt.spec.js diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 0c39c42..ab91a5b 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -13,6 +13,8 @@ on: - ".babelrc" - ".gitattributes" - ".nvmrc" + - "extension-build-receipt*.js" + - "userscript-build-receipt*.js" - "jest.config.js" - "package.json" - "package-lock.json" @@ -30,6 +32,8 @@ on: - ".babelrc" - ".gitattributes" - ".nvmrc" + - "extension-build-receipt*.js" + - "userscript-build-receipt*.js" - "jest.config.js" - "package.json" - "package-lock.json" @@ -59,6 +63,9 @@ jobs: - name: Build extension run: npm run build + - name: Check generated extension artifact + run: npm run check:extension-artifact + - name: Build userscript run: npm run build:userscript @@ -71,8 +78,11 @@ jobs: - name: Run generated-artifact browser tests run: npm run test:e2e:artifacts + - name: Run extension browser tests + run: npm run test:e2e:extension:run + - 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 uses: actions/upload-artifact@v4 @@ -96,7 +106,7 @@ jobs: if: failure() uses: actions/upload-artifact@v4 with: - name: userscript-playwright-failure + name: playwright-browser-failures path: | ./test-results ./playwright-report diff --git a/AMO_SUBMISSION_NOTES.md b/AMO_SUBMISSION_NOTES.md new file mode 100644 index 0000000..247d0d0 --- /dev/null +++ b/AMO_SUBMISSION_NOTES.md @@ -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. diff --git a/Docs/Privacy Policy b/Docs/Privacy Policy index c573154..690bedc 100644 --- a/Docs/Privacy Policy +++ b/Docs/Privacy Policy @@ -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. diff --git a/Docs/SECURITY-FAQ.md b/Docs/SECURITY-FAQ.md index da850a1..34705e7 100644 --- a/Docs/SECURITY-FAQ.md +++ b/Docs/SECURITY-FAQ.md @@ -4,23 +4,41 @@ Read this in other languages: [русский](SECURITY-FAQru.md), [Nederlands]( ### 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? -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? -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? -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! -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? diff --git a/Extensions/UserScript/Return Youtube Dislike.user.js b/Extensions/UserScript/Return Youtube Dislike.user.js index 42b95a5..7a728d9 100644 --- a/Extensions/UserScript/Return Youtube Dislike.user.js +++ b/Extensions/UserScript/Return Youtube Dislike.user.js @@ -30,8 +30,103 @@ // This file is generated by `npm run build:userscript`. // Edit Extensions/UserScript/src instead of the generated file. /******/ (() => { // 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__ = {}; +// 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 const DEFAULT_API_BASE_URL = "https://returnyoutubedislikeapi.com"; @@ -352,54 +447,248 @@ function createVoteClient({ -;// CONCATENATED MODULE: ./Extensions/common/vote-transition.js -const LIKED_STATE = "LIKED_STATE"; -const DISLIKED_STATE = "DISLIKED_STATE"; -const NEUTRAL_STATE = "NEUTRAL_STATE"; - -const LIKE_ACTION = "like"; -const DISLIKE_ACTION = "dislike"; - -const TRANSITIONS = { - [NEUTRAL_STATE]: { - [LIKE_ACTION]: { nextState: LIKED_STATE, value: 1, likesDelta: 1, dislikesDelta: 0 }, - [DISLIKE_ACTION]: { nextState: DISLIKED_STATE, value: -1, likesDelta: 0, dislikesDelta: 1 }, - }, - [LIKED_STATE]: { - [LIKE_ACTION]: { nextState: NEUTRAL_STATE, value: 0, likesDelta: -1, dislikesDelta: 0 }, - [DISLIKE_ACTION]: { nextState: DISLIKED_STATE, value: -1, likesDelta: -1, dislikesDelta: 1 }, - }, - [DISLIKED_STATE]: { - [LIKE_ACTION]: { nextState: LIKED_STATE, value: 1, likesDelta: 1, dislikesDelta: -1 }, - [DISLIKE_ACTION]: { nextState: NEUTRAL_STATE, value: 0, likesDelta: 0, dislikesDelta: -1 }, - }, -}; - -function resolveVoteTransition(previousState, action) { - const transition = TRANSITIONS[previousState]?.[action]; - if (!transition) { - throw new TypeError(`Unsupported vote transition: ${previousState} -> ${action}`); +;// CONCATENATED MODULE: ./Extensions/common/delegated-vote-activation.js +function defaultGetActivationTarget(control) { + if (control?.matches?.("button, tp-yt-paper-button#button")) { + return control; } - return { ...transition }; + return control?.querySelector?.("button, tp-yt-paper-button#button") ?? control ?? null; } -function applyVoteTransitionCounts(likes, dislikes, transition) { - if (!transition || !Number.isFinite(transition.likesDelta) || !Number.isFinite(transition.dislikesDelta)) { - throw new TypeError("A valid vote transition is required"); +function isHiddenWithinButtons(activationTarget, buttons) { + let current = activationTarget; + 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; - const normalizedDislikes = Number.isFinite(dislikes) ? dislikes : 0; + for (const [action, control] of [ + ["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 { - likes: Math.max(0, normalizedLikes + transition.likesDelta), - dislikes: Math.max(0, normalizedDislikes + transition.dislikesDelta), + activationTargets, + likeButton, + nativeChildren, + nativeLikeButton, + nativeLikeGraphic: nativeLikeIcon.graphic, + nativeLikeSvg: nativeLikeIcon.svg, }; } -function shouldSubmitVote({ disableVoteSubmission = false, signedOut = false } = {}) { - return disableVoteSubmission !== true && signedOut !== true; +function shortsNativeControlInventoryMatches(left, right) { + 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 @@ -573,6 +862,8 @@ const userscript_version_namespaceObject = "3.2.0"; + + if (false) {} const API_BASE_URL = "https://returnyoutubedislikeapi.com"; @@ -615,7 +906,6 @@ function getShortVideoIdFromPathname(pathname) { let isShorts = () => getShortVideoIdFromPathname(location.pathname) !== null; let mobileDislikes = 0; let suppressNextLikeActivation = false; -const boundLikeButtons = new WeakSet(); const boundDislikeButtons = new WeakSet(); const boundActivationVideoIds = new WeakMap(); const suppressedStaleRefreshTargets = new WeakSet(); @@ -628,6 +918,7 @@ let shortsLifecycleObserver = null; let shortsLifecycleObserverTarget = null; let initializationGeneration = 0; let initializationTimer = null; +let pendingShortsMutationSurface = null; let activeCountRequest = null; let countStateVideoId = null; 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) { if (!element?.isConnected || element.closest("[hidden], [aria-hidden='true'], [inert]")) { return false; @@ -693,26 +994,22 @@ function hasRenderedBox(element) { function getRendererShortVideoIds(renderer) { const identities = new Set(); + if (!renderer) { + return identities; + } const attributeVideoId = renderer.getAttribute("video-id"); if (attributeVideoId) { identities.add(attributeVideoId); - return identities; } - for (const link of renderer.querySelectorAll('a[href*="/shorts/"]')) { - try { - const linkVideoId = getShortVideoIdFromPathname(new URL(link.getAttribute("href"), location.origin).pathname); - if (linkVideoId) { - identities.add(linkVideoId); - } - } catch { - // Ignore malformed or incomplete links while YouTube hydrates the reel. - } + for (const linkVideoId of getShortsIdentityLinkVideoIds(renderer, location.href)) { + identities.add(linkVideoId); } return identities; } 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) { @@ -995,9 +1292,9 @@ function getActiveDesktopShortsActionBar() { })) .filter(({ actionBar }) => actionBar); - const matchingCandidate = candidates.find(({ renderer }) => rendererMatchesShort(renderer, videoId)); - if (matchingCandidate) { - return matchingCandidate.actionBar; + const matchingCandidates = candidates.filter(({ renderer }) => rendererMatchesShort(renderer, videoId)); + if (matchingCandidates.length === 1) { + return matchingCandidates[0].actionBar; } // 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; } +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() { const videoId = getVideoId(); const candidates = Array.from(document.querySelectorAll("ytm-like-button-renderer")) @@ -1043,18 +1381,7 @@ function getActiveMobileShortsButtons() { } function getExactShortLinkVideoIds(element) { - const identities = new Set(); - for (const link of element.querySelectorAll('a[href*="/shorts/"]')) { - try { - const linkVideoId = getShortVideoIdFromPathname(new URL(link.getAttribute("href"), location.origin).pathname); - if (linkVideoId) { - identities.add(linkVideoId); - } - } catch { - // Ignore malformed or incomplete links while YouTube hydrates the reel. - } - } - return identities; + return getShortsIdentityLinkVideoIds(element, location.href); } 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) { 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 readyForMutation = shortsControlSurfaceIsReadyForMutation(buttons, currentVideoId, { + allowUnhydratedFallback, + isHydrated: isHydrated && actionBarHasHydratedData(buttons), + isStable, + }); if (nativeDislike) { - removeSyntheticShortsDislike(syntheticDislike); + if (readyForMutation) syntheticDislikes.forEach(removeSyntheticShortsDislike); + return; + } + if (!readyForMutation) { return; } if (syntheticDislike) { - const videoId = getVideoId(); + syntheticDislikes.slice(1).forEach(removeSyntheticShortsDislike); + const videoId = currentVideoId; if (videoId && syntheticDislike.getAttribute("data-ryd-video-id") !== videoId) { syntheticDislike.setAttribute("data-ryd-video-id", videoId); setSyntheticShortsPressed(false, syntheticDislike); @@ -1272,9 +1612,7 @@ function ensureSyntheticShortsDislikeButton(buttons) { likeButton.insertAdjacentElement("afterend", ownedDislike); } -function getDislikeButton() { - const buttons = getButtons(); - ensureSyntheticShortsDislikeButton(buttons); +function getDislikeButton(buttons = getButtons()) { if (buttons?.tagName === "REEL-ACTION-BAR-VIEW-MODEL") { return ( buttons.querySelector("dislike-button-view-model, #dislike-button") ?? @@ -1303,8 +1641,7 @@ function getDislikeButton() { } } -function getLikeButton() { - const buttons = getButtons(); +function getLikeButton(buttons = getButtons()) { const firstButton = buttons?.children?.[0]; if (!firstButton) { return null; @@ -1325,18 +1662,27 @@ function getLikeTextContainer() { ); } -function getDislikeTextContainer() { - const dislikeButton = getDislikeButton(); +function getDislikeTextContainer(dislikeButton = getDislikeButton()) { + if (!dislikeButton) { + return null; + } + let result = - dislikeButton?.querySelector("#text") ?? - dislikeButton?.getElementsByTagName("yt-formatted-string")[0] ?? - dislikeButton?.querySelector("span[role='text']"); - if (result === null) { - let textSpan = document.createElement("span"); + dislikeButton.querySelector("#text") ?? + dislikeButton.getElementsByTagName("yt-formatted-string")[0] ?? + dislikeButton.querySelector("span[role='text']"); + if (result == null) { + const activationTarget = getActivationTarget(dislikeButton); + if (!activationTarget?.matches("button, tp-yt-paper-button#button")) { + return null; + } + + const textSpan = document.createElement("span"); textSpan.id = "text"; + textSpan.setAttribute("role", "text"); textSpan.style.marginLeft = "6px"; - dislikeButton?.querySelector("button").appendChild(textSpan); - if (dislikeButton) dislikeButton.querySelector("button").style.width = "auto"; + activationTarget.appendChild(textSpan); + activationTarget.style.width = "auto"; result = textSpan; } return result; @@ -1391,6 +1737,9 @@ function createObserver(options, callback) { observerWrapper.disconnect = function () { this.observer.disconnect(); }; + observerWrapper.takeRecords = function () { + return this.observer.takeRecords(); + }; return observerWrapper; } @@ -1850,11 +2199,7 @@ function removeWatchRateBarArtifacts(buttons) { } } -function clearStaleWatchPresentation(buttons, dislikeButton, videoId) { - if (isMobile || isShorts() || !initializedVideoId || initializedVideoId === videoId) { - return; - } - +function clearWatchPresentation(buttons, dislikeButton) { removeWatchRateBarArtifacts(buttons); const dislikeText = 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) { if ( isMobile || @@ -2022,8 +2375,14 @@ function setState() { } function updateDOMDislikes() { + const videoId = getVideoId(); + if (!videoId || !countStateLoaded || countStateVideoId !== videoId) { + return false; + } + setDislikes(numberFormat(dislikesvalue)); createRateBar(likesvalue, dislikesvalue); + return true; } function reportVoteFailure(error) { @@ -2208,6 +2567,49 @@ function dislikeClicked(event) { 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) { if (boundActivationVideoIds.get(event.currentTarget) === getVideoId()) { updateDOMDislikes(); @@ -2419,9 +2821,14 @@ function shortsControlsNeedInitialization() { const buttons = getButtons(); const likeButton = getLikeButton(); 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) { return true; } + if ((hasNativeDislike && syntheticDislikes > 0) || (!hasNativeDislike && syntheticDislikes !== 1)) { + return true; + } if ( initializedVideoId !== videoId || initializedLikeButton !== likeButton || @@ -2573,12 +2980,7 @@ function bindVoteButtonListeners(likeButton, dislikeButton, { enableSynthetic = const videoId = getVideoId(); boundActivationVideoIds.set(likeActivationTarget, videoId); boundActivationVideoIds.set(dislikeActivationTarget, videoId); - if (!boundLikeButtons.has(likeActivationTarget)) { - likeActivationTarget.addEventListener("click", likeClicked); - boundLikeButtons.add(likeActivationTarget); - } if (!boundDislikeButtons.has(dislikeActivationTarget)) { - dislikeActivationTarget.addEventListener("click", dislikeClicked); dislikeActivationTarget.addEventListener("focusin", refreshDislikesForBoundControl); dislikeActivationTarget.addEventListener("focusout", refreshDislikesForBoundControl); boundDislikeButtons.add(dislikeActivationTarget); @@ -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) { const videoId = getVideoId(); if (!videoId) { @@ -2602,15 +3104,31 @@ async function initializeCurrentButtons(generation) { return false; } - const buttons = getButtons(); - const likeButton = getLikeButton(); - const dislikeButton = getDislikeButton(); + let buttons = getButtons(); + const needsStableShortsControls = + 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) { return false; } if (!isShorts() && !watchControlsAreReadyForVideo(buttons, likeButton, dislikeButton, videoId)) { return false; } + if (!isShorts() && !getDislikeTextContainer(dislikeButton)) { + return false; + } observeShortsLifecycle(buttons); observeWatchRateBar(buttons, videoId); @@ -2795,7 +3313,13 @@ function checkPageLifecycle() { } function handleNavigateStart() { + pendingShortsMutationSurface = null; disconnectWatchRateBarObserver(); + if (smartimationObserver) { + smartimationObserver.takeRecords(); + smartimationObserver.disconnect(); + smartimationObserver.container = null; + } clearPendingWatchNavigationBoundary(); clearPendingWatchControlObservers(); if (isShorts()) { @@ -2823,6 +3347,11 @@ function handleNavigateStart() { return; } + activeCountRequest = null; + countStateLoaded = false; + countStateEpoch += 1; + clearWatchPresentation(buttons, dislikeButton); + const boundary = { buttons, completedVideoId: null, @@ -2874,6 +3403,7 @@ function handleNavigateFinish(event) { (function () { "use strict"; void voteClient.ensureRegistered().catch(reportVoteFailure); + document.addEventListener("click", handleDelegatedVoteClick, true); window.addEventListener("yt-navigate-start", handleNavigateStart, true); window.addEventListener("yt-navigate-finish", handleNavigateFinish, true); window.addEventListener("popstate", checkPageLifecycle, true); @@ -2892,5 +3422,7 @@ if (isMobile) { }, 1000); } +})(); + /******/ })() ; \ No newline at end of file diff --git a/Extensions/UserScript/e2e/fixtures/navigation-page.html b/Extensions/UserScript/e2e/fixtures/navigation-page.html index ee25fb4..8d9bef7 100644 --- a/Extensions/UserScript/e2e/fixtures/navigation-page.html +++ b/Extensions/UserScript/e2e/fixtures/navigation-page.html @@ -29,6 +29,15 @@ min-width: 96px; } + /* Model YouTube's overlaid Shorts next control without causing a document scroll on activation. */ + #short-next { + margin: 0; + position: fixed; + right: 4px; + top: 4px; + z-index: 1; + } + ytd-watch-flexy, ytd-menu-renderer, segmented-like-dislike-button-view-model, @@ -49,6 +58,19 @@ } segmented-like-dislike-button-view-model { + display: block; + min-height: 48px; + width: 320px; + } + + segmented-like-dislike-button-view-model > yt-smartimation, + [data-fixture-smartimation-content-shell] { + display: block; + min-height: 48px; + width: 320px; + } + + [data-fixture-smartimation-content] { display: flex; gap: 8px; min-height: 48px; @@ -60,10 +82,26 @@ min-width: 96px; } + [data-fixture-watch-icon] { + height: 20px; + width: 20px; + } + + [data-fixture-touch-feedback] { + display: none; + } + ytd-reel-video-renderer { display: block; min-height: 640px; - width: 420px; + width: min(420px, 100%); + } + + .reel-video-in-sequence-new, + .reel-video-in-sequence-thumbnail { + display: block; + min-height: 1px; + min-width: 1px; } ytd-reel-video-renderer[hidden] { @@ -155,11 +193,27 @@ (() => { const firstVideoId = "__VIDEO_ID__"; const secondVideoId = "__SECOND_VIDEO_ID__"; + const nativeLikesByVideo = new Map([ + [firstVideoId, Number("__VIDEO_LIKES__")], + [secondVideoId, Number("__SECOND_VIDEO_LIKES__")], + ]); const marker = "rydNavigationFixture=1"; const navigation = document.getElementById("fixture-navigation"); const page = document.getElementById("fixture-page"); const isMobile = location.hostname === "m.youtube.com"; + const fixtureBaseline = { + renderedDesktopShortsNativeDislikes: null, + renderedMobileShortsNativeDislikes: null, + renderedWatchNativeDislikeTextCount: null, + }; + const nativeReactionStats = { + activations: 0, + dislikeActivations: 0, + likeActivations: 0, + }; + globalThis.__navigationFixtureBaseline = fixtureBaseline; let pendingDelayedRender = null; + let pendingStartedWatchRender = null; if (!customElements.get("like-button-view-model")) { customElements.define( @@ -178,6 +232,55 @@ return `/@FixtureChannel?${marker}`; }; + const nativeLikes = (videoId) => nativeLikesByVideo.get(videoId) ?? 100; + + function setNativeLikeCount(root, videoId) { + const count = nativeLikes(videoId); + const like = root?.querySelector('[data-fixture-role="like"]'); + like?.querySelector("button")?.setAttribute("aria-label", `${count} likes`); + const text = like?.querySelector("#text, [role='text']"); + if (text) text.textContent = String(count); + } + + function setNativeReactionPressed(control, pressed) { + control?.classList.toggle("style-default-active", pressed); + control?.classList.toggle("style-text", !pressed); + control?.querySelector("button")?.setAttribute("aria-pressed", String(pressed)); + } + + function adjustNativeLikeCount(like, delta) { + const text = like?.querySelector("#text, [role='text']"); + const count = Number.parseInt(text?.textContent, 10); + if (!text || !Number.isFinite(count)) return; + text.textContent = String(Math.max(0, count + delta)); + } + + document.addEventListener( + "click", + (event) => { + const nativeButton = event.target.closest("button"); + const control = nativeButton?.closest('[data-fixture-role="like"], [data-fixture-role="dislike"]'); + if (!control || control.querySelector("button") !== nativeButton) return; + + const controls = control.closest('[data-fixture-role="buttons"]'); + const role = control.getAttribute("data-fixture-role"); + const like = controls?.querySelector('[data-fixture-role="like"]'); + const dislike = controls?.querySelector('[data-fixture-role="dislike"]'); + const wasPressed = nativeButton.getAttribute("aria-pressed") === "true"; + const counterpart = role === "like" ? dislike : like; + const counterpartWasPressed = counterpart?.querySelector("button")?.getAttribute("aria-pressed") === "true"; + + nativeReactionStats.activations += 1; + nativeReactionStats[`${role}Activations`] += 1; + + setNativeReactionPressed(control, !wasPressed); + if (!wasPressed) setNativeReactionPressed(counterpart, false); + if (role === "like") adjustNativeLikeCount(like, wasPressed ? -1 : 1); + if (role === "dislike" && !wasPressed && counterpartWasPressed) adjustNativeLikeCount(like, -1); + }, + true, + ); + const linkMarkup = (id, label, pageKind, videoId, options = {}) => ` -
- +
+ - + @@ -210,13 +313,13 @@
- - + + - + @@ -237,32 +340,57 @@ function watchMarkup(videoId) { const nextVideoId = videoId === firstVideoId ? secondVideoId : firstVideoId; + const likeCount = nativeLikes(videoId); return `
- -
- -
- -
+ + +
+
+ +
+
${linkMarkup("watch-to-short", "Open Short", "shorts", nextVideoId, { controlDelay: 200 })} ${linkMarkup("watch-related", "Open related video", "watch", nextVideoId)} ${linkMarkup("watch-next", "Play next video", "watch", nextVideoId)} @@ -272,38 +400,75 @@ } function shortRendererMarkup(videoId, active) { + const likeCount = nativeLikes(videoId); return ` +
+
-
- +
Ready
+ + - + - + - + + + + +
`; } function mobileShortRendererMarkup(videoId, active) { + const likeCount = nativeLikes(videoId); return `
- - - - + @@ -367,6 +532,24 @@ function render(pageKind, videoId) { page.innerHTML = pageMarkup(pageKind, videoId); + fixtureBaseline.renderedWatchNativeDislikeTextCount = + pageKind === "watch" + ? page.querySelectorAll( + '[data-fixture-page-kind="watch"] [data-fixture-role="dislike"] .ytSpecButtonShapeNextButtonTextContent', + ).length + : null; + fixtureBaseline.renderedDesktopShortsNativeDislikes = + pageKind === "shorts" && !isMobile + ? page.querySelectorAll( + "ytd-reel-video-renderer[is-active] > reel-action-bar-view-model > dislike-button-view-model", + ).length + : null; + fixtureBaseline.renderedMobileShortsNativeDislikes = + pageKind === "shorts" && isMobile + ? page.querySelectorAll( + "ytm-reel-video-renderer[is-active] ytm-like-button-renderer > dislike-button-view-model", + ).length + : null; navigation.innerHTML = pageKind === "channel" ? "" @@ -475,6 +658,28 @@ dispatchNavigation(); } + function navigateWatchAfterStartWithoutFinish(videoId) { + const watchPage = page.querySelector('[data-fixture-page-kind="watch"]'); + const watch = watchPage?.querySelector("ytd-watch-flexy"); + if (!watchPage || !watch) return; + document.dispatchEvent(new Event("yt-navigate-start", { bubbles: true })); + history.pushState({}, "", hrefFor("watch", videoId)); + watchPage.setAttribute("data-fixture-video-id", videoId); + watch.setAttribute("video-id", videoId); + page.dataset.fixtureTransitionPending = "watch-start-without-finish"; + pendingStartedWatchRender = () => { + delete page.dataset.fixtureTransitionPending; + render("watch", videoId); + }; + } + + function finishWatchAfterStartWithoutFinish() { + if (!pendingStartedWatchRender) throw new Error("No start-only Watch navigation is pending"); + const finish = pendingStartedWatchRender; + pendingStartedWatchRender = null; + finish(); + } + function beginSameNodeWatchNavigation(videoId) { const watch = page.querySelector("ytd-watch-flexy"); if (!watch) return; @@ -488,7 +693,7 @@ } function mutateOutgoingWatchDescendant() { - const dislike = page.querySelector('[data-fixture-page-kind="watch"] [data-ryd-role="dislike"]'); + const dislike = page.querySelector('[data-fixture-page-kind="watch"] [data-fixture-role="dislike"]'); if (!dislike) return; const countMarker = document.createElement("span"); countMarker.hidden = true; @@ -502,7 +707,7 @@ } function replaceDelayedWatchControl(role) { - const control = page.querySelector(`[data-fixture-page-kind="watch"] [data-ryd-role="${role}"]`); + const control = page.querySelector(`[data-fixture-page-kind="watch"] [data-fixture-role="${role}"]`); if (!control) return; const replacement = control.cloneNode(true); replacement.setAttribute("data-fixture-watch-replacement", role); @@ -516,7 +721,7 @@ control.replaceWith(replacement); } - function replaceCurrentWatchActions({ retainOutgoing = false } = {}) { + function replaceCurrentWatchActions({ nativeHydrationDelay = 0, retainOutgoing = false } = {}) { const section = page.querySelector('[data-fixture-page-kind="watch"]'); const currentActions = section?.querySelector("#top-level-buttons-computed"); const videoId = section?.dataset.fixtureVideoId; @@ -526,10 +731,10 @@ replacement.setAttribute("data-fixture-watch-actions-replacement", videoId); replacement.querySelectorAll(".ryd-tooltip").forEach((tooltip) => tooltip.remove()); - const controls = replacement.querySelector('[data-ryd-role="buttons"]'); + const controls = replacement.querySelector('[data-fixture-role="buttons"]'); controls?.setAttribute("data-fixture-control-video-id", videoId); for (const role of ["like", "dislike"]) { - const control = controls?.querySelector(`[data-ryd-role="${role}"]`); + const control = controls?.querySelector(`[data-fixture-role="${role}"]`); control?.classList.remove("style-default-active"); control?.classList.add("style-text"); const button = control?.querySelector("button"); @@ -537,8 +742,24 @@ button?.removeAttribute("disabled"); button?.setAttribute("aria-disabled", "false"); } - const dislikeCount = controls?.querySelector('[data-ryd-role="dislike"] #text'); - if (dislikeCount) dislikeCount.textContent = ""; + controls + ?.querySelectorAll( + '[data-fixture-role="dislike"] .ytSpecButtonShapeNextButtonTextContent, [data-fixture-role="dislike"] #text', + ) + .forEach((element) => element.remove()); + + const delayedNativeButtons = []; + if (nativeHydrationDelay > 0) { + for (const role of ["like", "dislike"]) { + const control = controls?.querySelector(`[data-fixture-role="${role}"]`); + const button = control?.querySelector("button"); + if (control && button) { + control.setAttribute("data-fixture-native-hydration-pending", role); + delayedNativeButtons.push({ button, control }); + button.remove(); + } + } + } currentActions.replaceWith(replacement); globalThis.__fixtureReplacedWatchActions = currentActions; @@ -549,17 +770,28 @@ retained.appendChild(currentActions); document.body.appendChild(retained); } + if (delayedNativeButtons.length > 0) { + setTimeout(() => { + for (const { button, control } of delayedNativeButtons) { + if (replacement.isConnected && control.isConnected && !control.querySelector("button")) { + control.appendChild(button); + control.removeAttribute("data-fixture-native-hydration-pending"); + } + } + }, nativeHydrationDelay); + } return true; } function refreshReusedWatchControl(role, videoId) { - const control = page.querySelector(`[data-fixture-page-kind="watch"] [data-ryd-role="${role}"]`); + const control = page.querySelector(`[data-fixture-page-kind="watch"] [data-fixture-role="${role}"]`); if (!control) return; + setNativeReactionPressed(control, false); control.querySelector("button")?.setAttribute("aria-label", `${role} refreshed for ${videoId}`); } function driftWatchControlBeforeNavigation(role, videoId) { - const button = page.querySelector(`[data-fixture-page-kind="watch"] [data-ryd-role="${role}"] button`); + const button = page.querySelector(`[data-fixture-page-kind="watch"] [data-fixture-role="${role}"] button`); if (!button) return; button.setAttribute("aria-label", `${role} drift while ${videoId}`); button.setAttribute("title", `${role} title drift while ${videoId}`); @@ -571,7 +803,7 @@ if (!overlay) return; const replacement = overlay.cloneNode(true); replacement.setAttribute("data-fixture-replacement", "true"); - const dislike = replacement.querySelector('[data-ryd-role="dislike"]'); + const dislike = replacement.querySelector('[data-fixture-role="dislike"]'); dislike?.classList.remove("style-default-active"); dislike?.classList.add("style-text"); dislike?.querySelector("button")?.setAttribute("aria-pressed", "false"); @@ -624,18 +856,24 @@ }, finishDelayedNavigation, finishSameNodeWatchNavigation, + finishWatchAfterStartWithoutFinish, driftWatchControlBeforeNavigation, navigate, navigateDelayedAnonymousShort, navigateDelayedShort, navigateDelayedWatch, navigateToShortsDescendant, + navigateWatchAfterStartWithoutFinish, mutateOutgoingWatchDescendant, + nativeReactionSnapshot() { + return { ...nativeReactionStats }; + }, recycleShort, replaceActiveMobileOverlay, replaceCurrentWatchActions, replaceDelayedWatchControl, refreshReusedWatchControl, + setNativeLikeCount, }; render("__INITIAL_PAGE_KIND__", "__VIDEO_ID__"); diff --git a/Extensions/UserScript/e2e/fixtures/watch-page.html b/Extensions/UserScript/e2e/fixtures/watch-page.html index 9d94aca..a4f507a 100644 --- a/Extensions/UserScript/e2e/fixtures/watch-page.html +++ b/Extensions/UserScript/e2e/fixtures/watch-page.html @@ -25,6 +25,9 @@ .slim-video-action-bar-actions, .segmented-buttons, segmented-like-dislike-button-view-model, + yt-smartimation, + toggle-button-view-model, + button-view-model, like-button-view-model, dislike-button-view-model { display: block; @@ -32,17 +35,33 @@ ytd-menu-renderer.ytd-watch-metadata > div { display: block; - width: min(320px, 100%); min-height: 36px; } #top-row { - width: min(320px, 100%); + width: min(500px, 100%); + } + + ytd-menu-renderer.ytd-watch-metadata { + display: flex; + gap: 8px; + width: min(500px, 100%); + } + + #top-level-buttons-computed { + position: relative; + width: 192px; } segmented-like-dislike-button-view-model { + display: block; + gap: 0; + } + + [data-fixture-smartimation-content] { display: flex; gap: 0; + width: 192px; } .segmented-buttons { @@ -56,8 +75,8 @@ display: none; } - [data-ryd-role="like"], - [data-ryd-role="dislike"] { + [__FIXTURE_ROLE_ATTRIBUTE__="like"], + [__FIXTURE_ROLE_ATTRIBUTE__="dislike"] { box-sizing: border-box; flex: 0 0 96px; height: 36px; @@ -66,8 +85,8 @@ width: 96px; } - [data-ryd-role="like"] button, - [data-ryd-role="dislike"] button { + [__FIXTURE_ROLE_ATTRIBUTE__="like"] button, + [__FIXTURE_ROLE_ATTRIBUTE__="dislike"] button { align-items: center; background: rgb(39, 39, 39); border: 0; @@ -88,7 +107,7 @@ width: 96px; } - [data-ryd-role].style-default-active button { + [__FIXTURE_ROLE_ATTRIBUTE__].style-default-active button { background: rgb(241, 241, 241); color: rgb(15, 15, 15); } @@ -102,7 +121,8 @@ width: 20px; } - [data-ryd-role] #text { + [__FIXTURE_ROLE_ATTRIBUTE__] #text, + [__FIXTURE_ROLE_ATTRIBUTE__] .ytSpecButtonShapeNextButtonTextContent { display: block; font: 500 14px/20px Arial, @@ -110,6 +130,32 @@ margin: 0; padding: 0; } + + #flexible-item-buttons { + display: flex; + gap: 8px; + margin: 0; + width: 172px; + } + + #flexible-item-buttons button { + background: rgb(39, 39, 39); + border: 0; + border-radius: 18px; + color: rgb(241, 241, 241); + height: 36px; + min-width: 82px; + } + + #fixture-comment-action-surfaces #top-level-buttons-computed { + height: 0; + overflow: hidden; + width: 0; + } + + [data-fixture-touch-feedback] { + display: none; + } @@ -117,12 +163,22 @@
- -
- -
+ +
+ +
+
+
@@ -147,51 +203,67 @@ function desktopButtonMarkup() { return ` - - - - - - - + + +
+
+ + + + + + + + + + + + + + +
+
+
`; } function mobileButtonMarkup() { return ` - - - + `; } function applyNativeState(state) { - const like = host.querySelector('[data-ryd-role="like"]'); - const dislike = host.querySelector('[data-ryd-role="dislike"]'); + const like = host.querySelector('[__FIXTURE_ROLE_ATTRIBUTE__="like"]'); + const dislike = host.querySelector('[__FIXTURE_ROLE_ATTRIBUTE__="dislike"]'); if (!like || !dislike) return; like.classList.toggle("style-default-active", state === "liked"); @@ -203,6 +275,17 @@ host.dataset.nativeState = state; } + function applyNativeLikeDelta(delta) { + const button = host.querySelector('[__FIXTURE_ROLE_ATTRIBUTE__="like"] button'); + const text = button?.querySelector("#text, [role='text']"); + if (!button || !text) return; + + const current = Number(text.textContent.replace(/[^\d]/g, "")) || 0; + const next = Math.max(0, current + delta); + text.textContent = String(next); + button.setAttribute("aria-label", `${next} likes`); + } + function insertButtons(state = "neutral") { host.innerHTML = isMobile ? mobileButtonMarkup() : desktopButtonMarkup(); applyNativeState(state); @@ -213,6 +296,28 @@ host.dataset.nativeState = "neutral"; } + function installCommentActionSurfaces() { + const comments = document.getElementById("fixture-comment-action-surfaces"); + for (let index = 0; index < 20; index += 1) { + const duplicate = document.createElement("div"); + duplicate.id = "top-level-buttons-computed"; + duplicate.setAttribute("data-fixture-comment-action-surface", String(index)); + duplicate.innerHTML = ` + + + + + + + `; + comments.appendChild(duplicate); + } + } + function navigate(videoId, { state = "neutral", buttons = true } = {}) { history.pushState({}, "", `/watch?v=${videoId}`); watch.setAttribute("video-id", videoId); @@ -225,11 +330,22 @@ host.addEventListener( "click", (event) => { - const like = event.target.closest('[data-ryd-role="like"]'); - const dislike = event.target.closest('[data-ryd-role="dislike"]'); + const like = event.target.closest('[__FIXTURE_ROLE_ATTRIBUTE__="like"]'); + const dislike = event.target.closest('[__FIXTURE_ROLE_ATTRIBUTE__="dislike"]'); const current = host.dataset.nativeState || "neutral"; - if (like) applyNativeState(current === "liked" ? "neutral" : "liked"); - if (dislike) applyNativeState(current === "disliked" ? "neutral" : "disliked"); + const next = like + ? current === "liked" + ? "neutral" + : "liked" + : dislike + ? current === "disliked" + ? "neutral" + : "disliked" + : current; + if (next !== current) { + applyNativeLikeDelta(Number(next === "liked") - Number(current === "liked")); + applyNativeState(next); + } }, true, ); @@ -243,7 +359,11 @@ }; setSignedIn(__SIGNED_IN__); + installCommentActionSurfaces(); if (__INITIAL_BUTTONS__) insertButtons("__INITIAL_STATE__"); + window.__youtubeFixture.initialDislikeTextContainers = host.querySelectorAll( + '[__FIXTURE_ROLE_ATTRIBUTE__="dislike"] #text, [__FIXTURE_ROLE_ATTRIBUTE__="dislike"] .ytSpecButtonShapeNextButtonTextContent', + ).length; })(); diff --git a/Extensions/UserScript/e2e/harness.js b/Extensions/UserScript/e2e/harness.js index e1ef59d..dda9eea 100644 --- a/Extensions/UserScript/e2e/harness.js +++ b/Extensions/UserScript/e2e/harness.js @@ -1,10 +1,10 @@ const fs = require("fs"); const path = require("path"); +const { isAllowedApiPreflight } = require("../../e2e/hermetic-api-contract"); +const { resolveRequestedUserscriptArtifact } = require("../../e2e/verify-userscript-artifact"); const REPOSITORY_ROOT = path.resolve(__dirname, "../../.."); -const GENERATED_USERSCRIPT = - process.env.RYD_USERSCRIPT_ARTIFACT || - path.join(REPOSITORY_ROOT, "Extensions", "UserScript", "Return Youtube Dislike.user.js"); +const GENERATED_USERSCRIPT = resolveRequestedUserscriptArtifact(); const WATCH_FIXTURE = fs.readFileSync(path.join(__dirname, "fixtures", "watch-page.html"), "utf8"); const SHORTS_FIXTURE = fs.readFileSync(path.join(__dirname, "fixtures", "shorts-page.html"), "utf8"); const NAVIGATION_FIXTURE = fs.readFileSync(path.join(__dirname, "fixtures", "navigation-page.html"), "utf8"); @@ -45,11 +45,14 @@ function jsonHeaders() { function createFakeBackend({ countsByVideo = {}, countDelayByVideo = {}, fixture = {} } = {}) { const blockedRequests = []; + const preflightRequests = []; const requests = []; const responsePlans = new Map(); const fixtureOptions = { initialButtons: fixture.initialButtons !== false, initialState: fixture.initialState || "neutral", + nativeDislikeText: fixture.nativeDislikeText !== false, + roleAttribute: fixture.roleAttribute || "data-ryd-role", signedIn: fixture.signedIn !== false, }; @@ -147,6 +150,13 @@ function createFakeBackend({ countsByVideo = {}, countDelayByVideo = {}, fixture const html = fixtureTemplate .replaceAll("__VIDEO_ID__", videoId) .replaceAll("__SECOND_VIDEO_ID__", VIDEO_B) + .replaceAll("__VIDEO_LIKES__", String(countsByVideo[videoId]?.likes ?? 100)) + .replaceAll("__SECOND_VIDEO_LIKES__", String(countsByVideo[VIDEO_B]?.likes ?? 100)) + .replaceAll( + "__DISLIKE_TEXT_MARKUP__", + fixtureOptions.nativeDislikeText ? '' : "", + ) + .replaceAll("__FIXTURE_ROLE_ATTRIBUTE__", fixtureOptions.roleAttribute) .replaceAll("__INITIAL_PAGE_KIND__", isShorts ? "shorts" : url.pathname === "/watch" ? "watch" : "channel") .replaceAll( "__SHORTS_RENDERER_TAG__", @@ -174,6 +184,22 @@ function createFakeBackend({ countsByVideo = {}, countDelayByVideo = {}, fixture } if (request.method() === "OPTIONS") { + const requestedMethod = request.headers()["access-control-request-method"]; + const preflight = { + method: request.method(), + pathname: url.pathname, + requestedMethod: requestedMethod ?? null, + url: url.toString(), + }; + if (!isAllowedApiPreflight(url.pathname, requestedMethod)) { + blockedRequests.push({ + ...preflight, + resourceType: request.resourceType(), + }); + await route.abort("blockedbyclient"); + return; + } + preflightRequests.push(preflight); await route.fulfill({ status: 204, headers: jsonHeaders(), body: "" }); return; } @@ -202,11 +228,14 @@ function createFakeBackend({ countsByVideo = {}, countDelayByVideo = {}, fixture if (response.delayMs) await delay(response.delayMs); const body = response.body === undefined ? null : response.body; + const responseStatus = response.status || 200; await route.fulfill({ - status: response.status || 200, + status: responseStatus, headers: { ...jsonHeaders(), ...(response.headers || {}) }, body: typeof body === "string" ? body : JSON.stringify(body), }); + record.responseBody = body; + record.responseStatus = responseStatus; record.respondedAt = Date.now(); } @@ -215,6 +244,7 @@ function createFakeBackend({ countsByVideo = {}, countDelayByVideo = {}, fixture defer, enqueue, handle, + preflightRequests, requests, requestsFor, }; @@ -340,17 +370,20 @@ function overrideBooleanOption(source, optionName, value) { return source.replace(optionPattern, `${optionName}: ${value}`); } -async function injectGeneratedUserscript(page, { coloredThumbs, disableVoteSubmission = false, rateBarEnabled } = {}) { - if (!fs.existsSync(GENERATED_USERSCRIPT)) { - throw new Error(`Generated userscript is missing: ${GENERATED_USERSCRIPT}`); +async function injectGeneratedUserscript( + page, + { artifactPath = GENERATED_USERSCRIPT, coloredThumbs, disableVoteSubmission = false, rateBarEnabled } = {}, +) { + if (!fs.existsSync(artifactPath)) { + throw new Error(`Generated userscript is missing: ${artifactPath}`); } if (!disableVoteSubmission && rateBarEnabled === undefined && coloredThumbs === undefined) { - await page.addScriptTag({ path: GENERATED_USERSCRIPT }); + await page.addScriptTag({ path: artifactPath }); return; } - let source = fs.readFileSync(GENERATED_USERSCRIPT, "utf8"); + let source = fs.readFileSync(artifactPath, "utf8"); if (disableVoteSubmission) { source = overrideBooleanOption(source, "disableVoteSubmission", true); } diff --git a/Extensions/UserScript/e2e/live/README.md b/Extensions/UserScript/e2e/live/README.md index 345282f..b3768f6 100644 --- a/Extensions/UserScript/e2e/live/README.md +++ b/Extensions/UserScript/e2e/live/README.md @@ -1,33 +1,56 @@ # Live YouTube smoke -This suite attaches over CDP to an already-running Brave profile. It is deliberately separate from `test:all` and CI. +This suite attaches over CDP to an already-running Chrome, Brave, or compatible Chromium profile. It is deliberately +separate from `test:all` and CI. The same page-level checks run in either userscript or extension mode; installation, storage, and protocol failure coverage remain in the hermetic suites. ## Browser preparation -1. Open `brave://inspect/#remote-debugging` in the target Brave profile and enable **Allow remote debugging for this - browser instance**. Set `RYD_CDP_ENDPOINT` to the explicit HTTP or WebSocket endpoint exposed by that Brave instance; - `http://127.0.0.1:9222` is a common HTTP example, but use the actual port shown by Brave. +1. Open the target browser's remote-debugging page (for example, `chrome://inspect/#remote-debugging` or + `brave://inspect/#remote-debugging`) and enable **Allow remote debugging for this browser instance**. If the page + displays only a server such as `127.0.0.1:60011`, set `RYD_CDP_ENDPOINT` to that exact host and port. Current Chromium + builds can return 404 from `/json/version` in this mode; the launcher reads the full browser WebSocket path from the + matching `DevToolsActivePort` file in the standard Chrome, Brave, Edge, or Chromium user-data root. Explicit + `ws://...` endpoints and legacy `http://127.0.0.1:9222` endpoints remain supported unchanged. Set the endpoint to the + browser alias `chrome`, `brave`, `edge`, or `chromium` to read that browser's standard active-port file directly. + For a custom or portable profile, set either `RYD_CDP_USER_DATA_DIR` to its user-data root or + `RYD_CDP_ACTIVE_PORT_FILE` to the exact file; do not set both overrides. The launcher waits 120 seconds for the + browser's **Allow remote debugging** confirmation. `RYD_CDP_CONNECT_TIMEOUT_MS` can override that wait from 15000 to + 300000 milliseconds. 2. Use a dedicated two-item playlist containing two allowlisted public or unlisted test videos. Both must be accessible to the signed-in profile and must have different rendered dislike counts. 3. Enable exactly one runtime: - - `userscript`: disable the browser extension and every other Return YouTube Dislike userscript, run - `npm run build:live:userscript`, import - `test-results/live-build/userscript/Return Youtube Dislike.user.js` into Tampermonkey, and enable it. - - `extension`: disable every Return YouTube Dislike userscript and any store-installed copy of the extension, run - `npm run build:live:extension`, then enable or reload only the unpacked extension at - `Extensions/combined/dist/chrome`. + - `userscript`: disable the browser extension and every other Return YouTube Dislike userscript. A read-only run + requires `npm run build:live:userscript` followed by importing + `test-results/live-build/userscript/Return Youtube Dislike.user.js` into Tampermonkey. A full run performs that + build itself, stops before browser attachment, and requires `INSTALLED` after the freshly generated file has been + reimported. + - `extension`: disable every Return YouTube Dislike userscript and any store-installed copy of the extension. A + read-only run requires `npm run build:live:extension` followed by `npm run reload:live:extension`. A full run + performs the fresh build and reload itself. The already-enabled unpacked extension must point at + `Extensions/combined/dist/chrome`. Keep an authenticated ordinary + YouTube page open in the target profile. Before reloading anything, the runner scans every attached browser + context and page, ignores internal and non-YouTube tabs, and verifies the exact public + `RYD_LIVE_EXPECTED_CHANNEL` handle from YouTube's account menu. Exactly one context may match. It reuses that + existing page for the CDP session and the worker's own identity check; it never reads cookies or storage and fails + closed on an absent, unverifiable, or ambiguous profile. Every live build receives a fresh random build ID even when its semantic version is unchanged. Import or reload the runtime **after the final live build command**; rebuilding again invalidates the installed copy for this smoke. -4. Keep the Brave window open and do not interact with the tab created by the suite. The suite creates, uses, and closes - only its own tab. + Hard-reload any already-open YouTube tab before ad hoc testing after an unpacked-extension reload. Existing documents + retain their previously injected content script; a rendered count or control in such a tab does not prove that the + newly built bundle is running. The automated suite avoids this ambiguity by creating a fresh tab and checking the + exact live-build marker before every scenario. +4. Keep the browser window open and do not interact with the tab created by the suite. The suite creates, uses, and + closes only its own tab. Only the explicit live-test builds expose page markers containing their runtime, version, and exact build ID. Normal production builds do not expose them. The suite reads the expected ID from the generated `live-build.json`, verifies that the installed runtime exposes that exact ID and expected version, and verifies that the other runtime marker is absent before every scenario. A stale installed script or extension therefore fails even when it has the same version. -There is no environment override for the build ID. +Before connecting to the browser it also verifies a build receipt against every current userscript or extension source +input and against the generated artifact bytes. Editing source after a build, changing generated output, or reusing a +receipt from another build fails closed. There is no environment override for the build ID or receipt. The read-only smoke still loads real YouTube and allows the installed runtime to read from the production RYD API, including eager registration when the selected runtime has no confirmed identity. It does not mock API responses, but @@ -41,74 +64,148 @@ start on `RYD_LIVE_WATCH_A`. ```powershell $env:RYD_LIVE_YOUTUBE="1" $env:RYD_LIVE_PRODUCTION_API="1" -$env:RYD_CDP_ENDPOINT="http://127.0.0.1:9222" +$env:RYD_CDP_ENDPOINT="127.0.0.1:60011" # Copy the current value shown by the browser. +# Optional for a custom or portable profile (choose only one): +# $env:RYD_CDP_USER_DATA_DIR="C:\path\to\User Data" +# $env:RYD_CDP_ACTIVE_PORT_FILE="C:\path\to\User Data\DevToolsActivePort" +# $env:RYD_CDP_CONNECT_TIMEOUT_MS="180000" $env:RYD_LIVE_RUNTIME="userscript" $env:RYD_LIVE_EXPECTED_CHANNEL="@your-test-channel" $env:RYD_LIVE_WATCH_A="AAAAAAAAAAA" $env:RYD_LIVE_WATCH_B="BBBBBBBBBBB" $env:RYD_LIVE_SHORT="CCCCCCCCCCC" +# Optional: use a different allowlisted Short whose exact count visibly changes by one for reaction validation. +# Defaults to RYD_LIVE_SHORT. +# $env:RYD_LIVE_REACTION_SHORT="EEEEEEEEEEE" $env:RYD_LIVE_PLAYLIST_URL="https://www.youtube.com/watch?v=AAAAAAAAAAA&list=PLAYLIST_ID" # Optional overrides for the cold channel-navigation smoke. These are the defaults: -$env:RYD_LIVE_NAV_CHANNEL_URL="https://www.youtube.com/@SmashTrash" -$env:RYD_LIVE_NAV_SHORT="iKQhN7omLM4" +$env:RYD_LIVE_NAV_CHANNEL_URL="https://www.youtube.com/@MrBeast" +$env:RYD_LIVE_NAV_SHORT="5mU6SRS2Bxo" +$env:RYD_LIVE_NAV_WATCH="Qtl8lJwbd4g" # Exact visible Watch link on the configured channel page. +# Optional; the Shorts stress targets 10 successful Next samples and accepts 10 through 25. +$env:RYD_LIVE_SHORTS_NEXT_HOPS="10" # Optional; the consecutive watch-sidebar stress defaults to three hops and accepts 1 through 10. $env:RYD_LIVE_SIDEBAR_HOPS="3" -npm run test:live:youtube +npm run test:live:youtube:read-only ``` -The navigation smoke performs a real hard load and reload of `RYD_LIVE_NAV_CHANNEL_URL`, finds an exact visible link to -`RYD_LIVE_NAV_SHORT`, and clicks that link. It fails rather than falling back to direct navigation when the configured -channel no longer contains the exact card. It proves the channel-to-Short transition reused the same document, verifies -that the selected runtime initialized the current Short (including exactly one visible synthetic control in userscript -mode), clicks YouTube's visible **Next video** control, proves that transition also reused the document, and verifies a -new current video ID and initialized control. The channel-to-Short check runs first in the fresh suite tab so earlier -watch or Shorts visits cannot warm the page lifecycle it is meant to exercise. +The default channel fixture was last verified in authenticated Chrome on September 4, 2026. Its exact first Short was +visible on the channel shelf, and ten consecutive visible **Next video** controls reached ten distinct Shorts. Reverify +the exact channel cards before a later release run because YouTube channel ordering changes over time. -YouTube can occasionally focus the visible **Next video** button without acting on its first trusted click. The live -driver waits five seconds for that first click, prints `LIVE_CHECKPOINT shorts-next-control.retrying`, and makes exactly -one more trusted click with the remaining 25-second navigation budget. If the URL still does not advance, the scenario -fails; it never loops or clicks a reaction control as part of this retry. +The navigation smoke derives the channel's `/shorts` and `/videos` tabs from `RYD_LIVE_NAV_CHANNEL_URL`, performs a +real hard load and reload of the relevant tab, finds the configured exact visible target, and activates that link. If +YouTube ignores the first hit-tested pointer activation, the driver re-resolves the same exact target and makes one +hit-tested keyboard activation attempt. It fails rather than falling back to direct navigation when the relevant +channel tab no longer contains the exact card. It proves the channel-to-Short transition reused the same document, verifies +that the selected runtime initialized the current Short, then keeps pressing YouTube's visible **Next video** control +until it has audited at least 10 successful, distinct samples. `RYD_LIVE_SHORTS_NEXT_HOPS` is the successful-sample +target, not the raw number of presses; the runner makes at most twice that many Next attempts. The initial transition +and every attempt baseline the driver's request IDs before navigation and reject any stale `/votes` request. A valid +sample must have at least one visible native YouTube action, an HTTP 200 response whose JSON `id` equals the exact +current 11-character video ID, and the authoritative dislike count rendered in one of the runtime's supported localized +number formats. Every valid Next sample must emit exactly one request. During the initial transition, the extension may +instead emit the source-supported sequence of one aggregate request followed by one request enriched with YouTube's +newly available numeric `likeCount`; the second response is authoritative. A repeated aggregate request, repeated +enrichment, reversed order, third request, or invalid `likeCount` still fails as duplicate traffic. Every attempt must +also keep the same document and produce a video ID that has not appeared earlier in the sequence. The current +synthetic dislike control must have no visible stale or duplicate controls and must preserve its count and ownership +through a bounded two-second stability soak. The suite also rechecks the runtime build marker and the complete Shorts +action-stack geometry after every hop. Page-owned requests must belong to the suite tab. Frame-less requests are +accepted only from the selected extension's `ryd.background.js` service worker, and the hop fails as ambiguous if +another browser tab has the destination video open. The channel-to-Short check runs first in the fresh suite tab so +earlier watch or Shorts visits cannot warm the page lifecycle it is +meant to exercise. Cropped evidence for the initial Short and every hop is written under +`test-results/live-youtube/shorts-navigation/` for both runtimes. -After the Shorts URL advances, the driver deliberately leaves playback running while YouTube hydrates the new reel, -the production `/votes` response arrives, and the current dislike control renders. It pauses only after those checks, -so a newly selected Short may play briefly before the `LIVE_CHECKPOINT playback.paused` message appears. +YouTube itself sometimes leaves a selected reel with no visible native action controls. The runner samples the whole +visible Shorts rail across a bounded 20-second observation window. Each synchronous DOM sample also returns the exact +current reel's visible video box and viewport, which the runner uses to periodically pulse the real browser pointer so +an idle auto-hidden rail gets a fair chance to appear. It does not walk retained video locators or wait for animation +frames in this polling path. Every DOM probe and pointer operation has its own two-second watchdog; a stalled browser +operation invalidates and fails the stage instead of producing blank evidence. Native buttons are evaluated individually +instead of requiring their whole action-bar host to fit in the viewport. Before a sample is finally recorded as blank, +the runner captures its evidence, waits two animation frames, and remeasures once; a rail revealed at that boundary +follows the normal strict runtime-control assertions. Only a sample with zero visible native actions for that complete window is recorded +as `shorts-sample.skipped`, including its video ID, observation duration, reason, attempt number, and whether any +`/votes` request was seen. Any request seen during a blank sample is still required to target that exact current ID and +return a valid successful response; stale traffic is never forgiven. If even one meaningful native action is visible, +a missing runtime Dislike or missing `/votes` request remains a hard failure. The run passes only after reaching the +configured successful-sample target and only when valid samples strictly outnumber blank YouTube samples. + +YouTube can occasionally focus the visible **Next video** button without acting on its first trusted click. On each +attempt, the live driver waits five seconds for that first click, prints +`LIVE_CHECKPOINT shorts-next-control.retrying`, and makes exactly one more trusted click with the remaining 25-second +navigation budget. If the URL still does not advance, the scenario fails; it never loops indefinitely or clicks a +reaction control as part of this retry. + +After each Shorts URL advances, the driver deliberately leaves playback running while YouTube hydrates the new reel, +the production `/votes` response arrives, and the current dislike control and geometry render. It pauses only after all +configured successful samples pass, so newly selected Shorts may play briefly before the `LIVE_CHECKPOINT +playback.paused` message appears. The watch-sidebar stress starts with the allowlisted `RYD_LIVE_WATCH_A`, then takes the first eligible visible `#related` watch link on each page for the configured number of consecutive SPA hops. Previously visited IDs are -skipped. Every hop waits for the exact production `/votes?videoId=` response and a rendered dislike count, +skipped. Every hop applies the same new-request baseline and exact request/HTTP/body-ID oracle, compares the rendered +dislike count with that response, requires exactly one visible ratio bar for the selected runtime with valid reaction-control geometry, and samples that same bar and count for four seconds to catch delayed YouTube pruning. It never clicks Like or Dislike. Deterministic evidence paths are overwritten on each run at `test-results/live-youtube/sidebar-stress/{runtime}-sidebar-hop-{01..N}.png`. The same scenario runs in userscript and extension mode. +The direct Watch, reload, playlist SPA, and cold channel-to-Watch scenarios use that same exact network oracle. +The userscript must issue one request. The extension may issue either one request or the single ordered aggregate-to- +`likeCount` refinement described above, matching the intentional richer-refresh behavior in its shared count client. +After each response they require one current-video ratio bar and fill, reject a visible stale or duplicate bar, compare +the rendered localized count with the final authoritative API response, and repeatedly sample the current video and +unchanged result for a bounded stability interval. + Every non-voting live scenario installs a BrowserContext deny route before it starts and aborts every production `POST /interact/*` request. The accompanying request observer intentionally does not require a page frame, so attempts from an extension or service worker also fail the scenario. The guard applies to the entire attached browser context; do not use another tab in that Brave profile to react to a video while the smoke is running. Automatic media-ended transitions for both watch pages and Shorts stay in the deterministic hermetic Playwright suite. -This production smoke intentionally exercises the visible Shorts **Next video** control only: live autoplay timing, -recommendation queues, ads, and account experiments are not stable enough to make an exact production transition a -reliable gate. The existing two-item playlist smoke continues to cover an explicit watch-page SPA transition. +This production smoke intentionally exercises at least 10 valid Shorts **Next video** transitions; fully blank native +YouTube rails do not reduce that total. Live autoplay timing, recommendation queues, ads, and account experiments are +not stable enough to make an exact production transition a reliable gate. The existing two-item playlist smoke +continues to cover an explicit watch-page SPA transition. -The default channel dataset can drift as its public page changes. Override both variables with a channel URL and Short -that are deliberately kept together. The channel URL is restricted to a plain HTTPS `youtube.com/@handle` page or its -`featured`, `shorts`, or `videos` tab; the target must be an 11-character video ID. +The default channel dataset can drift as its public page changes. Override the channel URL and both target IDs with a +channel whose `/shorts` and `/videos` tabs deliberately retain those exact cards. The channel URL is restricted to a +plain HTTPS `youtube.com/@handle` page or its `featured`, `shorts`, or `videos` tab; either way the driver selects the +kind-specific tab. Each target must be an 11-character video ID. -An additional cold channel-to-watch scenario is available only when its exact link is deterministic on the configured -channel page. Opt in with an 11-character ID; otherwise that scenario is skipped: +The cold channel-to-Watch scenario is required. `RYD_LIVE_NAV_WATCH` must identify an exact visible Watch link on the +configured channel page; an absent or stale target fails the run and cannot count as completed coverage. + +`npm run test:live:youtube:read-only` is the deliberately non-voting command. Its completion record is classified as +`read-only` with `releaseReady: false`; it cannot be reported as full validation. For a full run, use +`npm run test:live:youtube` (or its `:full` alias). It builds the selected runtime first, runs every read-only scenario, +then waits for the production-reaction approval described below. Entering `SKIP` fails the command as incomplete. + +To keep one approved browser-debugging connection alive across complete retries, opt into the persistent interactive +session before starting the full runner: ```powershell -$env:RYD_LIVE_NAV_WATCH="DDDDDDDDDDD" +$env:RYD_LIVE_KEEP_CDP_SESSION="1" +npm run test:live:youtube ``` -For the interactive Brave run, use `npm run test:live:youtube:interactive`. Before requesting any reaction approval, -it runs the read-only scenarios plus responsive visual checks. Enter `SKIP` at the prompt to finish without reactions. -The visual pass checks the watch-page ratio bar at widths 1280, 768, and 390. In userscript mode it also checks the -modern Shorts synthetic dislike control, its geometry, and its rendered count at those widths. In extension mode it -checks the active reel's native Like/Dislike pair at all three widths: exact action-host, button, and icon sizes; -typography; spacing; common reel ownership; ordering; and viewport containment. Cropped evidence images are written +After each passed or failed attempt it archives the current live evidence under +`test-results/live-youtube/attempts/`, prints `READY_FOR_LIVE_RERUN_OR_EXIT`, and waits for exactly `RERUN` or `EXIT`. +`RERUN` rebuilds the selected runtime, reloads the unpacked extension when applicable, and executes the complete +read-only and reaction flow again through the same top-level browser connection instead of making a new attachment. +Each rerun still requires a fresh reaction approval token, and a userscript rerun still requires a fresh install +acknowledgement. The endpoint, authenticated handle, and runtime cannot change while reusing the connection. `EXIT` +closes the connection and returns the latest attempt's status. Leaving +`RYD_LIVE_KEEP_CDP_SESSION` unset preserves the normal single-attempt behavior. + +The visual pass checks the watch-page ratio bar at widths 1280, 768, and 390. For both runtimes it also checks the +modern Shorts synthetic dislike control, its geometry, its rendered count, and its position beside the active reel's +native Like control at those widths: exact action-host, button, and icon sizes; typography; spacing; common reel +ownership; ordering; and viewport containment. Cropped evidence images are written under `test-results/live-youtube/responsive/`. If YouTube's native Like/Dislike pill is horizontally clipped by its own mobile page overflow, the ratio bar may share only that same native left/right footprint; any extra overflow introduced by the RYD bar still fails. @@ -117,10 +214,23 @@ The interactive runner prints `LIVE_STAGE_START`, `LIVE_STAGE_COMPLETE`, and `LI In particular, `LIVE_CHECKPOINT playback.paused` means the runner deliberately paused the current YouTube video while validating it; a stationary frame after that message is expected and is not evidence that Brave froze. Navigation, account, runtime, control, and production `/votes` waits have their own checkpoints, so the last line identifies the -pending operation. +pending operation. Every read-only stage also reports when it starts and completes removal of its production-interaction +deny route. That cleanup and the final test-tab close each have a ten-second watchdog: a stalled Chrome target fails the +attempt and returns control to the persistent runner instead of waiting indefinitely. A cleanup watchdog failure never +permits the runner to continue into production reactions. -If a scenario fails, the runner records page errors, unhandled promise rejections, console errors, and the latest RYD -API request outcomes before closing its test tab. It writes a JSON snapshot under +Both live runners treat a page error, unhandled promise rejection, unexpected `console.error`, or error from the +selected extension's service worker as a test failure even when the DOM and network assertions otherwise pass. A +benign message can be ignored only by adding an explicit rule that matches its exact signal type, full message, and +full source URL; there are no wildcard or callback suppressions. The Playwright runner uses a fresh page, driver, and +diagnostic guard for each scenario and one worker, so one failed read-only scenario does not suppress the remaining +independent scenarios. Its production-reaction test is gated on every required read-only scenario completing in the +same worker; a failure-driven worker restart therefore cannot accidentally carry a green gate forward. The interactive +runner also completes all independent read-only stages, then returns an aggregate failure and never reaches reaction +approval if any failed. + +If a scenario fails, the runner records those browser signals and the latest RYD API request outcomes before closing +its test tab. It writes a JSON snapshot under `test-results/live-youtube/diagnostics/` and prints its absolute path as `LIVE_FAILURE_SNAPSHOT`. The snapshot includes the URL, runtime markers, Shorts renderer IDs and links, action-bar and synthetic-control ownership, video paused state, and recent API paths/statuses. Anonymous identity and proof-related query values are redacted. Preserve this file when @@ -130,13 +240,21 @@ Use `RYD_LIVE_RUNTIME="extension"` after manually switching the enabled runtime extension. The expected version defaults to the local userscript candidate version or root package version; set `RYD_LIVE_EXPECTED_VERSION` only when deliberately validating another installed build. -## Optional production reaction matrix +## Full production reaction validation -The reaction test is skipped by default. It covers all six Like/Dislike state transitions on `RYD_LIVE_WATCH_B` and the -allowlisted Short, asserts one logical production handshake for every transition, and returns each video to its initial +The full command cannot succeed without this phase. Its first check opens `RYD_LIVE_WATCH_A`, clicks the exact +`RYD_LIVE_WATCH_B` playlist link, proves that YouTube kept the same document, and only then clicks Dislike on B. It +records before navigation and fails if navigation itself emits any `/interact/*` request. The reaction and its cleanup +must each send one logical handshake for B's exact video ID, and the cleanup must restore B's initial YouTube reaction +state. This is the production stale-video-ID gate shared by the userscript and extension. + +The same approved reaction phase then covers all six Like/Dislike state transitions on `RYD_LIVE_WATCH_B` and the +allowlisted `RYD_LIVE_REACTION_SHORT` (defaulting to `RYD_LIVE_SHORT`), asserts one logical production handshake for +every transition, and returns each video to its initial reaction state. A handshake contains one to three matching `/interact/vote` puzzle requests followed by exactly one -matching successful `/interact/confirmVote`. Any fourth vote request, changed identity/video/value, extra confirmation, -or other interaction traffic fails the run. It does not retry cleanup blindly if the state cannot be verified. +matching successful `/interact/confirmVote`. Every request must receive a 2xx response; confirmation must return the +literal JSON value `true`. Any fourth vote request, changed identity/video/value, extra confirmation, or other +interaction traffic fails the run. It does not retry cleanup blindly if the state cannot be verified. If click dispatch, the post-click state wait, or a handshake fails after a vote attempt while the UI already appears to be back at its initial state, cleanup does not trust the UI alone: it confirms an away-and-back reaction round trip with the same anonymous identity. A failed cleanup confirmation or identity mismatch reports the exact video URL for manual @@ -155,14 +273,20 @@ The exact live-build marker proves that the freshly generated test build is pres absent. It cannot detect an additional normal build of the same runtime, so the manual single-runtime preparation above is mandatory. -Only after approving those real YouTube and production RYD writes, create a runtime-and-video-specific token immediately -before the run. It expires after two minutes and is consumed once locally, so the userscript token cannot silently +Only after the full runner prints `READY_FOR_REACTION_APPROVAL`, create a runtime-and-video-specific token in a second +PowerShell window and paste it into the waiting runner. It expires after two minutes and is consumed once locally at the +first post-navigation reaction. That +single approval gates the contiguous post-navigation check and reaction matrix, so the userscript token cannot silently authorize a later extension run. ```powershell -$env:RYD_LIVE_VOTES="$env:RYD_LIVE_RUNTIME`:$env:RYD_LIVE_WATCH_B`:$([DateTimeOffset]::UtcNow.ToUnixTimeSeconds())" -npm run test:live:youtube -Remove-Item Env:RYD_LIVE_VOTES +$runtime="extension" # Or "userscript", matching the waiting run. +$watchB="BBBBBBBBBBB" +"$runtime`:$watchB`:$([DateTimeOffset]::UtcNow.ToUnixTimeSeconds())" | Set-Clipboard ``` -After the live run, turn off **Allow remote debugging for this browser instance** or restart the test Brave profile. +Paste the copied value into the waiting full-run terminal. The command exits successfully only after both +`post-navigation-vote` and `reaction-matrix` complete, then emits `LIVE_VALIDATION_COMPLETE` with +`productionReactionsCompleted: true` and `releaseReady: true`. + +After the live run, turn off **Allow remote debugging for this browser instance** or restart the test browser profile. diff --git a/Extensions/UserScript/e2e/live/live-artifact-readiness.js b/Extensions/UserScript/e2e/live/live-artifact-readiness.js new file mode 100644 index 0000000..ccf20f2 --- /dev/null +++ b/Extensions/UserScript/e2e/live/live-artifact-readiness.js @@ -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, +}; diff --git a/Extensions/UserScript/e2e/live/live-authenticated-context.js b/Extensions/UserScript/e2e/live/live-authenticated-context.js new file mode 100644 index 0000000..864ce80 --- /dev/null +++ b/Extensions/UserScript/e2e/live/live-authenticated-context.js @@ -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, +}; diff --git a/Extensions/UserScript/e2e/live/live-diagnostics.js b/Extensions/UserScript/e2e/live/live-diagnostics.js index f09a19c..78774a1 100644 --- a/Extensions/UserScript/e2e/live/live-diagnostics.js +++ b/Extensions/UserScript/e2e/live/live-diagnostics.js @@ -5,15 +5,168 @@ const API_ORIGIN = "https://returnyoutubedislikeapi.com"; const UNHANDLED_REJECTION_PREFIX = "__RYD_LIVE_UNHANDLED_REJECTION__"; const MAX_BROWSER_SIGNALS = 50; const MAX_API_REQUESTS = 50; +const EXTENSION_ID_PATTERN = /^[a-p]{32}$/; +const EXTERNAL_RESOURCE_HOSTS = Object.freeze({ + doubleclick: "doubleclick.net", + googlevideo: "googlevideo.com", +}); +const EXTERNAL_RESOURCE_FAILURE_PATTERN = + /^Failed to load resource: (?:the server responded with a status of 403(?: \([^)]*\))?|net::ERR_(?:BLOCKED_BY_CLIENT|CONNECTION_CLOSED|CONNECTION_REFUSED|CONNECTION_RESET|FAILED|NAME_NOT_RESOLVED|TIMED_OUT))\.?$/; +const DOUBLECLICK_CORS_PATTERN = + /^Access to (?:fetch|XMLHttpRequest) at ['"]([^'"]+)['"](?: \(redirected from ['"]([^'"]+)['"]\))? from origin ['"](https:\/\/(?:www\.|m\.)?youtube\.com)['"] has been blocked by CORS policy: .+$/s; +const SANDBOXED_ABOUT_BLANK_SCRIPT_MESSAGE = + "Blocked script execution in 'about:blank' because the document's frame is sandboxed and the 'allow-scripts' permission is not set."; +const YOUTUBE_ACCOUNTS_REPORT_ONLY_FRAME_ANCESTORS_MESSAGE = + "Framing 'https://accounts.youtube.com/' violates the following report-only Content Security Policy directive: \"frame-ancestors 'self'\". The violation has been logged, but no further action has been taken.\n"; +const YOUTUBE_ROTATE_COOKIES_RATE_LIMIT_PATTERN = + /^Failed to load resource: the server responded with a status of 429(?: \([^)]*\))?\.?$/; +const FATAL_SIGNAL_TYPES = new Set([ + "console.error", + "pageerror", + "unhandledrejection", + "extension-service-worker.console.error", + "extension-service-worker.instrumentation-error", + "extension-service-worker.unhandledrejection", +]); function serializeError(error) { if (!error) return null; if (typeof error === "string") return { message: error, name: "Error", stack: null }; - return { + const serialized = { message: String(error.message ?? error), name: String(error.name ?? "Error"), stack: typeof error.stack === "string" ? error.stack : null, }; + if (error instanceof AggregateError) serialized.errors = [...error.errors].map(serializeError); + return serialized; +} + +function normalizeIgnoredSignalRules(rules = []) { + if (!Array.isArray(rules)) throw new TypeError("ignoredSignalRules must be an array."); + const ids = new Set(); + return rules.map((rule) => { + if (!rule || typeof rule !== "object" || Array.isArray(rule)) { + throw new TypeError("Each ignored-signal rule must be an object."); + } + const allowedKeys = new Set(["id", "message", "type", "url"]); + const unknownKeys = Object.keys(rule).filter((key) => !allowedKeys.has(key)); + if (unknownKeys.length > 0) { + throw new TypeError(`Ignored-signal rule contains unsupported fields: ${unknownKeys.join(", ")}.`); + } + if (typeof rule.id !== "string" || rule.id.trim() === "") { + throw new TypeError("Each ignored-signal rule requires a non-empty id."); + } + if (ids.has(rule.id)) throw new TypeError(`Ignored-signal rule id ${rule.id} is duplicated.`); + ids.add(rule.id); + if (!FATAL_SIGNAL_TYPES.has(rule.type)) { + throw new TypeError(`Ignored-signal rule ${rule.id} has an unsupported exact type.`); + } + if (typeof rule.message !== "string" || rule.message.length === 0) { + throw new TypeError(`Ignored-signal rule ${rule.id} requires a non-empty exact message.`); + } + if (typeof rule.url !== "string" || rule.url.length === 0) { + throw new TypeError(`Ignored-signal rule ${rule.id} requires a non-empty exact url.`); + } + return Object.freeze({ ...rule }); + }); +} + +function signalMessage(signal) { + return signal.message ?? signal.error?.message ?? "Unknown browser error"; +} + +function signalUrl(signal) { + return signal.workerUrl ?? signal.location?.url ?? null; +} + +function ignoredSignalRuleId(signal, rules) { + const message = signalMessage(signal); + const url = signalUrl(signal); + const rule = rules.find((candidate) => { + if (candidate.type !== signal.type) return false; + return candidate.message === message && candidate.url === url; + }); + return rule?.id ?? null; +} + +function hostnameCategory(value) { + let hostname; + try { + const url = new URL(value); + if (url.protocol !== "https:" || url.port || url.username || url.password) return null; + hostname = url.hostname.toLowerCase(); + } catch { + return null; + } + + for (const [category, domain] of Object.entries(EXTERNAL_RESOURCE_HOSTS)) { + if (hostname === domain || hostname.endsWith(`.${domain}`)) return category; + } + return null; +} + +function environmentalConsoleWarningId(signal) { + if (signal?.type !== "console.error" || signal.source !== "page") return null; + const message = signalMessage(signal); + const locationUrl = signal.location?.url; + const locationCategory = hostnameCategory(locationUrl); + + if (message === SANDBOXED_ABOUT_BLANK_SCRIPT_MESSAGE && locationUrl === "about:blank") { + return "sandboxed-about-blank-script"; + } + + if (message === YOUTUBE_ACCOUNTS_REPORT_ONLY_FRAME_ANCESTORS_MESSAGE && locationUrl === "") { + return "youtube-accounts-report-only-frame-ancestors"; + } + + if (EXTERNAL_RESOURCE_FAILURE_PATTERN.test(message)) { + if (locationCategory === "googlevideo") return "googlevideo-resource-failure"; + if (locationCategory === "doubleclick") return "doubleclick-resource-failure"; + return null; + } + + if (YOUTUBE_ROTATE_COOKIES_RATE_LIMIT_PATTERN.test(message)) { + try { + const url = new URL(locationUrl); + if ( + url.protocol === "https:" && + url.hostname.toLowerCase() === "accounts.youtube.com" && + url.pathname === "/RotateCookies" && + !url.port && + !url.username && + !url.password + ) { + return "youtube-rotate-cookies-rate-limit"; + } + } catch {} + } + + const corsMatch = message.match(DOUBLECLICK_CORS_PATTERN); + if (!corsMatch || hostnameCategory(corsMatch[1]) !== "doubleclick") return null; + const [, , redirectedFrom, origin] = corsMatch; + if (redirectedFrom) { + try { + const redirectUrl = new URL(redirectedFrom); + if ( + redirectUrl.origin !== origin || + !redirectUrl.pathname.startsWith("/pagead/viewthroughconversion/") || + redirectUrl.username || + redirectUrl.password + ) { + return null; + } + } catch { + return null; + } + } + return "doubleclick-cors"; +} + +function extensionServiceWorkerUrl(extensionId) { + if (!EXTENSION_ID_PATTERN.test(extensionId ?? "")) { + throw new TypeError("selectedExtensionId must be a valid Chrome extension ID."); + } + return `chrome-extension://${extensionId}/ryd.background.js`; } function installUnhandledRejectionListener(prefix) { @@ -34,6 +187,211 @@ function installUnhandledRejectionListener(prefix) { }); } +class LiveFatalSignalGuard { + constructor( + page, + context, + { + clock = () => new Date(), + ignoredSignalRules = [], + log = console.log, + onSignal = null, + selectedExtensionId = null, + } = {}, + ) { + this.clock = clock; + this.context = context; + this.ignoredSignalRules = normalizeIgnoredSignalRules(ignoredSignalRules); + this.log = log; + this.onSignal = onSignal; + this.page = page; + this.selectedExtensionWorkerUrl = selectedExtensionId ? extensionServiceWorkerUrl(selectedExtensionId) : null; + this.serviceWorkerListeners = new Map(); + this.signals = []; + this.started = false; + + this.onPageConsole = this.onPageConsole.bind(this); + this.onPageError = this.onPageError.bind(this); + this.onServiceWorker = this.onServiceWorker.bind(this); + } + + now() { + return this.clock().toISOString(); + } + + recordSignal(type, details) { + const baseSignal = { at: this.now(), type, ...details }; + const ignoredBy = ignoredSignalRuleId(baseSignal, this.ignoredSignalRules); + const environmentalWarning = environmentalConsoleWarningId(baseSignal); + const severity = ignoredBy || environmentalWarning ? "warning" : "fatal"; + const signal = { ...baseSignal, environmentalWarning, ignoredBy, severity }; + this.signals.push(signal); + this.log( + `LIVE_BROWSER_SIGNAL ${type} ${JSON.stringify({ ...details, environmentalWarning, ignoredBy, severity })}`, + ); + this.onSignal?.(signal); + return signal; + } + + onPageError(error) { + this.recordSignal("pageerror", { error: serializeError(error), source: "page" }); + } + + readConsoleMessage(message) { + let location = {}; + try { + location = message.location?.() ?? {}; + } catch {} + return { location, message: message.text() }; + } + + onPageConsole(consoleMessage) { + if (consoleMessage.type() !== "error") return; + const { location, message } = this.readConsoleMessage(consoleMessage); + if (message.startsWith(UNHANDLED_REJECTION_PREFIX)) { + this.recordSignal("unhandledrejection", { + location, + message: message.slice(UNHANDLED_REJECTION_PREFIX.length), + source: "page", + }); + return; + } + this.recordSignal("console.error", { location, message, source: "page" }); + } + + async attachServiceWorker(worker) { + if (!this.selectedExtensionWorkerUrl || worker.url() !== this.selectedExtensionWorkerUrl) return; + if (this.serviceWorkerListeners.has(worker)) return; + + const listener = (consoleMessage) => { + if (consoleMessage.type() !== "error") return; + const { location, message } = this.readConsoleMessage(consoleMessage); + if (message.startsWith(UNHANDLED_REJECTION_PREFIX)) { + this.recordSignal("extension-service-worker.unhandledrejection", { + location, + message: message.slice(UNHANDLED_REJECTION_PREFIX.length), + source: "extension-service-worker", + workerUrl: this.selectedExtensionWorkerUrl, + }); + return; + } + this.recordSignal("extension-service-worker.console.error", { + location, + message, + source: "extension-service-worker", + workerUrl: this.selectedExtensionWorkerUrl, + }); + }; + this.serviceWorkerListeners.set(worker, listener); + worker.on("console", listener); + try { + await worker.evaluate(installUnhandledRejectionListener, UNHANDLED_REJECTION_PREFIX); + } catch (error) { + this.recordSignal("extension-service-worker.instrumentation-error", { + error: serializeError(error), + source: "extension-service-worker", + workerUrl: this.selectedExtensionWorkerUrl, + }); + } + } + + onServiceWorker(worker) { + void this.attachServiceWorker(worker); + } + + async start() { + if (this.started) return; + this.started = true; + this.page.on("console", this.onPageConsole); + this.page.on("pageerror", this.onPageError); + await this.page.addInitScript(installUnhandledRejectionListener, UNHANDLED_REJECTION_PREFIX); + if (!this.page.isClosed()) await this.page.evaluate(installUnhandledRejectionListener, UNHANDLED_REJECTION_PREFIX); + + if (this.selectedExtensionWorkerUrl) { + this.context.on("serviceworker", this.onServiceWorker); + await Promise.all(this.context.serviceWorkers().map((worker) => this.attachServiceWorker(worker))); + } + } + + stop() { + if (!this.started) return; + this.started = false; + this.page.off("console", this.onPageConsole); + this.page.off("pageerror", this.onPageError); + if (this.selectedExtensionWorkerUrl) this.context.off("serviceworker", this.onServiceWorker); + for (const [worker, listener] of this.serviceWorkerListeners) worker.off("console", listener); + this.serviceWorkerListeners.clear(); + } + + mark() { + return this.signals.length; + } + + fatalSignalsBetween(startIndex = 0, endIndex = this.signals.length) { + return this.signals.slice(startIndex, endIndex).filter((signal) => signal.severity !== "warning"); + } + + assertNoFatalSignalsBetween(startIndex = 0, endIndex = this.signals.length, label = "live scenario") { + const signals = this.fatalSignalsBetween(startIndex, endIndex); + if (signals.length === 0) return; + const summary = signals.map((signal) => `${signal.type}: ${signalMessage(signal)}`).join(" | "); + const error = new Error(`${label} emitted ${signals.length} fatal browser signal(s): ${summary}`); + error.name = "LiveFatalSignalError"; + error.signals = signals; + throw error; + } +} + +class LiveReadOnlyGate { + constructor(requiredScenarioIds, { allowedSkippedScenarioIds = [] } = {}) { + if (!Array.isArray(requiredScenarioIds) || requiredScenarioIds.length === 0) { + throw new TypeError("The live read-only gate requires at least one scenario ID."); + } + if (requiredScenarioIds.some((scenarioId) => typeof scenarioId !== "string" || scenarioId.trim() === "")) { + throw new TypeError("Every live read-only gate scenario ID must be a non-empty string."); + } + if (new Set(requiredScenarioIds).size !== requiredScenarioIds.length) { + throw new TypeError("Live read-only gate scenario IDs must be unique."); + } + if ( + !Array.isArray(allowedSkippedScenarioIds) || + allowedSkippedScenarioIds.some((scenarioId) => !requiredScenarioIds.includes(scenarioId)) + ) { + throw new TypeError("Allowed skipped scenarios must be a subset of the required live read-only scenarios."); + } + this.allowedSkipped = new Set(allowedSkippedScenarioIds); + this.completed = new Set(); + this.failed = new Set(); + this.requiredScenarioIds = Object.freeze([...requiredScenarioIds]); + } + + record(scenarioId, outcome) { + if (!this.requiredScenarioIds.includes(scenarioId)) { + throw new TypeError(`Unknown live read-only scenario: ${scenarioId}`); + } + if (!["failed", "passed", "skipped"].includes(outcome)) { + throw new TypeError(`Unsupported live read-only outcome for ${scenarioId}: ${outcome}`); + } + if (outcome === "failed" || (outcome === "skipped" && !this.allowedSkipped.has(scenarioId))) { + this.completed.delete(scenarioId); + this.failed.add(scenarioId); + return; + } + if (this.failed.has(scenarioId)) return; + this.completed.add(scenarioId); + } + + assertPassed() { + const missing = this.requiredScenarioIds.filter((scenarioId) => !this.completed.has(scenarioId)); + if (this.failed.size === 0 && missing.length === 0) return; + throw new Error( + `Production reactions are blocked because this worker did not pass every read-only scenario. Missing: ${ + missing.join(", ") || "none" + }; failed: ${[...this.failed].join(", ") || "none"}.`, + ); + } +} + function readLivePageState() { const readRect = (element) => { const rect = element.getBoundingClientRect(); @@ -149,9 +507,11 @@ class LiveRunDiagnostics { { clock = () => new Date(), fileSystem = fs, + ignoredSignalRules = [], log = console.log, outputDirectory = path.resolve(__dirname, "../../../../test-results/live-youtube/diagnostics"), runtime = null, + selectedExtensionId = null, } = {}, ) { this.browserSignals = []; @@ -166,13 +526,19 @@ class LiveRunDiagnostics { this.recentApiRequests = []; this.requestRecords = new WeakMap(); this.runtime = runtime; + this.fatalSignalCursor = 0; this.started = false; - this.onConsole = this.onConsole.bind(this); - this.onPageError = this.onPageError.bind(this); this.onRequest = this.onRequest.bind(this); this.onRequestFailed = this.onRequestFailed.bind(this); this.onResponse = this.onResponse.bind(this); + this.fatalSignalGuard = new LiveFatalSignalGuard(page, context, { + clock, + ignoredSignalRules, + log: () => {}, + onSignal: (signal) => this.recordBrowserSignal(signal), + selectedExtensionId, + }); } now() { @@ -184,34 +550,16 @@ class LiveRunDiagnostics { if (collection.length > maximum) collection.splice(0, collection.length - maximum); } - recordBrowserSignal(type, details) { - const signal = { - at: this.now(), + recordBrowserSignal(signal) { + const { at, ...details } = signal; + const enrichedSignal = { + at, checkpoint: this.currentCheckpoint, stage: this.currentStage, - type, ...details, }; - this.appendCapped(this.browserSignals, signal, MAX_BROWSER_SIGNALS); - this.log(`LIVE_BROWSER_SIGNAL ${type} ${JSON.stringify(details)}`); - } - - onPageError(error) { - this.recordBrowserSignal("pageerror", { error: serializeError(error) }); - } - - onConsole(message) { - if (message.type() !== "error") return; - const text = message.text(); - const location = message.location?.() ?? {}; - if (text.startsWith(UNHANDLED_REJECTION_PREFIX)) { - this.recordBrowserSignal("unhandledrejection", { - location, - message: text.slice(UNHANDLED_REJECTION_PREFIX.length), - }); - return; - } - this.recordBrowserSignal("console.error", { location, message: text }); + this.appendCapped(this.browserSignals, enrichedSignal, MAX_BROWSER_SIGNALS); + this.log(`LIVE_BROWSER_SIGNAL ${enrichedSignal.type} ${JSON.stringify(details)}`); } onRequest(request) { @@ -246,22 +594,16 @@ class LiveRunDiagnostics { async start() { if (this.started) return; this.started = true; - this.page.on("console", this.onConsole); - this.page.on("pageerror", this.onPageError); + await this.fatalSignalGuard.start(); this.context.on("request", this.onRequest); this.context.on("requestfailed", this.onRequestFailed); this.context.on("response", this.onResponse); - await this.page.addInitScript(installUnhandledRejectionListener, UNHANDLED_REJECTION_PREFIX); - if (!this.page.isClosed()) { - await this.page.evaluate(installUnhandledRejectionListener, UNHANDLED_REJECTION_PREFIX); - } } stop() { if (!this.started) return; this.started = false; - this.page.off("console", this.onConsole); - this.page.off("pageerror", this.onPageError); + this.fatalSignalGuard.stop(); this.context.off("request", this.onRequest); this.context.off("requestfailed", this.onRequestFailed); this.context.off("response", this.onResponse); @@ -291,6 +633,13 @@ class LiveRunDiagnostics { ); } + consumeFatalSignals(label) { + const startIndex = this.fatalSignalCursor; + const endIndex = this.fatalSignalGuard.mark(); + this.fatalSignalCursor = endIndex; + this.fatalSignalGuard.assertNoFatalSignalsBetween(startIndex, endIndex, label); + } + async snapshot(error) { let pageState = null; let pageStateError = null; @@ -336,23 +685,71 @@ class LiveRunDiagnostics { async function runLoggedStage(diagnostics, name, action) { const startedAt = Date.now(); diagnostics.stageStarted(name); + let result; + let failure = null; try { - const result = await action(); - diagnostics.stageCompleted(name, startedAt); - return result; + result = await action(); } catch (error) { - diagnostics.stageFailed(name, startedAt, error); + failure = error; + } + + await Promise.resolve(); + try { + diagnostics.consumeFatalSignals?.(name); + } catch (signalError) { + failure = failure + ? new AggregateError([failure, signalError], `${name} failed and emitted fatal browser signals.`) + : signalError; + } + + if (failure) { + diagnostics.stageFailed(name, startedAt, failure); + throw failure; + } + diagnostics.stageCompleted(name, startedAt); + return result; +} + +async function runIndependentLoggedStages(diagnostics, stages) { + if (!Array.isArray(stages)) throw new TypeError("Independent live stages must be an array."); + const results = []; + const failures = []; + for (const stage of stages) { + if (!stage || typeof stage.name !== "string" || typeof stage.action !== "function") { + throw new TypeError("Each independent live stage requires a name and action function."); + } + try { + results.push({ name: stage.name, result: await runLoggedStage(diagnostics, stage.name, stage.action) }); + } catch (error) { + failures.push(error); + results.push({ error: serializeError(error), name: stage.name }); + } + } + if (failures.length > 0) { + const error = new AggregateError( + failures, + `${failures.length} of ${stages.length} independent live read-only stages failed.`, + ); + error.stageResults = results; throw error; } + return results; } module.exports = { API_ORIGIN, + LiveFatalSignalGuard, + LiveReadOnlyGate, LiveRunDiagnostics, UNHANDLED_REJECTION_PREFIX, diagnosticApiUrl, + environmentalConsoleWarningId, + extensionServiceWorkerUrl, + ignoredSignalRuleId, installUnhandledRejectionListener, + normalizeIgnoredSignalRules, readLivePageState, + runIndependentLoggedStages, runLoggedStage, serializeError, }; diff --git a/Extensions/UserScript/e2e/live/live-extension-reloader.js b/Extensions/UserScript/e2e/live/live-extension-reloader.js new file mode 100644 index 0000000..194e0c5 --- /dev/null +++ b/Extensions/UserScript/e2e/live/live-extension-reloader.js @@ -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 ?? ""}, 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 ?? ""}, expected ${expectedBuildId}.`, + ); + } + if (identity.resourceBuildId !== expectedBuildId) { + throw new Error( + `Extension ${extensionId} exposes resource build ${identity.resourceBuildId ?? ""}, 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, +}; diff --git a/Extensions/UserScript/e2e/live/live-full-run-contract.js b/Extensions/UserScript/e2e/live/live-full-run-contract.js new file mode 100644 index 0000000..418a774 --- /dev/null +++ b/Extensions/UserScript/e2e/live/live-full-run-contract.js @@ -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, +}; diff --git a/Extensions/UserScript/e2e/live/live-interactive-readonly.js b/Extensions/UserScript/e2e/live/live-interactive-readonly.js new file mode 100644 index 0000000..27a3ee6 --- /dev/null +++ b/Extensions/UserScript/e2e/live/live-interactive-readonly.js @@ -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, +}; diff --git a/Extensions/UserScript/e2e/live/live-interactive-runner.js b/Extensions/UserScript/e2e/live/live-interactive-runner.js index fea5fe0..fbea0c4 100644 --- a/Extensions/UserScript/e2e/live/live-interactive-runner.js +++ b/Extensions/UserScript/e2e/live/live-interactive-runner.js @@ -1,3 +1,5 @@ +const fs = require("node:fs"); +const path = require("node:path"); const readline = require("node:readline"); const { chromium } = require("@playwright/test"); const { @@ -6,133 +8,540 @@ const { } = require("../../../e2e/live-runtime-adapter"); const { createSharedLiveScenarioRunner } = require("../../../e2e/shared-live-scenarios"); const { consumeLiveVoteApproval, hasFreshVoteApproval, readLiveOptions } = require("../../live/live-options"); +const { selectAuthenticatedYoutubeContext } = require("./live-authenticated-context"); const { LiveRunDiagnostics, runLoggedStage } = require("./live-diagnostics"); -const { LiveYoutubeDriver, VoteTrafficRecorder } = require("./live-youtube-driver"); +const { reloadLiveExtensionInBrowser } = require("./live-extension-reloader"); +const { + assertFullLiveReactionCompletion, + buildLiveRuntimeArtifact, + readFullLiveRuntime, + requireProductionReactionApproval, + requireUserscriptInstallAcknowledgement, +} = require("./live-full-run-contract"); +const { runInteractiveReadOnlyAfterRuntimeReload } = require("./live-interactive-readonly"); +const { LiveYoutubeDriver, VoteTrafficRecorder, withOperationTimeout } = require("./live-youtube-driver"); +const REPOSITORY_DIRECTORY = path.resolve(__dirname, "../../../.."); +const LIVE_EVIDENCE_DIRECTORY = path.resolve(__dirname, "../../../../test-results/live-youtube"); +const LIVE_EVIDENCE_PARENT_DIRECTORY = path.dirname(LIVE_EVIDENCE_DIRECTORY); +const LIVE_ATTEMPT_PAGE_CLOSE_TIMEOUT_MS = 10_000; +const LIVE_SESSION_COMMANDS = new Set(["EXIT", "RERUN"]); const scenarioRunner = createSharedLiveScenarioRunner(); -function readApprovalLine() { - const input = readline.createInterface({ input: process.stdin, terminal: false }); - return new Promise((resolve) => { - input.once("line", (line) => { - input.close(); - resolve(line.trim()); - }); +function createInputLineReader(input = process.stdin) { + const lineInterface = readline.createInterface({ input, terminal: false }); + const bufferedLines = []; + const waiters = []; + let closed = false; + + lineInterface.on("line", (line) => { + const value = line.trim(); + const waiter = waiters.shift(); + if (waiter) waiter(value); + else bufferedLines.push(value); + }); + lineInterface.once("close", () => { + closed = true; + for (const waiter of waiters.splice(0)) waiter(null); + }); + + return { + close: () => lineInterface.close(), + readLine: () => { + if (bufferedLines.length > 0) return Promise.resolve(bufferedLines.shift()); + if (closed) return Promise.resolve(null); + return new Promise((resolve) => waiters.push(resolve)); + }, + }; +} + +function readPersistentSessionMode(environment = process.env) { + const configured = environment.RYD_LIVE_KEEP_CDP_SESSION?.trim(); + if (!configured) return false; + if (configured !== "1") { + throw new Error("RYD_LIVE_KEEP_CDP_SESSION must be 1 or unset."); + } + return true; +} + +function normalizeLiveSessionCommand(value) { + if (value === null) return "EXIT"; + const command = value?.trim().toUpperCase(); + return LIVE_SESSION_COMMANDS.has(command) ? command : null; +} + +function assertReusableConnectionOptions(initialOptions, nextOptions) { + const immutableFields = ["cdpEndpoint", "expectedChannel", "runtime"]; + for (const field of immutableFields) { + if (nextOptions?.[field] !== initialOptions?.[field]) { + throw new Error( + `Cannot reuse the live browser connection after ${field} changed from ${initialOptions?.[field] ?? ""} to ${nextOptions?.[field] ?? ""}. Exit and start a new live runner instead.`, + ); + } + } + return nextOptions; +} + +function copyDirectoryEntry(source, destination, fsImpl = fs) { + const stats = fsImpl.statSync(source); + if (stats.isDirectory()) { + fsImpl.mkdirSync(destination, { recursive: true }); + for (const entry of fsImpl.readdirSync(source)) { + copyDirectoryEntry(path.join(source, entry), path.join(destination, entry), fsImpl); + } + return; + } + fsImpl.mkdirSync(path.dirname(destination), { recursive: true }); + fsImpl.copyFileSync(source, destination); +} + +function canonicalizeExistingPath(targetPath, fsImpl = fs) { + const resolvedPath = path.resolve(targetPath); + if (!fsImpl.existsSync(resolvedPath)) return resolvedPath; + const realpath = fsImpl.realpathSync?.native ?? fsImpl.realpathSync; + return realpath ? realpath(resolvedPath) : resolvedPath; +} + +function pathsAreEqual(left, right) { + const normalize = (value) => { + const normalized = path.normalize(value); + return process.platform === "win32" ? normalized.toLowerCase() : normalized; + }; + return normalize(left) === normalize(right); +} + +function isAttemptsEvidenceEntry(entry) { + return entry.toLowerCase() === "attempts"; +} + +function resolveSafeLiveEvidenceDirectory( + evidenceDirectory = LIVE_EVIDENCE_DIRECTORY, + fsImpl = fs, + allowedParentDirectory = LIVE_EVIDENCE_PARENT_DIRECTORY, +) { + const resolvedEvidenceDirectory = canonicalizeExistingPath(evidenceDirectory, fsImpl); + const resolvedRepositoryDirectory = canonicalizeExistingPath(REPOSITORY_DIRECTORY, fsImpl); + const resolvedAllowedParentDirectory = canonicalizeExistingPath(allowedParentDirectory, fsImpl); + const filesystemRoot = path.parse(resolvedEvidenceDirectory).root; + if ( + pathsAreEqual(resolvedEvidenceDirectory, filesystemRoot) || + pathsAreEqual(resolvedEvidenceDirectory, resolvedRepositoryDirectory) || + !pathsAreEqual(path.dirname(resolvedEvidenceDirectory), resolvedAllowedParentDirectory) || + !pathsAreEqual(path.basename(resolvedEvidenceDirectory), "live-youtube") + ) { + throw new Error( + `Refusing to reset unsafe live evidence directory ${resolvedEvidenceDirectory}. The target must be the dedicated live-youtube directory directly under ${resolvedAllowedParentDirectory}, never a filesystem or repository root.`, + ); + } + if (fsImpl.existsSync(resolvedEvidenceDirectory) && !fsImpl.statSync(resolvedEvidenceDirectory).isDirectory()) { + throw new Error(`Refusing to reset live evidence path because it is not a directory: ${resolvedEvidenceDirectory}`); + } + return resolvedEvidenceDirectory; +} + +function resetLiveAttemptEvidenceWorkspace( + { attemptNumber } = {}, + { + allowedParentDirectory = LIVE_EVIDENCE_PARENT_DIRECTORY, + evidenceDirectory = LIVE_EVIDENCE_DIRECTORY, + fsImpl = fs, + } = {}, +) { + const resolvedEvidenceDirectory = resolveSafeLiveEvidenceDirectory(evidenceDirectory, fsImpl, allowedParentDirectory); + if (!fsImpl.existsSync(resolvedEvidenceDirectory)) { + return { attemptNumber, evidenceDirectory: resolvedEvidenceDirectory, removedEntries: [] }; + } + + const removedEntries = []; + for (const entry of fsImpl.readdirSync(resolvedEvidenceDirectory).sort()) { + if (isAttemptsEvidenceEntry(entry)) continue; + const entryPath = path.resolve(resolvedEvidenceDirectory, entry); + const relativeEntryPath = path.relative(resolvedEvidenceDirectory, entryPath); + if ( + !relativeEntryPath || + relativeEntryPath === ".." || + relativeEntryPath.startsWith(`..${path.sep}`) || + path.isAbsolute(relativeEntryPath) + ) { + throw new Error(`Refusing to remove live evidence entry outside the dedicated directory: ${entryPath}`); + } + fsImpl.rmSync(entryPath, { force: true, recursive: true }); + removedEntries.push(entry); + } + return { attemptNumber, evidenceDirectory: resolvedEvidenceDirectory, removedEntries }; +} + +function preserveLiveAttemptEvidence( + { attemptNumber, outcome }, + { evidenceDirectory = LIVE_EVIDENCE_DIRECTORY, fsImpl = fs, now = () => new Date() } = {}, +) { + const timestamp = now().toISOString().replace(/[:.]/gu, "-"); + const attemptDirectory = path.join( + evidenceDirectory, + "attempts", + `${String(attemptNumber).padStart(2, "0")}-${timestamp}-${outcome}`, + ); + fsImpl.mkdirSync(attemptDirectory, { recursive: true }); + + if (fsImpl.existsSync(evidenceDirectory)) { + for (const entry of fsImpl.readdirSync(evidenceDirectory)) { + if (isAttemptsEvidenceEntry(entry)) continue; + copyDirectoryEntry(path.join(evidenceDirectory, entry), path.join(attemptDirectory, entry), fsImpl); + } + } + fsImpl.writeFileSync( + path.join(attemptDirectory, "attempt.json"), + `${JSON.stringify({ attemptNumber, capturedAt: now().toISOString(), outcome }, null, 2)}\n`, + ); + return attemptDirectory; +} + +function createInteractiveLiveDriver({ context, diagnostics, options, page, selectedExtensionId }) { + return new LiveYoutubeDriver(page, context, { + authenticatedHandle: options.expectedChannel, + expectedBuildId: options.expectedBuildId, + reportProgress: (name, details) => diagnostics.checkpoint(name, details), + selectedExtensionId, }); } -async function main() { - const options = readLiveOptions(); +function createInteractiveVoteRecorderFactory({ context, driver, options, page, selectedExtensionId }) { + return (videoId) => + new VoteTrafficRecorder(context, videoId, { + page, + runtime: options.runtime, + selectedExtensionId, + trafficLedger: driver.trafficLedger, + }); +} + +function createReactionTargetDetails(options) { + return { + reactionShort: options.reactionShort, + reactionWatch: options.watchB, + }; +} + +function formatReactionApprovalReadiness(options) { + return `READY_FOR_REACTION_APPROVAL runtime=${options.runtime} watch=${options.watchB} reactionShort=${options.reactionShort} readOnlyShort=${options.short}`; +} + +async function closeLiveAttemptPage(page, { timeoutMs = LIVE_ATTEMPT_PAGE_CLOSE_TIMEOUT_MS } = {}) { + if (!page || page.isClosed()) return false; + if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) { + throw new TypeError("The live-attempt page-close timeout must be positive."); + } + await withOperationTimeout( + () => page.close(), + timeoutMs, + `Timed out after ${timeoutMs}ms while closing the live-attempt test page.`, + ); + return true; +} + +async function prepareLiveAttempt({ buildArtifact, environment, output, readLine, readOptions, requestedRuntime }) { + const build = buildArtifact(requestedRuntime); + output.log(`LIVE_ARTIFACT_BUILT ${JSON.stringify(build)}`); + if (requestedRuntime === "userscript") { + output.log( + "LIVE_USERSCRIPT_INSTALL_REQUIRED Reinstall test-results/live-build/userscript/Return Youtube Dislike.user.js, then enter INSTALLED.", + ); + requireUserscriptInstallAcknowledgement(await readLine()); + } + const options = readOptions(environment); if (!options) throw new Error("Set RYD_LIVE_YOUTUBE=1 and the documented allowlist variables to opt in."); + if (options.runtime !== requestedRuntime) { + throw new Error(`The built live runtime ${requestedRuntime} does not match configured runtime ${options.runtime}.`); + } + return options; +} - let browser; +async function runConnectedLiveAttempt({ + browser, + options, + output, + pageCloseTimeoutMs = LIVE_ATTEMPT_PAGE_CLOSE_TIMEOUT_MS, + readLine, + selectedSession, +}) { + let attemptError; let diagnostics; + let driver; let page; + let selectedExtensionId = null; try { - console.log("WAITING_FOR_BRAVE_DEBUG_APPROVAL"); - browser = await chromium.connectOverCDP(options.cdpEndpoint, { - isLocal: true, - noDefaults: true, - timeout: 120_000, + const { context } = selectedSession; + let runtimeAdapter; + const { readOnly } = await runInteractiveReadOnlyAfterRuntimeReload({ + initializeRuntime: async (reloaded) => { + selectedExtensionId = reloaded?.extensionId ?? null; + page = await context.newPage(); + diagnostics = new LiveRunDiagnostics(page, context, { runtime: options.runtime, selectedExtensionId }); + await diagnostics.start(); + driver = createInteractiveLiveDriver({ + context, + diagnostics, + options, + page, + selectedExtensionId, + }); + const createAdapter = + options.runtime === "extension" ? createExtensionLiveRuntimeAdapter : createUserscriptLiveRuntimeAdapter; + runtimeAdapter = createAdapter({ + driver, + expectedBuildId: options.expectedBuildId, + expectedVersion: options.expectedVersion, + }); + output.log("CHROMIUM_CONNECTED"); + return { diagnostics, runtimeAdapter }; + }, + options, + reloadExtension: async () => { + const reloaded = await reloadLiveExtensionInBrowser({ + browser, + context, + expectedBuildId: options.expectedBuildId, + expectedVersion: options.expectedVersion, + sessionPage: selectedSession.sessionPage, + }); + output.log(`LIVE_EXTENSION_RELOADED ${JSON.stringify(reloaded)}`); + return reloaded; + }, + scenarioRunner, }); - const [context] = browser.contexts(); - if (!context) throw new Error("The attached Chromium browser has no default context."); - - page = await context.newPage(); - diagnostics = new LiveRunDiagnostics(page, context, { runtime: options.runtime }); - await diagnostics.start(); - const driver = new LiveYoutubeDriver(page, context, { - reportProgress: (name, details) => diagnostics.checkpoint(name, details), - }); - const createAdapter = - options.runtime === "extension" ? createExtensionLiveRuntimeAdapter : createUserscriptLiveRuntimeAdapter; - const runtimeAdapter = createAdapter({ - driver, - expectedBuildId: options.expectedBuildId, - expectedVersion: options.expectedVersion, - }); - console.log("BRAVE_CONNECTED"); - - const readOnly = {}; - readOnly.channelShorts = await runLoggedStage(diagnostics, "read-only.channel-to-shorts-and-next", () => - scenarioRunner.run(runtimeAdapter, "channel-shorts-navigation", options), - ); - if (options.navigation.watch) { - readOnly.channelWatch = await runLoggedStage(diagnostics, "read-only.channel-to-watch", () => - scenarioRunner.run(runtimeAdapter, "channel-watch-navigation", options), - ); - } else { - readOnly.channelWatch = null; - diagnostics.checkpoint("read-only.channel-to-watch.skipped", { - reason: "RYD_LIVE_NAV_WATCH is not configured", - }); - } - readOnly.reload = await runLoggedStage(diagnostics, "read-only.reload", () => - scenarioRunner.run(runtimeAdapter, "reload", options), - ); - readOnly.responsive = await runLoggedStage(diagnostics, "read-only.responsive-visual", () => - scenarioRunner.run(runtimeAdapter, "responsive-visual", options), - ); - readOnly.short = await runLoggedStage(diagnostics, "read-only.short-render", () => - scenarioRunner.run(runtimeAdapter, "shorts-render", options), - ); - readOnly.spa = await runLoggedStage(diagnostics, "read-only.playlist-spa", () => - scenarioRunner.run(runtimeAdapter, "spa-navigation", options), - ); - readOnly.sidebarStress = await runLoggedStage(diagnostics, "read-only.sidebar-stress", () => - scenarioRunner.run(runtimeAdapter, "sidebar-navigation-stress", options), - ); - readOnly.watch = await runLoggedStage(diagnostics, "read-only.watch-render", () => - scenarioRunner.run(runtimeAdapter, "watch-render", options), - ); - console.log(`READ_ONLY_COMPLETE ${JSON.stringify(readOnly)}`); - console.log( - `READY_FOR_REACTION_APPROVAL runtime=${options.runtime} watch=${options.watchB} short=${options.short}`, - ); + await driver.assertNoUnclaimedAttributedTraffic("interactive read-only run"); + output.log(`READ_ONLY_COMPLETE ${JSON.stringify(readOnly)}`); + output.log(formatReactionApprovalReadiness(options)); diagnostics.checkpoint("reaction-approval.waiting", { - instruction: "Enter SKIP to close the live-test tab without production reactions", + instruction: "Enter the fresh production-reaction approval token", }); - const approval = await readApprovalLine(); - if (approval === "SKIP") { - console.log("REACTION_MATRIX_SKIPPED"); - return; - } + const approval = requireProductionReactionApproval(await readLine()); + diagnostics.consumeFatalSignals("reaction approval wait"); + await driver.assertNoUnclaimedAttributedTraffic("reaction approval wait"); if (!hasFreshVoteApproval(approval, options.runtime, options.watchB, Date.now())) { throw new Error("The supplied live reaction approval is missing, expired, or does not match this run."); } - const reaction = await runLoggedStage(diagnostics, "reaction-matrix", () => - scenarioRunner.run(runtimeAdapter, "reaction-matrix", options, { - createRecorder: (videoId) => new VoteTrafficRecorder(context, videoId), - consumeVoteApproval: async () => { - driver.assertCurrentVideo(options.watchB); - await driver.assertRuntime(options.runtime, options.expectedVersion, options.expectedBuildId); - if (!consumeLiveVoteApproval(approval, options.runtime, options.watchB)) { - throw new Error("The live reaction approval expired or was already used. No reaction was clicked."); - } - }, - }), + let approvalConsumed = false; + const reactionServices = { + createRecorder: createInteractiveVoteRecorderFactory({ context, driver, options, page, selectedExtensionId }), + consumeVoteApproval: async () => { + driver.assertCurrentVideo(options.watchB); + await driver.assertRuntime(options.runtime, options.expectedVersion, options.expectedBuildId); + if (approvalConsumed) return; + if (!consumeLiveVoteApproval(approval, options.runtime, options.watchB)) { + throw new Error("The live reaction approval expired or was already used. No reaction was clicked."); + } + approvalConsumed = true; + }, + }; + const reaction = await runLoggedStage(diagnostics, "production-reactions", async () => ({ + "post-navigation-vote": await scenarioRunner.run( + runtimeAdapter, + "post-navigation-vote", + options, + reactionServices, + ), + "reaction-matrix": await scenarioRunner.run(runtimeAdapter, "reaction-matrix", options, reactionServices), + })); + await driver.assertNoUnclaimedAttributedTraffic("interactive production reactions"); + const completion = assertFullLiveReactionCompletion(reaction); + output.log(`PRODUCTION_REACTIONS_COMPLETE ${JSON.stringify(reaction)}`); + output.log( + `LIVE_VALIDATION_COMPLETE ${JSON.stringify({ + ...completion, + ...createReactionTargetDetails(options), + })}`, ); - console.log(`REACTION_MATRIX_COMPLETE ${JSON.stringify(reaction)}`); + return { classification: completion.classification, reaction, readOnly }; } catch (error) { + attemptError = error; if (diagnostics) { try { const snapshotPath = await diagnostics.persistFailureSnapshot(error); - console.error(`LIVE_FAILURE_SNAPSHOT ${snapshotPath}`); + output.error(`LIVE_FAILURE_SNAPSHOT ${snapshotPath}`); } catch (snapshotError) { - console.error(`LIVE_FAILURE_SNAPSHOT_FAILED ${snapshotError.message}`); + output.error(`LIVE_FAILURE_SNAPSHOT_FAILED ${snapshotError.message}`); } } - throw error; } finally { + driver?.stop(); diagnostics?.stop(); - if (page && !page.isClosed()) await page.close(); - if (browser) await browser.close(); + let pageCloseError; + try { + if (page && !page.isClosed()) { + output.log(`LIVE_ATTEMPT_PAGE_CLOSE_START ${JSON.stringify({ timeoutMs: pageCloseTimeoutMs })}`); + await closeLiveAttemptPage(page, { timeoutMs: pageCloseTimeoutMs }); + output.log("LIVE_ATTEMPT_PAGE_CLOSE_COMPLETE"); + } + } catch (error) { + pageCloseError = error; + output.error(`LIVE_ATTEMPT_PAGE_CLOSE_FAILED ${JSON.stringify({ message: error.message })}`); + } + if (attemptError && pageCloseError) { + throw new AggregateError( + [attemptError, pageCloseError], + "The live attempt and its test-page cleanup both failed.", + ); + } + if (attemptError) throw attemptError; + if (pageCloseError) throw pageCloseError; } } -main().catch((error) => { - console.error(error); - process.exitCode = 1; -}); +async function runLiveAttemptLoop({ + initialOptions, + output, + persistentSession, + prepareNextAttempt, + preserveEvidence, + readLine, + resetEvidenceWorkspace, + runAttempt, +}) { + let attemptNumber = 1; + let options = initialOptions; + + while (true) { + const reset = await resetEvidenceWorkspace({ attemptNumber }); + output.log( + `LIVE_ATTEMPT_EVIDENCE_RESET ${JSON.stringify({ + attemptNumber, + removedEntries: reset?.removedEntries ?? [], + })}`, + ); + output.log( + `LIVE_ATTEMPT_START ${JSON.stringify({ attemptNumber, persistentSession, runtime: options?.runtime ?? null })}`, + ); + let attemptError = null; + let attemptResult = null; + try { + if (attemptNumber > 1) { + const nextOptions = await prepareNextAttempt(); + options = assertReusableConnectionOptions(initialOptions, nextOptions); + } + attemptResult = await runAttempt(options, attemptNumber); + output.log(`LIVE_ATTEMPT_COMPLETE ${JSON.stringify({ attemptNumber, outcome: "passed" })}`); + } catch (error) { + attemptError = error; + output.error( + `LIVE_ATTEMPT_FAILED ${JSON.stringify({ attemptNumber, message: error.message, outcome: "failed" })}`, + ); + } + + if (!persistentSession) { + if (attemptError) throw attemptError; + return attemptResult; + } + + const outcome = attemptError ? "failed" : "passed"; + try { + const evidencePath = await preserveEvidence({ attemptNumber, outcome }); + output.log(`LIVE_ATTEMPT_EVIDENCE ${JSON.stringify({ attemptNumber, outcome, path: evidencePath })}`); + } catch (evidenceError) { + attemptError = attemptError + ? new AggregateError([attemptError, evidenceError], "The live attempt and its evidence snapshot both failed.") + : evidenceError; + output.error(`LIVE_ATTEMPT_EVIDENCE_FAILED ${JSON.stringify({ attemptNumber, message: evidenceError.message })}`); + } + + while (true) { + output.log(`READY_FOR_LIVE_RERUN_OR_EXIT attempt=${attemptNumber} outcome=${attemptError ? "failed" : "passed"}`); + const command = normalizeLiveSessionCommand(await readLine()); + if (command === "RERUN") { + attemptNumber += 1; + break; + } + if (command === "EXIT") { + if (attemptError) throw attemptError; + return attemptResult; + } + output.error("LIVE_RERUN_COMMAND_REJECTED expected=RERUN_OR_EXIT"); + } + } +} + +async function main({ + buildArtifact = buildLiveRuntimeArtifact, + connectBrowser = (options) => + chromium.connectOverCDP(options.cdpEndpoint, { + isLocal: true, + noDefaults: true, + timeout: options.cdpConnectTimeoutMilliseconds, + }), + environment = process.env, + executeAttempt = runConnectedLiveAttempt, + output = console, + preserveEvidence = preserveLiveAttemptEvidence, + readLine, + readOptions = readLiveOptions, + resetEvidenceWorkspace = resetLiveAttemptEvidenceWorkspace, + selectContext = selectAuthenticatedYoutubeContext, +} = {}) { + const persistentSession = readPersistentSessionMode(environment); + const requestedRuntime = readFullLiveRuntime(environment); + const ownedLineReader = readLine ? null : createInputLineReader(); + const nextLine = readLine ?? ownedLineReader.readLine; + const prepareAttempt = () => + prepareLiveAttempt({ + buildArtifact, + environment, + output, + readLine: nextLine, + readOptions, + requestedRuntime, + }); + + let browser; + try { + const initialOptions = await prepareAttempt(); + output.log("WAITING_FOR_CHROMIUM_DEBUG_APPROVAL"); + browser = await connectBrowser(initialOptions); + const selectedSession = await selectContext(browser, initialOptions.expectedChannel); + output.log( + `AUTHENTICATED_CHROMIUM_CONTEXT_SELECTED context=${selectedSession.contextIndex + 1} page=${selectedSession.pageIndex + 1} handle=${initialOptions.expectedChannel}`, + ); + + return await runLiveAttemptLoop({ + initialOptions, + output, + persistentSession, + prepareNextAttempt: prepareAttempt, + preserveEvidence, + readLine: nextLine, + resetEvidenceWorkspace, + runAttempt: (options, attemptNumber) => + executeAttempt({ browser, options, output, readLine: nextLine, selectedSession, attemptNumber }), + }); + } finally { + if (browser) await browser.close(); + ownedLineReader?.close(); + } +} + +if (require.main === module) { + main().catch((error) => { + console.error(error); + process.exitCode = 1; + }); +} + +module.exports = { + assertReusableConnectionOptions, + closeLiveAttemptPage, + createInputLineReader, + createInteractiveLiveDriver, + createInteractiveVoteRecorderFactory, + createReactionTargetDetails, + formatReactionApprovalReadiness, + main, + normalizeLiveSessionCommand, + prepareLiveAttempt, + preserveLiveAttemptEvidence, + readPersistentSessionMode, + resetLiveAttemptEvidenceWorkspace, + resolveSafeLiveEvidenceDirectory, + runConnectedLiveAttempt, + runLiveAttemptLoop, +}; diff --git a/Extensions/UserScript/e2e/live/live-interactive-scenario-contract.js b/Extensions/UserScript/e2e/live/live-interactive-scenario-contract.js new file mode 100644 index 0000000..5b1ee72 --- /dev/null +++ b/Extensions/UserScript/e2e/live/live-interactive-scenario-contract.js @@ -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, +}; diff --git a/Extensions/UserScript/e2e/live/live-scenarios.js b/Extensions/UserScript/e2e/live/live-scenarios.js index 3f70872..b445c4a 100644 --- a/Extensions/UserScript/e2e/live/live-scenarios.js +++ b/Extensions/UserScript/e2e/live/live-scenarios.js @@ -1,7 +1,16 @@ const assert = require("node:assert/strict"); const fs = require("node:fs"); const path = require("node:path"); +const { MIN_LIVE_SHORTS_NEXT_HOPS } = require("../../live/live-navigation-constants"); const { WATCH_RATIO_SOAK_DURATION_MS } = require("./live-youtube-driver"); +const { + DISLIKED_STATE, + LIKED_STATE, + NEUTRAL_STATE, + applyVoteTransitionCounts, + resolveVoteTransition, +} = require("../../../common/vote-transition-core"); +const { assertExpectedWatchCounts } = require("./watch-ratio-audit"); const RESPONSIVE_VIEWPORTS = [ { height: 720, width: 1280 }, @@ -9,16 +18,38 @@ const RESPONSIVE_VIEWPORTS = [ { height: 844, width: 390 }, ]; +const WATCH_ACTION_TOPOLOGY_VIEWPORTS = [ + { height: 900, name: "wide-constrained", width: 1536 }, + { height: 800, name: "stacked", width: 1300 }, + { height: 800, name: "narrow", width: 820 }, +]; + const REACTION_CYCLES = { neutral: ["like", "like", "dislike", "like", "dislike", "dislike"], liked: ["like", "dislike", "like", "dislike", "dislike", "like"], disliked: ["like", "dislike", "dislike", "like", "like", "dislike"], }; +const LIVE_TO_SHARED_REACTION_STATE = { + disliked: DISLIKED_STATE, + liked: LIKED_STATE, + neutral: NEUTRAL_STATE, +}; +const SHARED_TO_LIVE_REACTION_STATE = Object.fromEntries( + Object.entries(LIVE_TO_SHARED_REACTION_STATE).map(([liveState, sharedState]) => [sharedState, liveState]), +); + +function resolveLiveVoteTransition(state, action) { + const sharedState = LIVE_TO_SHARED_REACTION_STATE[state]; + assert.ok(sharedState, `Unsupported live reaction state: ${state}`); + const transition = resolveVoteTransition(sharedState, action); + const nextState = SHARED_TO_LIVE_REACTION_STATE[transition.nextState]; + assert.ok(nextState, `Unsupported shared reaction state: ${transition.nextState}`); + return { ...transition, nextState }; +} + function nextReactionState(state, action) { - if (action === "like") return state === "liked" ? "neutral" : "liked"; - if (action === "dislike") return state === "disliked" ? "neutral" : "disliked"; - throw new Error(`Unsupported reaction action: ${action}`); + return resolveLiveVoteTransition(state, action).nextState; } function reactionValue(state) { @@ -35,39 +66,40 @@ async function assertLivePreconditions(driver, options) { async function runWatchRenderScenario(driver, options) { return driver.withNoProductionInteractions(async () => { - await driver.openPlaylist(options.playlistUrl, options.watchA); + const navigation = await driver.openPlaylist(options.playlistUrl, options.watchA); await assertLivePreconditions(driver, options); - return driver.waitForDislikeText(); + return driver.assertCurrentWatchResult(options.watchA, options.runtime, navigation.body); }); } async function runReloadScenario(driver, options) { return driver.withNoProductionInteractions(async () => { - await driver.openPlaylist(options.playlistUrl, options.watchA); + const initialNavigation = await driver.openPlaylist(options.playlistUrl, options.watchA); await assertLivePreconditions(driver, options); - await driver.waitForDislikeText(); - await driver.reload(options.watchA); + const initial = await driver.assertCurrentWatchResult(options.watchA, options.runtime, initialNavigation.body); + const reloadNavigation = await driver.reload(options.watchA); await assertLivePreconditions(driver, options); - return driver.waitForDislikeText(); + const reloaded = await driver.assertCurrentWatchResult(options.watchA, options.runtime, reloadNavigation.body); + return { initial, reloaded }; }); } async function runSpaNavigationScenario(driver, options) { return driver.withNoProductionInteractions(async () => { - await driver.openPlaylist(options.playlistUrl, options.watchA); + const watchANavigation = await driver.openPlaylist(options.playlistUrl, options.watchA); await assertLivePreconditions(driver, options); - const watchACount = await driver.waitForDislikeText(); - await driver.navigateWithinPlaylist(options.watchB); + const watchA = await driver.assertCurrentWatchResult(options.watchA, options.runtime, watchANavigation.body); + const watchBNavigation = await driver.navigateWithinPlaylist({ excludedVideoIds: [options.watchA] }); + const destinationVideoId = watchBNavigation.videoId; + assert.match(destinationVideoId, /^[A-Za-z0-9_-]{11}$/, "Playlist navigation returned an invalid video ID."); + assert.notEqual(destinationVideoId, options.watchA, "Playlist navigation returned the source video."); await assertLivePreconditions(driver, options); - const watchBCount = await driver.waitForDislikeText({ differentFrom: watchACount }); + const watchB = await driver.assertCurrentWatchResult(destinationVideoId, options.runtime, watchBNavigation.body); + const watchACount = watchA.count; + const watchBCount = watchB.count; assert.match(watchACount, /\d/); assert.match(watchBCount, /\d/); - assert.notEqual( - watchBCount, - watchACount, - "Choose allowlisted playlist videos whose rendered dislike counts differ so stale SPA UI can be detected.", - ); - return { watchACount, watchBCount }; + return { destinationVideoId, watchA, watchACount, watchB, watchBCount }; }); } @@ -106,26 +138,31 @@ async function runSidebarStressScenario( outputDirectory, `${options.runtime}-sidebar-hop-${String(index + 1).padStart(2, "0")}.png`, ); - const readinessStartedAt = Date.now(); const visual = await driver.captureWatchRatioVisual(options.runtime, screenshotPath, { + expectedCounts: body, + expectedVideoId: videoId, presenceTimeoutMs: readyTimeoutMs, }); - const readyLatencyMs = Date.now() - readinessStartedAt; + const readyLatencyMs = visual.presenceLatencyMs; assert.ok( - readyLatencyMs <= readyTimeoutMs, + Number.isFinite(readyLatencyMs) && readyLatencyMs <= readyTimeoutMs, `Sidebar hop ${index + 1} ratio bar took ${readyLatencyMs}ms; the budget is ${readyTimeoutMs}ms.`, ); assert.match(visual.count, /\d/, `Sidebar hop ${index + 1} did not render a dislike count.`); + const countAudit = await driver.assertRenderedDislikeCount(visual.count, body.dislikes, options.runtime); const soak = await driver.soakWatchRatioVisual(options.runtime, { durationMs: soakDurationMs, expectedCount: visual.count, + expectedCounts: body, videoId, }); visitedVideoIds.push(videoId); hops.push({ apiDislikes: body.dislikes, + apiLikes: body.likes, count: visual.count, + countAudit, readyLatencyMs, readyTimeoutMs, screenshotPath, @@ -138,43 +175,402 @@ async function runSidebarStressScenario( }); } -async function runShortsRenderScenario(driver, options) { +async function runWatchActionTopologyScenario( + driver, + options, + { + makeDirectory = (directory) => fs.mkdirSync(directory, { recursive: true }), + outputDirectory = path.resolve(__dirname, "../../../../test-results/live-youtube/watch-actions"), + } = {}, +) { + makeDirectory(outputDirectory); return driver.withNoProductionInteractions(async () => { - await driver.openShort(options.short); - await assertLivePreconditions(driver, options); - return driver.waitForDislikeText(); + const originalViewport = await driver.readViewportSize(); + const coldLayouts = []; + let baseline; + + try { + for (const viewport of WATCH_ACTION_TOPOLOGY_VIEWPORTS) { + await driver.setViewportSize(viewport); + const navigation = await driver.openWatch(options.watchA); + await assertLivePreconditions(driver, options); + const count = await driver.waitForDislikeText(); + await driver.assertRenderedDislikeCount(count, navigation.body.dislikes, options.runtime); + const topology = await driver.captureWatchActionTopologyVisual( + options.runtime, + path.join(outputDirectory, `${options.runtime}-cold-${viewport.name}-${viewport.width}.png`), + { + expectedCounts: navigation.body, + expectedInventorySignatures: baseline?.inventorySignatures ?? null, + minimumTopLevelOptionalActions: baseline ? 0 : 1, + }, + ); + if (!baseline) { + baseline = topology; + } + coldLayouts.push(topology); + } + + const coldLayoutRecords = coldLayouts.map((topology, index) => ({ + topology, + viewport: WATCH_ACTION_TOPOLOGY_VIEWPORTS[index], + })); + const expansionTransitions = coldLayoutRecords + .flatMap((source) => coldLayoutRecords.map((destination) => ({ destination, source }))) + .filter( + ({ destination, source }) => + destination.topology.topLevelOptionalSignatures.length > + source.topology.topLevelOptionalSignatures.length && + source.topology.topLevelOptionalSignatures.every((signature) => + destination.topology.topLevelOptionalSignatures.includes(signature), + ), + ) + .sort( + (left, right) => + right.destination.topology.topLevelOptionalSignatures.length - + left.destination.topology.topLevelOptionalSignatures.length || + left.source.topology.topLevelOptionalSignatures.length - + right.source.topology.topLevelOptionalSignatures.length, + ); + const { destination: resizeDestination, source: resizeSource } = expansionTransitions[0] ?? {}; + assert.ok( + resizeDestination, + "The configured cold Watch widths did not expose a layout with a strict superset of top-level actions.", + ); + + await driver.setViewportSize(resizeSource.viewport); + const resizeNavigation = await driver.openWatch(options.watchA); + await assertLivePreconditions(driver, options); + const resizeCount = await driver.waitForDislikeText(); + await driver.assertRenderedDislikeCount(resizeCount, resizeNavigation.body.dislikes, options.runtime); + const resizeRoundTripSource = await driver.captureWatchActionTopologyVisual( + options.runtime, + path.join( + outputDirectory, + `${options.runtime}-resize-source-${resizeSource.viewport.name}-${resizeSource.viewport.width}.png`, + ), + { + expectedCounts: resizeNavigation.body, + expectedInventorySignatures: baseline.inventorySignatures, + expectedTopLevelOptionalSignatures: resizeSource.topology.topLevelOptionalSignatures, + minimumTopLevelOptionalActions: resizeSource.topology.topLevelOptionalSignatures.length, + }, + ); + + await driver.setViewportSize(resizeDestination.viewport); + await driver.waitForDislikeText(); + const resizeRoundTripDestination = await driver.captureWatchActionTopologyVisual( + options.runtime, + path.join( + outputDirectory, + `${options.runtime}-resize-destination-${resizeDestination.viewport.name}-${resizeDestination.viewport.width}.png`, + ), + { + expectedCounts: resizeNavigation.body, + expectedInventorySignatures: baseline.inventorySignatures, + expectedTopLevelOptionalSignatures: resizeDestination.topology.topLevelOptionalSignatures, + minimumTopLevelOptionalActions: resizeDestination.topology.topLevelOptionalSignatures.length, + }, + ); + + await driver.setViewportSize(WATCH_ACTION_TOPOLOGY_VIEWPORTS[0]); + const { body: sidebarBody, videoId: sidebarVideoId } = await driver.navigateToRelatedWatch([options.watchA]); + await assertLivePreconditions(driver, options); + const sidebarCount = await driver.waitForDislikeText(); + await driver.assertRenderedDislikeCount(sidebarCount, sidebarBody.dislikes, options.runtime); + const sidebar = await driver.captureWatchActionTopologyVisual( + options.runtime, + path.join(outputDirectory, `${options.runtime}-sidebar-${sidebarVideoId}.png`), + { expectedCounts: sidebarBody, minimumTopLevelOptionalActions: 1 }, + ); + + return { + coldLayouts, + outputDirectory, + resizeRoundTrip: { + destination: resizeRoundTripDestination, + destinationViewport: resizeDestination.viewport, + source: resizeRoundTripSource, + sourceViewport: resizeSource.viewport, + }, + sidebar, + sidebarVideoId, + }; + } finally { + await driver.setViewportSize(originalViewport); + } }); } -async function runChannelShortsNavigationScenario(driver, options) { - return driver.withNoProductionInteractions(async () => { - await driver.navigateFromColdChannelToShort(options.navigation.channelUrl, options.navigation.short); - await assertLivePreconditions(driver, options); - const initial = await driver.assertCurrentShortsControl(options.navigation.short, options.runtime); +async function runShortsRenderScenario( + driver, + options, + { + makeDirectory = (directory) => fs.mkdirSync(directory, { recursive: true }), + outputDirectory = path.resolve(__dirname, "../../../../test-results/live-youtube/specific-short"), + } = {}, +) { + const captureShortsVisual = { + "native-pair": (screenshotPath) => driver.captureNativeShortsVisual(options.short, screenshotPath), + "strict-synthetic": (screenshotPath) => driver.captureSyntheticShortsVisual(options.short, screenshotPath), + }[options.capabilities?.shortsVisualModel]; + assert.equal( + typeof captureShortsVisual, + "function", + `Unsupported live Shorts visual model: ${options.capabilities?.shortsVisualModel ?? "missing"}.`, + ); + makeDirectory(outputDirectory); - const nextVideoId = await driver.navigateToNextShort(options.navigation.short); - await driver.assertRuntime(options.runtime, options.expectedVersion, options.expectedBuildId); - const next = await driver.assertCurrentShortsControl(nextVideoId, options.runtime); - await driver.pausePlayback(); - assert.notEqual( - next.videoId, - initial.videoId, - "The Shorts Next video scenario did not change the current video ID.", + return driver.withNoProductionInteractions(async () => { + const validate = async (navigation, phase) => { + await assertLivePreconditions(driver, options); + assert.equal(navigation?.videoId, options.short, `${phase} targeted a stale Short.`); + assert.equal(navigation?.status, 200, `${phase} did not receive HTTP 200 for ${options.short}.`); + assert.ok( + Number.isSafeInteger(navigation?.body?.dislikes) && navigation.body.dislikes >= 0, + `${phase} has no valid production dislike count for ${options.short}.`, + ); + const control = await driver.assertCurrentShortsControl(options.short, options.runtime, { + expectedDislikes: navigation.body.dislikes, + }); + const visual = await captureShortsVisual( + path.join(outputDirectory, `${options.runtime}-${phase.toLowerCase().replace(/[^a-z0-9]+/g, "-")}.png`), + ); + const stability = await driver.soakCurrentShortsControl(options.short, options.runtime, navigation.body.dislikes); + return { control, navigation, stability, visual }; + }; + + const direct = await validate(await driver.openShort(options.short), "cold-direct-load"); + const reloaded = await validate(await driver.reload(options.short), "reload"); + return { direct, outputDirectory, reloaded, videoId: options.short }; + }); +} + +async function runChannelShortsNavigationScenario( + driver, + options, + { + makeDirectory = (directory) => fs.mkdirSync(directory, { recursive: true }), + outputDirectory = path.resolve(__dirname, "../../../../test-results/live-youtube/shorts-navigation"), + writeFile = (filePath, contents) => fs.writeFileSync(filePath, contents, "utf8"), + } = {}, +) { + const hopCount = options.navigation?.shortsNextHops; + assert.ok( + Number.isSafeInteger(hopCount) && hopCount >= MIN_LIVE_SHORTS_NEXT_HOPS, + `The Shorts navigation smoke requires at least ${MIN_LIVE_SHORTS_NEXT_HOPS} successful Next samples.`, + ); + const captureShortsVisual = { + "native-pair": (videoId, screenshotPath) => driver.captureNativeShortsVisual(videoId, screenshotPath), + "strict-synthetic": (videoId, screenshotPath) => driver.captureSyntheticShortsVisual(videoId, screenshotPath), + }[options.capabilities?.shortsVisualModel]; + assert.equal( + typeof captureShortsVisual, + "function", + `Unsupported live Shorts visual model: ${options.capabilities?.shortsVisualModel ?? "missing"}.`, + ); + makeDirectory(outputDirectory); + + return driver.withNoProductionInteractions(async () => { + const initialNavigation = await driver.navigateFromColdChannelToShort( + options.navigation.channelUrl, + options.navigation.short, ); - return { initial, next }; + await assertLivePreconditions(driver, options); + const visitedVideoIds = [options.navigation.short]; + const hops = []; + const skipped = []; + let previousVideoId = options.navigation.short; + + const recordBlankSample = async (navigation, source, attemptNumber) => { + const evidenceName = `${options.runtime}-shorts-blank-${source}-${String(attemptNumber).padStart(2, "0")}-${ + navigation.videoId + }`; + const screenshotPath = path.join(outputDirectory, `${evidenceName}.png`); + const inventoryPath = path.join(outputDirectory, `${evidenceName}.json`); + const diagnostics = await driver.captureBlankShortsDiagnostics(navigation.videoId, screenshotPath); + if (diagnostics.nativeControlsAfterEvidence.status !== "blank") { + navigation.nativeControls = diagnostics.nativeControlsAfterEvidence; + driver.reportProgress("shorts-sample.recovered-after-evidence", { + attemptNumber, + source, + status: diagnostics.nativeControlsAfterEvidence.status, + videoId: navigation.videoId, + }); + return null; + } + const record = { + attemptNumber, + durationMs: navigation.nativeControls.observedForMs, + inventoryPath, + reason: navigation.nativeControls.reason, + screenshotPath, + source, + videoId: navigation.videoId, + votesRequestObserved: navigation.request !== null, + }; + writeFile( + inventoryPath, + `${JSON.stringify( + { + diagnostics, + nativeControls: navigation.nativeControls, + sample: record, + }, + null, + 2, + )}\n`, + ); + skipped.push(record); + driver.reportBlankShortsSample(record); + return record; + }; + const assertReadyNavigation = (navigation, label) => { + assert.equal( + navigation?.nativeControls?.status, + "present", + `${label} did not provide a native Shorts control classification.`, + ); + assert.equal(navigation.status, 200, `${label} did not receive HTTP 200 for ${navigation.videoId}.`); + assert.equal(navigation.request?.method, "GET", `${label} did not issue a GET /votes request.`); + assert.equal( + navigation.request?.videoId, + navigation.videoId, + `${label} requested votes for stale video ${navigation.request?.videoId} instead of ${navigation.videoId}.`, + ); + assert.ok( + Number.isSafeInteger(navigation.body?.dislikes) && navigation.body.dislikes >= 0, + `${label} has no valid production dislike count.`, + ); + }; + + let initial = null; + let initialStability = null; + let initialVisual = null; + if (initialNavigation.nativeControls?.status === "blank") { + await recordBlankSample(initialNavigation, "channel", 0); + } + if (initialNavigation.nativeControls?.status !== "blank") { + assertReadyNavigation(initialNavigation, "The initial channel-to-Short transition"); + initial = await driver.assertCurrentShortsControl(options.navigation.short, options.runtime, { + expectedDislikes: initialNavigation.body.dislikes, + }); + initialStability = await driver.soakCurrentShortsControl( + options.navigation.short, + options.runtime, + initialNavigation.body.dislikes, + ); + initialVisual = await captureShortsVisual( + options.navigation.short, + path.join(outputDirectory, `${options.runtime}-shorts-hop-00-${options.navigation.short}.png`), + ); + } + + const maximumNextAttempts = hopCount * 2; + const expectedValidSamples = hopCount + (initial === null ? 0 : 1); + const maximumBlankSamples = expectedValidSamples - 1; + let attemptedNextHops = 0; + while (hops.length < hopCount && attemptedNextHops < maximumNextAttempts && skipped.length <= maximumBlankSamples) { + attemptedNextHops += 1; + const navigation = await driver.navigateToNextShort(previousVideoId); + const { videoId } = navigation; + assert.match( + videoId, + /^[A-Za-z0-9_-]{11}$/, + `Shorts Next attempt ${attemptedNextHops} produced an invalid video ID.`, + ); + assert.ok( + !visitedVideoIds.includes(videoId), + `Shorts Next attempt ${attemptedNextHops} revisited an earlier video ${videoId}.`, + ); + visitedVideoIds.push(videoId); + previousVideoId = videoId; + + if (navigation.nativeControls?.status === "blank") { + const skippedNavigation = await recordBlankSample(navigation, "next", attemptedNextHops); + if (skippedNavigation !== null) continue; + } + + const hopNumber = hops.length + 1; + const label = `Shorts successful Next sample ${hopNumber} (attempt ${attemptedNextHops})`; + assertReadyNavigation(navigation, label); + const { body, request, status } = navigation; + await driver.assertRuntime(options.runtime, options.expectedVersion, options.expectedBuildId); + const control = await driver.assertCurrentShortsControl(videoId, options.runtime, { + expectedDislikes: body.dislikes, + }); + assert.equal(control.videoId, videoId, `${label} returned a stale control owner.`); + assert.equal(control.syntheticVideoId, videoId, `${label} retained a stale synthetic ID.`); + assert.equal( + control.activeActionBarSyntheticControls, + 1, + `${label} has duplicate controls in its active action stack.`, + ); + assert.equal(control.currentReelSyntheticControls, 1, `${label} has duplicate controls in its current reel.`); + assert.equal(control.visibleDocumentSyntheticControls, 1, `${label} has duplicate visible controls.`); + assert.equal(control.visibleStaleSyntheticControls, 0, `${label} has a visible stale-video control.`); + assert.match(control.count, /\p{Number}/u, `${label} did not render its dislike count.`); + const visual = await captureShortsVisual( + videoId, + path.join( + outputDirectory, + `${options.runtime}-shorts-hop-${String(hopNumber).padStart(2, "0")}-${videoId}.png`, + ), + ); + const stability = await driver.soakCurrentShortsControl(videoId, options.runtime, body.dislikes); + hops.push({ + attemptNumber: attemptedNextHops, + body, + control, + nativeControls: navigation.nativeControls, + request, + stability, + status, + videoId, + visual, + }); + } + + assert.equal( + hops.length, + hopCount, + `The Shorts navigation smoke required ${hopCount} successful Next samples, but found ${hops.length} valid and ${skipped.length} blank samples across ${attemptedNextHops} Next attempts (limit ${maximumNextAttempts}).`, + ); + const validSampleCount = hops.length + (initial === null ? 0 : 1); + assert.ok( + validSampleCount > skipped.length, + `The Shorts navigation smoke found too many broken YouTube samples: ${validSampleCount} valid versus ${skipped.length} blank.`, + ); + await driver.pausePlayback(); + return { + attemptedNextHops, + hops, + initial, + initialNavigation, + initialStability, + initialVisual, + maximumNextAttempts, + outputDirectory, + skipped, + successfulNextSamples: hops.length, + validSampleCount, + visitedVideoIds, + }; }); } async function runChannelWatchNavigationScenario(driver, options) { if (!options.navigation.watch) { - throw new Error("RYD_LIVE_NAV_WATCH is required for the optional channel-to-watch scenario."); + throw new Error("RYD_LIVE_NAV_WATCH is required for the channel-to-watch scenario."); } return driver.withNoProductionInteractions(async () => { - await driver.navigateFromColdChannelToWatch(options.navigation.channelUrl, options.navigation.watch); + const navigation = await driver.navigateFromColdChannelToWatch( + options.navigation.channelUrl, + options.navigation.watch, + ); await assertLivePreconditions(driver, options); - const count = await driver.waitForDislikeText(); - return { count, videoId: options.navigation.watch }; + return driver.assertCurrentWatchResult(options.navigation.watch, options.runtime, navigation.body); }); } @@ -186,6 +582,16 @@ async function runResponsiveVisualScenario( outputDirectory = path.resolve(__dirname, "../../../../test-results/live-youtube/responsive"), } = {}, ) { + const shortsVisualModel = options.capabilities?.shortsVisualModel; + const captureShortsVisual = { + "native-pair": (screenshotPath) => driver.captureNativeShortsVisual(options.short, screenshotPath), + "strict-synthetic": (screenshotPath) => driver.captureSyntheticShortsVisual(options.short, screenshotPath), + }[shortsVisualModel]; + assert.equal( + typeof captureShortsVisual, + "function", + `Unsupported live Shorts visual model: ${shortsVisualModel ?? "missing"}.`, + ); makeDirectory(outputDirectory); return driver.withNoProductionInteractions(async () => { const originalViewport = await driver.readViewportSize(); @@ -194,34 +600,38 @@ async function runResponsiveVisualScenario( try { await driver.setViewportSize(RESPONSIVE_VIEWPORTS[0]); - await driver.openWatch(options.watchA); + const watchNavigation = await driver.openWatch(options.watchA); await assertLivePreconditions(driver, options); for (let index = 0; index < RESPONSIVE_VIEWPORTS.length; index += 1) { const viewport = RESPONSIVE_VIEWPORTS[index]; if (index > 0) await driver.setViewportSize(viewport); - await driver.waitForDislikeText(); + const count = await driver.waitForDislikeText(); + await driver.assertRenderedDislikeCount(count, watchNavigation.body.dislikes, options.runtime); watch.push( await driver.captureWatchRatioVisual( options.runtime, path.join(outputDirectory, `${options.runtime}-watch-ratio-${viewport.width}.png`), + { expectedCounts: watchNavigation.body, expectedVideoId: options.watchA }, ), ); } await driver.setViewportSize(RESPONSIVE_VIEWPORTS[0]); - await driver.openShort(options.short); + const shortNavigation = await driver.openShort(options.short); await assertLivePreconditions(driver, options); for (let index = 0; index < RESPONSIVE_VIEWPORTS.length; index += 1) { const viewport = RESPONSIVE_VIEWPORTS[index]; if (index > 0) await driver.setViewportSize(viewport); - await driver.assertCurrentShortsControl(options.short, options.runtime); - await driver.waitForDislikeText(); + const control = await driver.assertCurrentShortsControl(options.short, options.runtime, { + expectedDislikes: shortNavigation.body.dislikes, + }); + const count = await driver.waitForDislikeText(); + await driver.assertRenderedDislikeCount(count, shortNavigation.body.dislikes, options.runtime); const screenshotPath = path.join(outputDirectory, `${options.runtime}-shorts-control-${viewport.width}.png`); - shorts.push( - options.runtime === "userscript" - ? await driver.captureSyntheticShortsVisual(options.short, screenshotPath) - : await driver.captureNativeShortsVisual(options.short, screenshotPath), - ); + shorts.push({ + control, + visual: await captureShortsVisual(screenshotPath), + }); } } finally { await driver.setViewportSize(originalViewport); @@ -333,24 +743,40 @@ async function runReactionCycle( options, { beforeFirstAction, captureReactionVisual = null, isShort = false, videoId }, ) { - if (isShort) await driver.openShort(videoId); - else await driver.openWatch(videoId); + const navigation = isShort ? await driver.openShort(videoId) : await driver.openWatch(videoId); + assertExpectedWatchCounts(navigation?.body); await assertLivePreconditions(driver, options); - await driver.waitForDislikeText(); + const baselineCount = await driver.waitForDislikeText(); + await driver.assertRenderedDislikeCount(baselineCount, navigation.body.dislikes, options.runtime); const initialState = await driver.readReactionState(); const actions = REACTION_CYCLES[initialState]; assert.ok(actions, `Unsupported initial YouTube reaction state: ${initialState}`); + let projectedState = initialState; + let projectedCounts = { dislikes: navigation.body.dislikes, likes: navigation.body.likes }; + const observableDislikeChanges = []; + for (const action of actions) { + const transition = resolveLiveVoteTransition(projectedState, action); + const nextCounts = applyVoteTransitionCounts(projectedCounts.likes, projectedCounts.dislikes, transition); + if (transition.dislikesDelta !== 0) { + observableDislikeChanges.push({ after: nextCounts.dislikes, before: projectedCounts.dislikes }); + } + projectedCounts = nextCounts; + projectedState = transition.nextState; + } + await driver.assertDislikeCountChangesObservable(observableDislikeChanges, options.runtime); let authorized = false; let completed = false; let expectedUserId; let failedAttempt; let currentState = initialState; + const initialCounts = { dislikes: navigation.body.dislikes, likes: navigation.body.likes }; + let expectedCounts = { ...initialCounts }; const evidencePaths = []; const captureEvidence = async (index) => { if (!captureReactionVisual) return; - const screenshotPath = await captureReactionVisual({ index, state: currentState }); + const screenshotPath = await captureReactionVisual({ counts: { ...expectedCounts }, index, state: currentState }); assert.equal(typeof screenshotPath, "string", "The reaction visual capture did not return an evidence path."); evidencePaths.push(screenshotPath); }; @@ -363,9 +789,11 @@ async function runReactionCycle( for (let index = 0; index < actions.length; index += 1) { const action = actions[index]; - const expectedState = nextReactionState(currentState, action); + const transition = resolveLiveVoteTransition(currentState, action); + const expectedState = transition.nextState; + const nextCounts = applyVoteTransitionCounts(expectedCounts.likes, expectedCounts.dislikes, transition); const mark = recorder.mark(); - const value = reactionValue(expectedState); + const value = transition.value; failedAttempt = { mark, userId: undefined, value }; await driver.clickAction(videoId, action); await driver.waitForReactionState(expectedState); @@ -375,13 +803,15 @@ async function runReactionCycle( expectedUserId = userId; failedAttempt = undefined; currentState = expectedState; - await driver.waitForDislikeText(); + expectedCounts = nextCounts; + const renderedCount = await driver.waitForDislikeText(); + await driver.assertRenderedDislikeCount(renderedCount, expectedCounts.dislikes, options.runtime); await captureEvidence(index + 1); } assert.equal(currentState, initialState, "The six-transition cycle did not return to its initial state."); completed = true; - return { evidencePaths, initialState, userId: expectedUserId }; + return { evidencePaths, finalCounts: expectedCounts, initialCounts, initialState, userId: expectedUserId }; } finally { if (authorized && !completed) { if (failedAttempt && !failedAttempt.userId && typeof recorder.voteUserId === "function") { @@ -416,16 +846,19 @@ async function runProductionReactionMatrixScenario( const outputDirectory = visualOptions.outputDirectory ?? path.resolve(__dirname, "../../../../test-results/live-youtube/reactions", options.runtime); + const reactionShort = options.reactionShort ?? options.short; makeDirectory(outputDirectory); const captureFor = (kind, videoId, isShort) => - async ({ index, state }) => { + async ({ counts, index, state }) => { const screenshotPath = path.join(outputDirectory, `${kind}-${index}-${state}.png`); const evidence = await driver.captureReactionStateVisual({ + expectedCounts: counts, expectedState: state, isShort, runtime: options.runtime, screenshotPath, + shortsVisualModel: options.capabilities?.shortsVisualModel, videoId, }); assert.equal( @@ -448,12 +881,12 @@ async function runProductionReactionMatrixScenario( watchRecorder.stop(); } - const shortRecorder = createRecorder(options.short); + const shortRecorder = createRecorder(reactionShort); try { const shortResult = await runReactionCycle(driver, shortRecorder, options, { - captureReactionVisual: captureFor("short", options.short, true), + captureReactionVisual: captureFor("short", reactionShort, true), isShort: true, - videoId: options.short, + videoId: reactionShort, }); assert.equal( shortResult.userId, @@ -471,6 +904,91 @@ async function runProductionReactionMatrixScenario( } } +async function runPostNavigationVoteScenario(driver, options, createRecorder, consumeVoteApproval) { + const recorder = createRecorder(options.watchB); + try { + await driver.openPlaylist(options.playlistUrl, options.watchA); + await assertLivePreconditions(driver, options); + driver.assertCurrentVideo(options.watchA); + const watchACount = await driver.waitForDislikeText(); + + const navigationMark = recorder.mark(); + await driver.navigateWithinPlaylist(options.watchB); + await assertLivePreconditions(driver, options); + driver.assertCurrentVideo(options.watchB); + const watchBCount = await driver.waitForDislikeText({ differentFrom: watchACount }); + assert.notEqual( + watchBCount, + watchACount, + "Choose allowlisted playlist videos whose rendered dislike counts differ so stale SPA UI can be detected.", + ); + assert.equal( + recorder.mark(), + navigationMark, + "SPA navigation emitted unexpected production interaction traffic before any reaction click.", + ); + + const initialState = await driver.readReactionState(); + const action = "dislike"; + const selectedState = nextReactionState(initialState, action); + const selectedValue = reactionValue(selectedState); + const voteMark = navigationMark; + let authorized = false; + let failedAttempt = { mark: voteMark, userId: undefined, value: selectedValue }; + let userId; + + try { + driver.assertCurrentVideo(options.watchB); + await assertLivePreconditions(driver, options); + await consumeVoteApproval(); + authorized = true; + await driver.clickAction(options.watchB, action); + await driver.waitForReactionState(selectedState); + userId = await recorder.waitForHandshake(selectedValue, voteMark); + failedAttempt = undefined; + } finally { + if (authorized) { + if (failedAttempt && !failedAttempt.userId && typeof recorder.voteUserId === "function") { + try { + failedAttempt.userId = recorder.voteUserId(failedAttempt.value, failedAttempt.mark); + } catch { + failedAttempt.userId = undefined; + } + } + await restoreReactionState( + driver, + recorder, + options, + options.watchB, + initialState, + userId, + false, + failedAttempt, + ); + } + } + + driver.assertCurrentVideo(options.watchB); + assert.equal( + await driver.readReactionState(), + initialState, + "The post-navigation vote scenario did not restore the destination video's initial reaction state.", + ); + await driver.waitForDislikeText(); + return { + action, + initialState, + selectedState, + userId, + videoId: options.watchB, + watchACount, + watchBCount, + }; + } finally { + recorder.stop(); + } +} + async function restoreNeutralStateUnchecked(driver, recorder, options, dislikeMark, expectedUserId) { let state; try { @@ -554,8 +1072,10 @@ async function runReversibleVoteScenario(driver, recorder, options, consumeVoteA module.exports = { RESPONSIVE_VIEWPORTS, + WATCH_ACTION_TOPOLOGY_VIEWPORTS, runChannelShortsNavigationScenario, runChannelWatchNavigationScenario, + runPostNavigationVoteScenario, runProductionReactionMatrixScenario, runReactionCycle, runReloadScenario, @@ -565,4 +1085,5 @@ module.exports = { runShortsRenderScenario, runSpaNavigationScenario, runWatchRenderScenario, + runWatchActionTopologyScenario, }; diff --git a/Extensions/UserScript/e2e/live/live-youtube-driver.js b/Extensions/UserScript/e2e/live/live-youtube-driver.js index a927088..1360553 100644 --- a/Extensions/UserScript/e2e/live/live-youtube-driver.js +++ b/Extensions/UserScript/e2e/live/live-youtube-driver.js @@ -1,7 +1,24 @@ const assert = require("node:assert/strict"); +const { + USER_ID_PATTERN, + hasExactKeys, + isFourByteProofSolution, + isVoteProtocolBodyPairValid, +} = require("../../../e2e/vote-protocol-contract"); +const { inspectYoutubeSessionDocument, normalizeChannelHandle } = require("./live-authenticated-context"); +const { + assertExpectedWatchCounts, + assertWatchRatioData, + formattedWatchTooltipCandidates, +} = require("./watch-ratio-audit"); const API_ORIGIN = "https://returnyoutubedislikeapi.com"; +const EXTENSION_ID_PATTERN = /^[a-p]{32}$/; +const LIVE_BUILD_ID_PATTERN = /^[a-f0-9]{32}$/; +const LIVE_ACCEPT_HEADER_PATTERN = + /^application\/json, application\/vnd\.ryd-live\+json; id=([a-p]{32}); build=([a-f0-9]{32})$/; const VIDEO_ID_PATTERN = /^[A-Za-z0-9_-]{11}$/; +const RENDERED_NUMBER_PATTERN = /\p{Number}/u; const DISLIKE_BUTTON_SELECTORS = [ "[data-ryd-synthetic-shorts-dislike] button", "dislike-button-view-model button", @@ -21,11 +38,39 @@ const ACTION_BUTTON_SELECTORS = { dislike: DISLIKE_BUTTON_SELECTORS, like: LIKE_BUTTON_SELECTORS, }; +const VISIBLE_ACTION_BUTTON_SELECTORS = Object.fromEntries( + Object.entries(ACTION_BUTTON_SELECTORS).map(([action, selectors]) => [ + action, + selectors + .split(",") + .map((selector) => `${selector.trim()}:visible`) + .join(", "), + ]), +); const RATE_BAR_SELECTORS = { - extension: { bar: "#ryd-bar", container: "#ryd-bar-container" }, - userscript: { bar: "#return-youtube-dislike-bar", container: "#return-youtube-dislike-bar-container" }, + extension: { + bar: "#ryd-bar", + container: "#ryd-bar-container", + tooltip: "#ryd-dislike-tooltip", + }, + userscript: { + bar: "#return-youtube-dislike-bar", + container: "#return-youtube-dislike-bar-container", + tooltip: "#ryd-dislike-tooltip", + }, }; +const WATCH_OVERFLOW_POPUP_SELECTORS = Object.freeze([ + "ytd-popup-container ytd-menu-popup-renderer", + "tp-yt-iron-dropdown ytd-menu-popup-renderer", + "ytd-popup-container tp-yt-paper-listbox[role='menu']", + "ytd-popup-container [role='menu']", +]); +const WATCH_OVERFLOW_POPUP_SELECTOR = WATCH_OVERFLOW_POPUP_SELECTORS.join(", "); +const PLAYLIST_WATCH_LINK_SELECTOR = 'ytd-playlist-panel-video-renderer a[href*="/watch"]'; +const RELATED_WATCH_LINK_SELECTOR = '#related a[href*="/watch"]'; const SYNTHETIC_SHORTS_SELECTOR = "[data-ryd-synthetic-shorts-dislike]"; +const SHORTS_NATIVE_ACTION_BAR_SELECTOR = "reel-action-bar-view-model, .slim-video-action-bar-actions"; +const SHORTS_MINIMUM_NATIVE_ACTION_CONTROLS = 4; const SHORTS_NEXT_BUTTON_SELECTOR = [ "ytd-shorts #navigation-button-down button", "#navigation-button-down button", @@ -33,8 +78,8 @@ const SHORTS_NEXT_BUTTON_SELECTOR = [ ].join(", "); const SHORTS_GEOMETRY = { buttonSize: 48, + countHorizontalAllowance: 12, controlHeight: 78, - controlWidth: 48, fontSize: 12, geometryTolerance: 1, iconSize: 24, @@ -54,10 +99,25 @@ const SHORTS_ACTION_HOST_INSETS = { }; const SHORTS_NEXT_FIRST_CLICK_TIMEOUT = 5_000; const SHORTS_NEXT_RETRY_TIMEOUT = 25_000; +const CHANNEL_TARGET_ACTIVATION_TIMEOUT = 20_000; +const CHANNEL_TARGET_FIRST_NAVIGATION_TIMEOUT = 5_000; +const CHANNEL_TARGET_RETRY_NAVIGATION_TIMEOUT = 25_000; +const CHANNEL_TARGET_RETRY_INTERVAL_MS = 200; +const WATCH_OVERFLOW_FIRST_OPEN_TIMEOUT_MS = 3_000; +const SHORTS_CONTROL_SOAK_DURATION_MS = 2_000; +const SHORTS_CONTROL_SOAK_INTERVAL_MS = 400; +const SHORTS_NATIVE_CONTROLS_TIMEOUT_MS = 20_000; +const SHORTS_NATIVE_CONTROLS_OPERATION_TIMEOUT_MS = 2_000; +const SHORTS_BLANK_EVIDENCE_TIMEOUT_MS = 10_000; +const SHORTS_NATIVE_CONTROLS_WAKE_INTERVAL_MS = 1_000; +const SHORTS_VOTES_REQUEST_QUIET_MS = 250; +const VOTES_RESPONSE_REQUEST_QUIET_MS = 1_000; +const READ_ONLY_INTERACTION_GUARD_CLEANUP_TIMEOUT_MS = 10_000; const SHORTS_VISUAL_PAINT_TIMEOUT = 5_000; const VISUAL_TOOLTIP_TIMEOUT = 5_000; const WATCH_RATIO_SOAK_DURATION_MS = 4_000; const WATCH_RATIO_SOAK_INTERVAL_MS = 500; +const WATCH_RESULT_SOAK_DURATION_MS = 1_000; const NATIVE_YOUTUBE_TOOLTIP_SELECTOR = [ "tp-yt-paper-tooltip:not(#ryd-dislike-tooltip)", "ytd-tooltip-renderer", @@ -68,41 +128,391 @@ const NATIVE_YOUTUBE_TOOLTIP_SELECTOR = [ const delay = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)); -function isShortCandidateEligible(element, settings) { - const rect = element.getBoundingClientRect(); - const intersectsViewport = - rect.width > 0 && - rect.height > 0 && - rect.bottom > 0 && - rect.right > 0 && - rect.top < innerHeight && - rect.left < innerWidth; - if (!intersectsViewport) return false; +async function withOperationTimeout(action, timeoutMs, timeoutMessage) { + let timeoutId; + try { + return await Promise.race([ + Promise.resolve().then(action), + new Promise((_, reject) => { + timeoutId = setTimeout(() => reject(new Error(timeoutMessage)), timeoutMs); + }), + ]); + } finally { + clearTimeout(timeoutId); + } +} - const reel = element.closest("ytd-reel-video-renderer, ytm-reel-video-renderer"); +function normalizeRenderedCount(value) { + return String(value ?? "") + .normalize("NFKC") + .replace(/[\u200e\u200f\u202a-\u202e\u2066-\u2069]/giu, "") + .replace(/\s+/gu, " ") + .trim(); +} + +function roundDownUserscriptCount(value) { + if (value < 1_000) return value; + const integerDigits = Math.floor(Math.log10(value) - 2); + const decimal = integerDigits + (integerDigits % 3 ? 1 : 0); + return Math.floor(value / 10 ** decimal) * 10 ** decimal; +} + +function formattedDislikeCountCandidates(dislikes, locale, runtime) { + assert.ok(Number.isSafeInteger(dislikes) && dislikes >= 0, "The API dislike count must be a non-negative integer."); + assert.ok(runtime === "extension" || runtime === "userscript", `Unsupported live count runtime: ${runtime}`); + + const values = runtime === "userscript" ? [dislikes, roundDownUserscriptCount(dislikes)] : [dislikes]; + const formats = [ + { compactDisplay: "short", notation: "compact" }, + { compactDisplay: "long", notation: "compact" }, + { compactDisplay: "short", notation: "standard" }, + ]; + return [ + ...new Set( + values.flatMap((value) => + formats.map((format) => normalizeRenderedCount(Intl.NumberFormat(locale || "en", format).format(value))), + ), + ), + ]; +} + +function assertRenderedDislikeCountMatchesApi(renderedCount, dislikes, locale, runtime) { + const normalizedCount = normalizeRenderedCount(renderedCount); + const candidates = formattedDislikeCountCandidates(dislikes, locale, runtime); + assert.ok( + candidates.includes(normalizedCount), + `Rendered dislike count ${JSON.stringify(normalizedCount)} does not represent API count ${dislikes} for ${runtime}; expected one of ${JSON.stringify(candidates)}.`, + ); + return { candidates, normalizedCount }; +} + +function assertDislikeCountChangesObservable(changes, locale, runtime) { + assert.ok(Array.isArray(changes) && changes.length > 0, "At least one dislike-count change is required."); + return changes.map(({ after, before }, index) => { + assert.notEqual(before, after, `Dislike-count observability change ${index + 1} does not change the count.`); + const beforeCandidates = formattedDislikeCountCandidates(before, locale, runtime); + const afterCandidates = formattedDislikeCountCandidates(after, locale, runtime); + const afterSet = new Set(afterCandidates); + const overlap = beforeCandidates.filter((candidate) => afterSet.has(candidate)); + assert.deepEqual( + overlap, + [], + `A ${before} -> ${after} dislike change is not observable for ${runtime}; the formatted text can remain ${JSON.stringify( + overlap, + )}. Choose a low-count reaction target whose +/-1 changes are visibly distinct.`, + ); + return { after, afterCandidates, before, beforeCandidates }; + }); +} + +function assertExactVotesRequestAudit(requests, expectedVideoId) { + assert.match(expectedVideoId ?? "", VIDEO_ID_PATTERN, "A valid current video ID is required for the /votes audit."); + assert.ok(Array.isArray(requests), "The /votes request audit is missing."); + const staleRequests = requests.filter((request) => request.videoId !== expectedVideoId); + assert.deepEqual( + staleRequests, + [], + `The Shorts Next transition emitted a stale /votes request while targeting ${expectedVideoId}.`, + ); + assert.equal( + requests.length, + 1, + `The Shorts Next transition must emit exactly one /votes request for ${expectedVideoId}; observed ${requests.length}.`, + ); + assert.equal(requests[0].method, "GET", `The /votes request for ${expectedVideoId} did not use GET.`); + const requestUrl = new URL(requests[0].url); + assert.equal(requestUrl.origin, API_ORIGIN, `The /votes request for ${expectedVideoId} used the wrong origin.`); + assert.equal(requestUrl.pathname, "/votes", `The vote-count request for ${expectedVideoId} used the wrong path.`); + const requestedVideoIds = requestUrl.searchParams.getAll("videoId"); + assert.equal( + requestedVideoIds.length, + 1, + `The /votes request for ${expectedVideoId} did not contain exactly one matching videoId parameter.`, + ); + assert.equal( + requestedVideoIds[0], + expectedVideoId, + `The /votes request for ${expectedVideoId} did not contain exactly one matching videoId parameter.`, + ); + const unexpectedQueryParameters = [...new Set(requestUrl.searchParams.keys())].filter( + (name) => name !== "videoId" && name !== "likeCount", + ); + assert.deepEqual( + unexpectedQueryParameters, + [], + `The /votes request for ${expectedVideoId} contained unexpected query parameters.`, + ); + return requests[0]; +} + +function likeCountParameters(request) { + return new URL(request.url).searchParams.getAll("likeCount"); +} + +function assertExactVotesRequestSequenceAudit(requests, expectedVideoId, { allowLikeCountRefinement = false } = {}) { + assert.ok(Array.isArray(requests), "The /votes request audit is missing."); + const staleRequests = requests.filter((request) => request.videoId !== expectedVideoId); + assert.deepEqual( + staleRequests, + [], + `The navigation emitted a stale /votes request while targeting ${expectedVideoId}.`, + ); + + if (!allowLikeCountRefinement) { + return [assertExactVotesRequestAudit(requests, expectedVideoId)]; + } + + assert.ok( + requests.length === 1 || requests.length === 2, + `The extension navigation must emit one /votes request, or one base request followed by one Like-count refinement, for ${expectedVideoId}; observed ${requests.length}.`, + ); + requests.forEach((request) => assertExactVotesRequestAudit([request], expectedVideoId)); + + const likeCounts = requests.map(likeCountParameters); + for (const parameters of likeCounts) { + assert.ok( + parameters.length === 0 || + (parameters.length === 1 && /^\d+$/.test(parameters[0]) && Number.isSafeInteger(Number(parameters[0]))), + `The /votes request for ${expectedVideoId} has an invalid Like-count refinement.`, + ); + } + if (requests.length === 2) { + assert.equal( + likeCounts[0].length, + 0, + `The first /votes request for ${expectedVideoId} must be the aggregate request before refinement.`, + ); + assert.equal( + likeCounts[1].length, + 1, + `The second /votes request for ${expectedVideoId} must be the single Like-count refinement.`, + ); + } + return requests; +} + +function assertExactVotesResponseAudit( + requests, + expectedVideoId, + baselineRequestId, + { allowLikeCountRefinement = false } = {}, +) { + assert.ok( + Number.isSafeInteger(baselineRequestId) && baselineRequestId >= 0, + "The /votes request baseline must be a non-negative request ID.", + ); + const sequence = assertExactVotesRequestSequenceAudit(requests, expectedVideoId, { + allowLikeCountRefinement, + }); + let previousRequestId = baselineRequestId; + for (const request of sequence) { + assert.ok( + Number.isSafeInteger(request.requestId) && request.requestId > previousRequestId, + `The /votes request for ${expectedVideoId} was not created after request baseline ${previousRequestId}.`, + ); + previousRequestId = request.requestId; + assert.equal( + request.responseStatus, + 200, + `The production /votes request for ${expectedVideoId} returned HTTP ${request.responseStatus ?? "no response"}.`, + ); + assert.ok( + request.responseBody && typeof request.responseBody === "object", + `The production /votes response for ${expectedVideoId} is not JSON.`, + ); + assert.equal( + request.responseBody.id, + expectedVideoId, + `The production /votes response returned video ${request.responseBody.id ?? ""} instead of ${expectedVideoId}.`, + ); + assert.ok( + Number.isSafeInteger(request.responseBody.dislikes) && request.responseBody.dislikes >= 0, + `The production /votes response for ${expectedVideoId} has no valid dislike count.`, + ); + } + return sequence.at(-1); +} + +function isSelectedRuntimeServiceWorkerUrl(workerUrl, runtime, selectedExtensionId) { + if (runtime !== "extension" || !EXTENSION_ID_PATTERN.test(selectedExtensionId ?? "")) return false; + + let url; + try { + url = new URL(workerUrl); + } catch { + return false; + } + return ( + url.protocol === "chrome-extension:" && + url.hostname === selectedExtensionId && + url.pathname === "/ryd.background.js" && + url.search === "" && + url.hash === "" + ); +} + +function parseLiveExtensionAcceptHeader(value) { + if (typeof value !== "string") return null; + const match = value.match(LIVE_ACCEPT_HEADER_PATTERN); + return match ? { buildId: match[2], extensionId: match[1] } : null; +} + +function requestAcceptHeader(request) { + try { + const headers = request.headers(); + if (!headers || typeof headers !== "object") return null; + const entry = Object.entries(headers).find(([name]) => name.toLowerCase() === "accept"); + return typeof entry?.[1] === "string" ? entry[1] : null; + } catch { + return null; + } +} + +function attributeRuntimeRequest(request, page, runtime, selectedExtensionId = null, expectedBuildId = null) { + if (runtime === "extension" && !EXTENSION_ID_PATTERN.test(selectedExtensionId ?? "")) return null; + + try { + if (request.frame().page() !== page) return null; + if (runtime !== "extension") return { source: "page", workerUrl: null }; + if (request.method() !== "GET" || !LIVE_BUILD_ID_PATTERN.test(expectedBuildId ?? "")) return null; + const fingerprint = parseLiveExtensionAcceptHeader(requestAcceptHeader(request)); + if (fingerprint?.extensionId !== selectedExtensionId || fingerprint?.buildId !== expectedBuildId) return null; + return { source: "page", workerUrl: null }; + } catch { + let workerUrl; + try { + workerUrl = request.serviceWorker?.()?.url?.(); + } catch { + return null; + } + if (!isSelectedRuntimeServiceWorkerUrl(workerUrl, runtime, selectedExtensionId)) return null; + return { source: "service-worker", workerUrl }; + } +} + +function requestVideoId(request, url = null) { + let parsedUrl = url; + if (!(parsedUrl instanceof URL)) { + try { + parsedUrl = new URL(request.url()); + } catch { + parsedUrl = null; + } + } + const queryVideoId = parsedUrl?.searchParams.get("videoId"); + if (queryVideoId) return queryVideoId; + const bodyVideoId = readJsonBody(request)?.videoId; + return typeof bodyVideoId === "string" ? bodyVideoId : null; +} + +function snapshotSameVideoOtherPages(context, page, attribution, videoId) { + if (attribution.source !== "service-worker") return []; + if (typeof context?.pages !== "function" || !VIDEO_ID_PATTERN.test(videoId ?? "")) return null; + return context + .pages() + .filter((candidate) => candidate !== page) + .map((candidate) => { + try { + return candidate.url(); + } catch { + return null; + } + }) + .filter((candidateUrl) => candidateUrl && videoIdFromUrl(candidateUrl) === videoId); +} + +function assertWorkerRequestAttributionIsUnambiguous(context, page, requests, expectedVideoId) { + for (const request of requests.filter((candidate) => candidate.source === "service-worker")) { + const requestVideo = request.videoId ?? expectedVideoId; + const matchingOtherPages = Object.hasOwn(request, "sameVideoOtherPagesAtRequest") + ? request.sameVideoOtherPagesAtRequest + : snapshotSameVideoOtherPages(context, page, request, requestVideo); + assert.ok( + Array.isArray(matchingOtherPages), + `Cannot prove which tab initiated the selected runtime's service-worker request for ${requestVideo ?? "an unknown video"}.`, + ); + assert.deepEqual( + matchingOtherPages, + [], + `Cannot attribute the service-worker request for ${requestVideo}: another tab has the same video open at send time.`, + ); + } +} + +function readShortsRendererIdentityState(element, settings = {}) { + const renderedInViewport = (candidate) => { + if (!candidate?.isConnected || candidate.closest("[hidden], [aria-hidden='true'], [inert]")) return false; + + let effectiveOpacity = 1; + for (let current = candidate; current; current = current.parentElement) { + const style = getComputedStyle(current); + if (style.display === "none" || style.visibility === "hidden" || style.visibility === "collapse") return false; + const opacity = Number.parseFloat(style.opacity); + if (Number.isFinite(opacity)) effectiveOpacity *= opacity; + } + if (effectiveOpacity <= 0.01) return false; + + const rect = candidate.getBoundingClientRect(); + return ( + rect.width > 0 && + rect.height > 0 && + rect.bottom > 0 && + rect.right > 0 && + rect.top < innerHeight && + rect.left < innerWidth + ); + }; + const reel = element?.closest?.("ytd-reel-video-renderer, ytm-reel-video-renderer") ?? null; + const candidateVideoIds = new Set(); + const rendererVideoId = reel?.getAttribute("video-id"); + if (rendererVideoId) candidateVideoIds.add(rendererVideoId); + + const links = [...(reel?.querySelectorAll("a[href*='/shorts/']") ?? [])]; + const canonicalPlayerLinks = links.filter((link) => link.matches("a.ytp-title-link[href*='/shorts/']")); + // Match the production readiness helper: when YouTube exposes its hidden + // player-title permalink, description/metapanel Shorts links are not reel + // identities. A renderer attribute is unioned with that canonical identity + // so stale/conflicting metadata remains ambiguous instead of being trusted. + 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, settings.baseUrl ?? location.href).pathname.match(/^\/shorts\/([^/]+)\/?$/)?.[1]; + if (videoId) candidateVideoIds.add(videoId); + } catch { + // Ignore malformed or incomplete links while YouTube hydrates the reel. + } + } + + const identities = [...candidateVideoIds]; + return { + candidateVideoIds: identities, + elementRenderedInViewport: renderedInViewport(element), + hasReel: reel !== null, + reelRenderedInViewport: renderedInViewport(reel), + rendererVideoId, + videoMatches: + identities.length === 1 && + Boolean(settings.expectedShortVideoId) && + identities[0] === settings.expectedShortVideoId, + }; +} + +function isShortCandidateEligibilityState(state, settings) { + if (!state?.elementRenderedInViewport) return false; if (!settings.activeShortRequired) return true; - if (!reel || !settings.expectedShortVideoId) return false; + return Boolean(settings.expectedShortVideoId && state.hasReel && state.reelRenderedInViewport && state.videoMatches); +} - const reelRect = reel.getBoundingClientRect(); - const reelIntersectsViewport = - reelRect.width > 0 && - reelRect.height > 0 && - reelRect.bottom > 0 && - reelRect.right > 0 && - reelRect.top < innerHeight && - reelRect.left < innerWidth; - const expectedPath = `/shorts/${settings.expectedShortVideoId}`; - const rendererVideoId = reel.getAttribute("video-id"); - const matchesVideo = rendererVideoId - ? rendererVideoId === settings.expectedShortVideoId - : [...reel.querySelectorAll('a[href*="/shorts/"]')].some((link) => { - try { - return new URL(link.getAttribute("href"), location.origin).pathname === expectedPath; - } catch { - return false; - } - }); - return reelIntersectsViewport && matchesVideo; +function isShortCandidateEligible(element, settings) { + return isShortCandidateEligibilityState( + readShortsRendererIdentityState(element, { + baseUrl: typeof location === "undefined" ? "https://www.youtube.com/" : location.href, + expectedShortVideoId: settings.expectedShortVideoId, + }), + settings, + ); } function readDislikeControlText(button) { @@ -111,8 +521,400 @@ function readDislikeControlText(button) { return (textSource.innerText ?? textSource.textContent ?? "").replace(/\s+/g, " ").trim(); } +function readWatchTopRowTopology(topRow, expectedVideoId) { + const readBox = (element) => { + if (!element) return null; + const rect = element.getBoundingClientRect(); + return { height: rect.height, width: rect.width, x: rect.x, y: rect.y }; + }; + const rendered = (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; + }; + const normalizeText = (value) => (value ?? "").normalize("NFKC").replace(/\s+/g, " ").trim(); + const signature = (host) => { + const textSelectors = [ + "[role='text']", + ".yt-spec-button-shape-next__button-text-content", + ".ytSpecButtonShapeNextButtonTextContent", + ".yt-core-attributed-string", + "yt-formatted-string", + ]; + for (const selector of textSelectors) { + for (const textNode of host.querySelectorAll(selector)) { + if (!rendered(textNode)) continue; + const value = normalizeText(textNode.innerText ?? textNode.textContent); + if (value) return value; + } + } + const interactive = [...host.querySelectorAll("button, a[href], [role='button']")].find(rendered) ?? host; + return ( + [interactive, host] + .flatMap((element) => [ + element.getAttribute?.("aria-label"), + element.getAttribute?.("title"), + element.innerText, + element.textContent, + ]) + .map(normalizeText) + .find(Boolean) ?? "" + ); + }; + const buttonsFor = (selector, scope) => + [...scope.querySelectorAll(selector)].filter((button) => rendered(button) && !button.disabled); + const directChildOf = (element, parent) => { + let current = element; + while (current && current.parentElement !== parent) current = current.parentElement; + return current?.parentElement === parent ? current : null; + }; + + const watchRoot = topRow.closest("ytd-watch-flexy, ytd-watch-grid"); + const visibleMenus = [...topRow.querySelectorAll("ytd-menu-renderer.ytd-watch-metadata")].filter(rendered); + const menu = visibleMenus[0] ?? null; + const visibleSurfaces = menu ? [...menu.querySelectorAll("#top-level-buttons-computed")].filter(rendered) : []; + const surface = visibleSurfaces[0] ?? null; + const likeButtons = surface + ? buttonsFor( + [ + "like-button-view-model button", + "#segmented-like-button button", + "button#segmented-like-button", + "#like-button button", + "ytd-like-button-renderer button", + ].join(", "), + surface, + ) + : []; + const dislikeButtons = surface + ? buttonsFor( + [ + "dislike-button-view-model button", + "#segmented-dislike-button button", + "button#segmented-dislike-button", + "#dislike-button button", + "ytd-dislike-button-renderer button", + ].join(", "), + surface, + ) + : []; + const likeHost = surface && likeButtons[0] ? directChildOf(likeButtons[0], surface) : null; + const dislikeHost = surface && dislikeButtons[0] ? directChildOf(dislikeButtons[0], surface) : null; + const reactionGroup = likeHost && likeHost === dislikeHost ? likeHost : null; + const fixedActionHosts = surface + ? [...surface.children].filter( + (child) => + child !== reactionGroup && + !child.matches(".ryd-tooltip, [data-ryd-ratebar-wrapper], [data-ryd-rate-bar-wrapper]") && + rendered(child) && + [...child.querySelectorAll("button, a[href], [role='button']")].some(rendered), + ) + : []; + // YouTube's current Watch layout renders width-dependent actions in a + // sibling #flexible-item-buttons container. Restricting discovery to + // #top-level-buttons-computed silently missed Save/Thanks and could make a + // failed resize restoration look green. + const visibleFlexibleSurfaces = menu ? [...menu.querySelectorAll("#flexible-item-buttons")].filter(rendered) : []; + const flexibleActionHosts = visibleFlexibleSurfaces.flatMap((flexibleSurface) => + [...flexibleSurface.children].filter( + (child) => rendered(child) && [...child.querySelectorAll("button, a[href], [role='button']")].some(rendered), + ), + ); + const topLevelActionHosts = [...new Set([...fixedActionHosts, ...flexibleActionHosts])]; + const topLevelActions = topLevelActionHosts.map((host) => ({ box: readBox(host), signature: signature(host) })); + const moreButtons = menu + ? [...menu.querySelectorAll("button")].filter( + (button) => + rendered(button) && + !button.disabled && + !surface?.contains(button) && + !button.closest("#flexible-item-buttons") && + button.closest("ytd-menu-renderer") === menu, + ) + : []; + + return { + boxes: { + actionSurface: readBox(surface), + dislikeButton: readBox(dislikeButtons[0]), + likeButton: readBox(likeButtons[0]), + menu: readBox(menu), + moreButton: readBox(moreButtons[0]), + reactionGroup: readBox(reactionGroup), + topLevelActionHosts: topLevelActions.map(({ box }) => box), + topRow: readBox(topRow), + }, + counts: { + dislikeButtons: dislikeButtons.length, + likeButtons: likeButtons.length, + moreButtons: moreButtons.length, + reactionGroups: reactionGroup ? 1 : 0, + visibleMenus: visibleMenus.length, + visibleSurfaces: visibleSurfaces.length, + }, + topLevelOptionalSignatures: topLevelActions.map(({ signature: value }) => value), + videoId: watchRoot?.getAttribute("video-id") ?? null, + videoMatches: watchRoot?.getAttribute("video-id") === expectedVideoId, + }; +} + +function readWatchRatioSurroundings(wrapper) { + const readBox = (element) => { + if (!element) return null; + const rect = element.getBoundingClientRect(); + return { height: rect.height, width: rect.width, x: rect.x, y: rect.y }; + }; + const rendered = (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 && + rect.bottom > 0 && + rect.right > 0 && + rect.top < innerHeight && + rect.left < innerWidth + ); + }; + const directChildOf = (element, parent) => { + let current = element; + while (current && current.parentElement !== parent) current = current.parentElement; + return current?.parentElement === parent ? current : null; + }; + + const surface = wrapper?.parentElement ?? null; + const topRow = wrapper?.closest("#top-row") ?? null; + const likeButton = surface?.querySelector( + "like-button-view-model button, #segmented-like-button button, button#segmented-like-button, #like-button button, ytd-like-button-renderer button", + ); + const dislikeButton = surface?.querySelector( + "dislike-button-view-model button, #segmented-dislike-button button, button#segmented-dislike-button, #dislike-button button, ytd-dislike-button-renderer button", + ); + const likeHost = directChildOf(likeButton, surface); + const dislikeHost = directChildOf(dislikeButton, surface); + const reactionGroup = likeHost && likeHost === dislikeHost ? likeHost : null; + const menu = wrapper?.closest("ytd-menu-renderer.ytd-watch-metadata") ?? null; + const interactiveCandidates = menu + ? [...menu.querySelectorAll("button, a[href], [role='button']")].filter(rendered) + : []; + const leafInteractiveCandidates = interactiveCandidates.filter( + (candidate) => + !interactiveCandidates.some((descendant) => descendant !== candidate && candidate.contains(descendant)), + ); + const nearbyActions = leafInteractiveCandidates.filter( + (candidate) => + candidate !== likeButton && + candidate !== dislikeButton && + !reactionGroup?.contains(candidate) && + !wrapper?.contains(candidate), + ); + + return { + hitArea: readBox(wrapper?.querySelector(".ryd-tooltip-bar-container")), + nearbyActions: nearbyActions.map(readBox), + topRow: readBox(topRow), + }; +} + +function readWatchRatioAppearance(wrapper, selectors) { + const container = wrapper?.querySelector(selectors.container) ?? null; + const bar = wrapper?.querySelector(selectors.bar) ?? null; + const tooltipHost = wrapper?.querySelector(selectors.tooltip) ?? null; + const tooltipCandidates = [ + tooltipHost?.shadowRoot?.querySelector("#tooltip"), + tooltipHost?.querySelector?.("#tooltip"), + tooltipHost, + ]; + const tooltip = tooltipCandidates.find((candidate) => candidate?.textContent?.trim()) ?? tooltipHost; + const containerStyle = container ? getComputedStyle(container) : null; + const effectiveOpacity = (() => { + let opacity = 1; + for (let current = container; current; current = current.parentElement) { + const value = Number.parseFloat(getComputedStyle(current).opacity); + if (Number.isFinite(value)) opacity *= value; + } + return opacity; + })(); + const isOpaqueColor = (value) => { + const color = String(value ?? "") + .trim() + .toLowerCase(); + if (!color || color === "transparent") return false; + const rgb = color.match(/^rgba?\((.*)\)$/u)?.[1]; + if (rgb) { + const commaParts = rgb.includes(",") ? rgb.split(",").map((part) => part.trim()) : null; + if (commaParts?.length === 4) return Number.parseFloat(commaParts[3]) >= 0.99; + const alpha = rgb.match(/\/\s*([\d.]+%?)\s*$/u)?.[1]; + if (alpha) return alpha.endsWith("%") ? Number.parseFloat(alpha) >= 99 : Number.parseFloat(alpha) >= 0.99; + return true; + } + const modernAlpha = color.match(/\/\s*([\d.]+%?)\s*\)$/u)?.[1]; + if (modernAlpha) { + return modernAlpha.endsWith("%") ? Number.parseFloat(modernAlpha) >= 99 : Number.parseFloat(modernAlpha) >= 0.99; + } + return true; + }; + + const containerBox = container?.getBoundingClientRect(); + const barBox = bar?.getBoundingClientRect(); + const probeX = containerBox + ? Math.min(containerBox.right - 0.5, Math.max(containerBox.left + 0.5, (barBox?.right ?? containerBox.left) + 0.5)) + : 0; + const probeY = containerBox ? containerBox.top + containerBox.height / 2 : 0; + const stackedElements = + containerBox && typeof document.elementsFromPoint === "function" ? document.elementsFromPoint(probeX, probeY) : []; + const candidates = [ + ...stackedElements.filter((element) => !wrapper?.contains(element)), + ...(() => { + const ancestors = []; + for (let current = wrapper?.parentElement; current; current = current.parentElement) ancestors.push(current); + return ancestors; + })(), + document.body, + document.documentElement, + ].filter(Boolean); + let pageBackgroundColor = null; + let pageBackgroundSource = null; + for (const candidate of [...new Set(candidates)]) { + const color = getComputedStyle(candidate).backgroundColor; + if (!isOpaqueColor(color)) continue; + pageBackgroundColor = color; + pageBackgroundSource = candidate.tagName?.toLowerCase() ?? "unknown"; + break; + } + if (!pageBackgroundColor) { + const rootStyle = getComputedStyle(document.documentElement); + const fallback = rootStyle.getPropertyValue("--yt-spec-base-background").trim(); + if (isOpaqueColor(fallback)) { + pageBackgroundColor = fallback; + pageBackgroundSource = "--yt-spec-base-background"; + } + } + + return { + inlineFillWidth: bar?.style.width ?? "", + negativeTrackBackgroundImage: containerStyle?.backgroundImage ?? "none", + negativeTrackColor: containerStyle?.backgroundColor ?? "", + negativeTrackOpacity: effectiveOpacity, + numberLocale: Intl.NumberFormat().resolvedOptions().locale || navigator.language || "en", + pageBackgroundColor, + pageBackgroundSource, + pageTheme: + document.documentElement.hasAttribute("dark") || document.documentElement.getAttribute("data-theme") === "dark" + ? "dark" + : "light", + tooltipText: tooltip?.textContent ?? "", + wrapperVideoId: wrapper?.getAttribute("data-ryd-video-id") ?? null, + }; +} + +function isWatchMoreButton(button) { + const menu = button.closest("ytd-menu-renderer.ytd-watch-metadata"); + const surface = menu?.querySelector("#top-level-buttons-computed"); + if ( + !menu || + surface?.contains(button) || + button.closest("#flexible-item-buttons") || + button.closest("ytd-menu-renderer") !== menu || + button.disabled + ) + return false; + const rect = button.getBoundingClientRect(); + const style = getComputedStyle(button); + return rect.width > 0 && rect.height > 0 && style.display !== "none" && style.visibility !== "hidden"; +} + +function readOverflowMenuTopology(popup) { + const readBox = (element) => { + const rect = element.getBoundingClientRect(); + return { height: rect.height, width: rect.width, x: rect.x, y: rect.y }; + }; + const rendered = (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; + }; + const normalizeText = (value) => (value ?? "").normalize("NFKC").replace(/\s+/g, " ").trim(); + const signature = (host) => { + const text = normalizeText(host.innerText ?? host.textContent); + if (text) return text; + const interactive = host.matches("button, a[href], [role='menuitem']") + ? host + : host.querySelector("button, a[href], [role='menuitem']"); + return normalizeText( + interactive?.getAttribute("aria-label") ?? host.getAttribute("aria-label") ?? interactive?.getAttribute("title"), + ); + }; + const itemSelector = [ + "ytd-menu-service-item-renderer", + "ytd-menu-navigation-item-renderer", + "yt-list-item-view-model", + "[role='menuitem']", + ].join(", "); + const candidates = [...popup.querySelectorAll(itemSelector)].filter(rendered); + const itemHosts = candidates.filter( + (candidate) => + !candidates.some((possibleParent) => possibleParent !== candidate && possibleParent.contains(candidate)), + ); + return { + box: readBox(popup), + itemBoxes: itemHosts.map(readBox), + signatures: itemHosts.map(signature), + }; +} + +function readRenderedElementIndexes(elements) { + const rendered = (element) => { + if (!element?.isConnected || element.closest("[hidden], [aria-hidden='true'], [inert]")) return false; + let effectiveOpacity = 1; + for (let current = element; current; current = current.parentElement) { + const style = getComputedStyle(current); + if (style.display === "none" || style.visibility === "hidden" || style.visibility === "collapse") return false; + const opacity = Number.parseFloat(style.opacity); + if (Number.isFinite(opacity)) effectiveOpacity *= opacity; + } + const rect = element.getBoundingClientRect(); + return effectiveOpacity > 0.01 && rect.width > 0 && rect.height > 0; + }; + return elements.flatMap((element, index) => (rendered(element) ? [index] : [])); +} + function readShortsIconVisualState(element) { const svg = element.querySelector("svg"); + const svgBox = svg?.getBoundingClientRect() ?? null; const paintedGraphicCount = svg ? [...svg.querySelectorAll("path, circle, ellipse, line, polygon, polyline, rect")].filter((graphic) => { if (graphic.tagName.toLowerCase() !== "path") return true; @@ -133,19 +935,314 @@ function readShortsIconVisualState(element) { effectiveOpacity, paintedGraphicCount, rendered, + svgHeight: svgBox?.height ?? 0, svgPresent: svg !== null, + svgWidth: svgBox?.width ?? 0, }; } function isShortsIconVisualReady(state) { return ( state?.svgPresent === true && + state.svgWidth > 0 && + state.svgHeight > 0 && state.paintedGraphicCount > 0 && state.rendered === true && state.effectiveOpacity > 0.01 ); } +function readCurrentShortsNativeControlState(settings) { + const { actionBarSelector, minimumActionControlCount = 4, syntheticSelector, videoId } = settings; + const renderedInViewport = (element) => { + if (!element?.isConnected || element.closest("[hidden], [aria-hidden='true'], [inert]")) return false; + + let effectiveOpacity = 1; + for (let current = element; current; current = current.parentElement) { + const style = getComputedStyle(current); + if (style.display === "none" || style.visibility === "hidden" || style.visibility === "collapse") return false; + const opacity = Number.parseFloat(style.opacity); + if (Number.isFinite(opacity)) effectiveOpacity *= opacity; + } + if (effectiveOpacity <= 0.01) return false; + + const rect = element.getBoundingClientRect(); + return ( + rect.width > 0 && + rect.height > 0 && + rect.bottom > 0 && + rect.right > 0 && + rect.top < innerHeight && + rect.left < innerWidth + ); + }; + const rendererIdentityVideoIds = (renderer) => { + const identities = new Set(); + const rendererVideoId = renderer.getAttribute("video-id"); + if (rendererVideoId) identities.add(rendererVideoId); + const links = [...renderer.querySelectorAll("a[href*='/shorts/']")]; + const canonicalPlayerLinks = links.filter((link) => link.matches("a.ytp-title-link[href*='/shorts/']")); + const identityLinks = canonicalPlayerLinks.length > 0 ? canonicalPlayerLinks : links; + for (const link of identityLinks) { + try { + const href = link.getAttribute("href"); + if (!href) continue; + const identity = new URL(href, location.href).pathname.match(/^\/shorts\/([^/]+)\/?$/)?.[1]; + if (identity) identities.add(identity); + } catch { + // Ignore malformed or incomplete links while YouTube hydrates the reel. + } + } + return [...identities]; + }; + const rendererMatchesVideo = (renderer) => { + const identities = rendererIdentityVideoIds(renderer); + return identities.length === 1 && identities[0] === videoId; + }; + const activationSelector = "button, a[href], a[role='button'][tabindex='0'], tp-yt-paper-button#button"; + const fallbackActionHostSelector = + "like-button-view-model, dislike-button-view-model, button-view-model, pivot-button-view-model, yt-button-view-model"; + const likeActionSelector = "like-button-view-model, #like-button, ytd-like-button-renderer, ytm-like-button-renderer"; + const renderedReels = [...document.querySelectorAll("ytd-reel-video-renderer, ytm-reel-video-renderer")].filter( + renderedInViewport, + ); + const matchingReels = renderedReels.filter(rendererMatchesVideo); + const viewport = { height: innerHeight, width: innerWidth }; + const visibleVideoBox = (video) => { + if (!renderedInViewport(video)) return null; + const rect = video.getBoundingClientRect(); + const left = Math.max(0, rect.left); + const top = Math.max(0, rect.top); + const right = Math.min(viewport.width, rect.right); + const bottom = Math.min(viewport.height, rect.bottom); + if (right <= left || bottom <= top) return null; + return { height: bottom - top, width: right - left, x: left, y: top }; + }; + let currentVideoBox = null; + let currentVideoVisibleArea = 0; + for (const reel of matchingReels) { + for (const video of reel.querySelectorAll("video")) { + const candidateBox = visibleVideoBox(video); + const candidateArea = candidateBox ? candidateBox.width * candidateBox.height : 0; + if (candidateArea > currentVideoVisibleArea) { + currentVideoBox = candidateBox; + currentVideoVisibleArea = candidateArea; + } + } + } + const nativeControls = new Set(); + const matchingNativeControls = new Set(); + const independentActions = new Set(); + const matchingIndependentActions = new Set(); + let completeNativeActionBars = 0; + let matchingCompleteNativeActionBars = 0; + let completeIndependentActionBars = 0; + let matchingCompleteIndependentActionBars = 0; + const labels = []; + const independentActionInventory = []; + + for (const reel of renderedReels) { + const belongsToCurrentVideo = matchingReels.includes(reel); + for (const actionBar of reel.querySelectorAll(actionBarSelector)) { + const nativeHosts = [...actionBar.children].filter( + (host) => !host.matches(syntheticSelector) && !host.querySelector(syntheticSelector), + ); + const visibleControls = []; + let hasVisibleReaction = false; + let allControlsEnabled = true; + for (const host of nativeHosts) { + const control = host.matches(activationSelector) ? host : host.querySelector(activationSelector); + if (!control || !renderedInViewport(control)) { + allControlsEnabled = false; + continue; + } + visibleControls.push(control); + if (control.disabled || control.getAttribute("aria-disabled") === "true") allControlsEnabled = false; + if ( + host.matches(likeActionSelector) || + host.querySelector(likeActionSelector) || + control.matches(likeActionSelector) || + control.closest(likeActionSelector) || + control.hasAttribute("aria-pressed") + ) { + hasVisibleReaction = true; + } + if (!nativeControls.has(control)) { + nativeControls.add(control); + if (belongsToCurrentVideo) matchingNativeControls.add(control); + labels.push(control.getAttribute("aria-label") ?? control.textContent?.replace(/\s+/g, " ").trim() ?? ""); + } + } + if ( + nativeHosts.length >= minimumActionControlCount && + visibleControls.length === nativeHosts.length && + hasVisibleReaction && + allControlsEnabled + ) { + completeNativeActionBars += 1; + if (belongsToCurrentVideo) matchingCompleteNativeActionBars += 1; + } + } + + // Keep a selector-drift fallback, but anchor it to a structural Like host and + // its sibling action hosts. Scanning every button in the reel also sees the + // player's Pause, Mute, and More chrome and can turn a blank rail into a + // false positive. + const fallbackSurfaces = new Set(); + for (const likeAction of reel.querySelectorAll(likeActionSelector)) { + if (likeAction.closest(actionBarSelector) || likeAction.closest(syntheticSelector)) continue; + const likeHost = likeAction.closest(fallbackActionHostSelector) ?? likeAction; + if (likeHost.parentElement && reel.contains(likeHost.parentElement)) fallbackSurfaces.add(likeHost.parentElement); + } + for (const actionSurface of fallbackSurfaces) { + const actionHosts = [...actionSurface.children].filter( + (host) => + !host.matches(syntheticSelector) && + !host.querySelector(syntheticSelector) && + (host.matches(fallbackActionHostSelector) || host.querySelector(fallbackActionHostSelector)), + ); + const visibleControls = []; + let allControlsEnabled = true; + for (const host of actionHosts) { + const control = host.matches(activationSelector) ? host : host.querySelector(activationSelector); + if (!control || !renderedInViewport(control)) { + allControlsEnabled = false; + continue; + } + visibleControls.push(control); + if (control.disabled || control.getAttribute("aria-disabled") === "true") allControlsEnabled = false; + if (independentActions.has(control)) continue; + independentActions.add(control); + if (belongsToCurrentVideo) matchingIndependentActions.add(control); + const rect = control.getBoundingClientRect(); + independentActionInventory.push({ + ariaLabel: control.getAttribute("aria-label"), + className: typeof control.className === "string" ? control.className : "", + id: control.id || null, + owningActionBar: control.closest(actionBarSelector)?.tagName?.toLowerCase() ?? null, + rect: { height: rect.height, width: rect.width, x: rect.x, y: rect.y }, + tagName: control.tagName.toLowerCase(), + text: (control.innerText ?? control.textContent ?? "").replace(/\s+/g, " ").trim().slice(0, 160), + }); + } + if ( + actionHosts.length >= minimumActionControlCount && + visibleControls.length === actionHosts.length && + allControlsEnabled + ) { + completeIndependentActionBars += 1; + if (belongsToCurrentVideo) matchingCompleteIndependentActionBars += 1; + } + } + } + + return { + completeIndependentActionBars, + completeNativeActionBars, + currentVideoId: videoId, + currentVideoBox, + independentActionInventory, + independentActionLabels: independentActionInventory.map( + (control) => control.ariaLabel ?? control.text ?? `${control.tagName}${control.id ? `#${control.id}` : ""}`, + ), + labels, + matchingCompleteIndependentActionBars, + matchingCompleteNativeActionBars, + matchingVisibleIndependentActions: matchingIndependentActions.size, + matchingRenderedReels: matchingReels.length, + matchingVisibleNativeControls: matchingNativeControls.size, + rendererIdentities: renderedReels.map((renderer) => rendererIdentityVideoIds(renderer)), + renderedReels: renderedReels.length, + visibleIndependentActions: independentActions.size, + visibleNativeControls: nativeControls.size, + viewport, + }; +} + +function readShortsReelDiagnosticState(reel, settings) { + const readBox = (element) => { + if (!element) return null; + const rect = element.getBoundingClientRect(); + return { + bottom: rect.bottom, + height: rect.height, + left: rect.left, + right: rect.right, + top: rect.top, + width: rect.width, + }; + }; + const renderedInViewport = (element) => { + if (!element?.isConnected || element.closest("[hidden], [aria-hidden='true'], [inert]")) return false; + let effectiveOpacity = 1; + for (let current = element; current; current = current.parentElement) { + const style = getComputedStyle(current); + if (style.display === "none" || style.visibility === "hidden" || style.visibility === "collapse") return false; + const opacity = Number.parseFloat(style.opacity); + if (Number.isFinite(opacity)) effectiveOpacity *= opacity; + } + if (effectiveOpacity <= 0.01) return false; + const rect = element.getBoundingClientRect(); + return ( + rect.width > 0 && + rect.height > 0 && + rect.bottom > 0 && + rect.right > 0 && + rect.top < innerHeight && + rect.left < innerWidth + ); + }; + const describe = (element) => { + const ancestors = []; + for ( + let current = element.parentElement; + current && current !== reel && ancestors.length < 5; + current = current.parentElement + ) { + ancestors.push({ + className: typeof current.className === "string" ? current.className : "", + id: current.id || null, + tagName: current.tagName.toLowerCase(), + }); + } + return { + ancestors, + ariaDisabled: element.getAttribute("aria-disabled"), + ariaLabel: element.getAttribute("aria-label"), + box: readBox(element), + className: typeof element.className === "string" ? element.className : "", + disabled: Boolean(element.disabled), + id: element.id || null, + renderedInViewport: renderedInViewport(element), + role: element.getAttribute("role"), + synthetic: element.closest(settings.syntheticSelector) !== null, + tagName: element.tagName.toLowerCase(), + text: (element.innerText ?? element.textContent ?? "").replace(/\s+/g, " ").trim().slice(0, 160), + }; + }; + const interactiveSelector = + "button, [role='button'], input[type='button'], input[type='submit'], tp-yt-paper-button#button"; + const interactiveElements = [...reel.querySelectorAll(interactiveSelector)]; + const knownActionBars = [...reel.querySelectorAll(settings.actionBarSelector)]; + + return { + actionBars: knownActionBars.map((actionBar) => ({ + box: readBox(actionBar), + childCount: actionBar.children.length, + className: typeof actionBar.className === "string" ? actionBar.className : "", + id: actionBar.id || null, + renderedInViewport: renderedInViewport(actionBar), + tagName: actionBar.tagName.toLowerCase(), + visibleInteractiveCount: [...actionBar.querySelectorAll(interactiveSelector)].filter(renderedInViewport).length, + })), + box: readBox(reel), + renderedInViewport: renderedInViewport(reel), + tagName: reel.tagName.toLowerCase(), + totalInteractiveCount: interactiveElements.length, + visibleActions: interactiveElements.filter(renderedInViewport).map(describe), + }; +} + async function waitForValue(readValue, predicate, message, timeout = 20_000) { const deadline = Date.now() + timeout; let lastValue; @@ -160,7 +1257,13 @@ async function waitForValue(readValue, predicate, message, timeout = 20_000) { async function firstVisible( locator, label, - { expectedShortVideoId = null, requireActiveShort = false, requireViewport = false, timeout = 20_000 } = {}, + { + expectedShortVideoId = null, + requireActiveShort = false, + requireEnabled = false, + requireViewport = false, + timeout = 20_000, + } = {}, ) { return waitForValue( async () => { @@ -168,12 +1271,16 @@ async function firstVisible( for (let index = 0; index < count; index += 1) { const candidate = locator.nth(index); if (!(await candidate.isVisible())) continue; + if (requireEnabled && !(await candidate.isEnabled())) continue; if (requireActiveShort || requireViewport) { - const isEligible = await candidate.evaluate(isShortCandidateEligible, { - activeShortRequired: requireActiveShort, - expectedShortVideoId, - }); - if (!isEligible) continue; + const eligibilityState = await candidate.evaluate(readShortsRendererIdentityState, { expectedShortVideoId }); + if ( + !isShortCandidateEligibilityState(eligibilityState, { + activeShortRequired: requireActiveShort, + expectedShortVideoId, + }) + ) + continue; } return candidate; } @@ -194,6 +1301,38 @@ async function visibleLocatorIndexes(locator) { return indexes; } +async function currentWatchMoreButtons(menu) { + const buttons = menu.locator("button"); + const matches = []; + for (let index = 0; index < (await buttons.count()); index += 1) { + const candidate = buttons.nth(index); + if (!(await candidate.isVisible()) || !(await candidate.isEnabled())) continue; + if (await candidate.evaluate(isWatchMoreButton)) matches.push(candidate); + } + return matches; +} + +async function visibleWatchOverflowMenu(page) { + const menus = page.locator(WATCH_OVERFLOW_POPUP_SELECTOR); + const visibleIndexes = await menus.evaluateAll(readRenderedElementIndexes); + return visibleIndexes.length > 0 + ? { + locator: menus.nth(visibleIndexes[0]), + selector: WATCH_OVERFLOW_POPUP_SELECTOR, + visibleCount: visibleIndexes.length, + } + : null; +} + +async function firstVisibleWatchOverflowMenu(page, timeout = 20_000) { + return waitForValue( + () => visibleWatchOverflowMenu(page), + Boolean, + "Timed out waiting for the visible Watch More-actions menu", + timeout, + ); +} + function relatedWatchVideoId(element, settings) { try { const url = new URL(element.getAttribute("href"), settings.origin); @@ -208,7 +1347,7 @@ function relatedWatchVideoId(element, settings) { } async function firstVisibleRelatedWatchLink(page, currentVideoId, excludedVideoIds, timeout = 30_000) { - const locator = page.locator('#related a[href*="/watch"]'); + const locator = page.locator(RELATED_WATCH_LINK_SELECTOR); const origin = new URL(page.url()).origin; return waitForValue( async () => { @@ -231,27 +1370,91 @@ async function firstVisibleRelatedWatchLink(page, currentVideoId, excludedVideoI ); } +async function firstActionableUnvisitedWatchLink( + page, + selector, + currentVideoId, + excludedVideoIds, + label, + timeout = 30_000, +) { + const locator = page.locator(selector); + const origin = new URL(page.url()).origin; + let lastFailure = null; + const state = await waitForValue( + async () => { + const count = await locator.count(); + for (let index = 0; index < count; index += 1) { + const candidate = locator.nth(index); + try { + if (!(await candidate.isVisible())) continue; + const videoId = await candidate.evaluate(relatedWatchVideoId, { + currentVideoId, + excludedVideoIds, + origin, + }); + if (!videoId) continue; + const actionability = await waitForSettledNavigationLinkActionability(candidate, { + expectedKind: "watch", + expectedVideoId: videoId, + label, + origin, + timeout: 5_000, + }); + return { lastFailure, result: { actionability, link: candidate, videoId } }; + } catch (error) { + lastFailure = error; + } + } + return { lastFailure: lastFailure?.message ?? null, result: null }; + }, + (candidateState) => Boolean(candidateState.result), + `Timed out waiting for an actionable unvisited ${label}`, + timeout, + ); + return state.result; +} + +function isExactVideoLinkTarget(element, target) { + try { + const url = new URL(element.getAttribute("href"), target.origin); + if (url.origin !== target.origin) return false; + if (target.kind === "short") return url.pathname === `/shorts/${target.videoId}`; + return url.pathname === "/watch" && url.searchParams.get("v") === target.videoId; + } catch { + return false; + } +} + +function channelTabUrl(channelUrl, kind) { + assert.ok(kind === "short" || kind === "watch", `Unsupported channel-navigation kind: ${kind}`); + const url = new URL(channelUrl); + const channelPath = url.pathname.replace(/\/$/, "").replace(/\/(?:featured|shorts|videos)$/, ""); + url.pathname = `${channelPath}/${kind === "short" ? "shorts" : "videos"}`; + return url; +} + +function isExactVideoUrl(value, { kind, origin, videoId }) { + try { + const url = value instanceof URL ? value : new URL(value); + if (url.origin !== origin) return false; + if (kind === "short") return url.pathname === `/shorts/${videoId}`; + return url.pathname === "/watch" && url.searchParams.get("v") === videoId; + } catch { + return false; + } +} + async function firstVisibleExactVideoLink(page, videoId, kind, timeout = 30_000) { const locator = page.locator(kind === "short" ? 'a[href*="/shorts/"]' : 'a[href*="/watch"]'); + const origin = new URL(page.url()).origin; return waitForValue( async () => { const count = await locator.count(); for (let index = 0; index < count; index += 1) { const candidate = locator.nth(index); if (!(await candidate.isVisible())) continue; - const isExactTarget = await candidate.evaluate( - (element, target) => { - try { - const url = new URL(element.getAttribute("href"), location.origin); - if (url.origin !== location.origin) return false; - if (target.kind === "short") return url.pathname === `/shorts/${target.videoId}`; - return url.pathname === "/watch" && url.searchParams.get("v") === target.videoId; - } catch { - return false; - } - }, - { kind, videoId }, - ); + const isExactTarget = await candidate.evaluate(isExactVideoLinkTarget, { kind, origin, videoId }); if (isExactTarget) return candidate; } return null; @@ -262,11 +1465,311 @@ async function firstVisibleExactVideoLink(page, videoId, kind, timeout = 30_000) ); } +async function clickStabilizedExactVideoLink( + page, + videoId, + kind, + { + activation = "pointer", + label = `exact ${kind} link for ${videoId}`, + origin = new URL(page.url()).origin, + pollInterval = CHANNEL_TARGET_RETRY_INTERVAL_MS, + selector = kind === "short" ? 'a[href*="/shorts/"]' : 'a[href*="/watch"]', + timeout = CHANNEL_TARGET_ACTIVATION_TIMEOUT, + } = {}, +) { + assert.ok(kind === "short" || kind === "watch", `Unsupported exact-link navigation kind: ${kind}`); + assert.ok(VIDEO_ID_PATTERN.test(videoId), `Invalid exact-link navigation video ID: ${videoId}`); + assert.ok( + activation === "pointer" || activation === "trusted-pointer" || activation === "keyboard", + `Unsupported exact-link activation method: ${activation}`, + ); + + const locator = page.locator(selector); + const deadline = Date.now() + timeout; + let exactCandidateCount = 0; + let lastFailure = null; + + do { + let count; + try { + count = await locator.count(); + } catch (error) { + lastFailure = error; + count = 0; + } + + for (let index = 0; index < count; index += 1) { + const candidate = locator.nth(index); + try { + if (!(await candidate.isVisible())) continue; + if (!(await candidate.evaluate(isExactVideoLinkTarget, { kind, origin, videoId }))) continue; + exactCandidateCount += 1; + const activationOptions = { + expectedKind: kind, + expectedVideoId: videoId, + label, + origin, + timeout: Math.max(1, Math.min(5_000, deadline - Date.now())), + }; + if (activation === "pointer") { + return await clickHitTestedNavigationLink(candidate, activationOptions); + } + + let actionability = await waitForSettledNavigationLinkActionability(candidate, activationOptions); + if (activation === "trusted-pointer") { + await candidate.click({ timeout: activationOptions.timeout }); + return actionability; + } + + await candidate.focus({ timeout: activationOptions.timeout }); + actionability = await candidate.evaluate( + readElementActionability, + { includeHref: true, scroll: false }, + { timeout: activationOptions.timeout }, + ); + assertNavigationLinkTarget(actionability, { + expectedKind: kind, + expectedVideoId: videoId, + label, + origin, + }); + assertNavigationLinkActionable(actionability, label); + await candidate.press("Enter", { timeout: activationOptions.timeout }); + return actionability; + } catch (error) { + // Channel shelves can replace an exact-target anchor or briefly mark + // it hidden while their carousel/hydration settles. Re-resolve every + // exact candidate, but never click one that fails the strict center + // hit test immediately before activation. + lastFailure = error; + } + } + + const remaining = deadline - Date.now(); + if (remaining > 0) await delay(Math.min(pollInterval, remaining)); + } while (Date.now() < deadline); + + const lastFailureMessage = lastFailure ? ` Last failure: ${lastFailure.message}` : ""; + throw new Error( + `Timed out waiting for an actionable exact ${kind} link for ${videoId}; observed ${exactCandidateCount} exact candidate checks.${lastFailureMessage}`, + lastFailure ? { cause: lastFailure } : undefined, + ); +} + +function readElementActionability(element, { includeHref = false, scroll = true } = {}) { + if (scroll) element.scrollIntoView?.({ block: "center", inline: "nearest" }); + + const ownerDocument = element.ownerDocument; + const view = ownerDocument.defaultView ?? window; + const rect = element.getBoundingClientRect(); + const viewportHeight = view.innerHeight || ownerDocument.documentElement.clientHeight; + const viewportWidth = view.innerWidth || ownerDocument.documentElement.clientWidth; + const describeElement = (candidate) => + candidate + ? { + ariaHidden: candidate.getAttribute("aria-hidden"), + className: typeof candidate.className === "string" ? candidate.className : "", + hidden: Boolean(candidate.hidden), + id: candidate.id || null, + inert: Boolean(candidate.inert || candidate.hasAttribute("inert")), + tagName: candidate.tagName.toLowerCase(), + } + : null; + const semanticVisibilityBlocker = element.closest("[hidden], [aria-hidden='true'], [inert]"); + let computedVisibilityBlocker = null; + let visible = element.isConnected && !semanticVisibilityBlocker; + let effectiveOpacity = 1; + for (let current = element; visible && current; current = current.parentElement) { + const style = view.getComputedStyle(current); + if (style.display === "none" || style.visibility === "hidden" || style.visibility === "collapse") { + computedVisibilityBlocker = current; + visible = false; + break; + } + const opacity = Number.parseFloat(style.opacity); + if (Number.isFinite(opacity)) effectiveOpacity *= opacity; + } + visible = Boolean(visible && effectiveOpacity > 0.01 && rect.width > 0 && rect.height > 0); + + const center = { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }; + const centerInViewport = center.x >= 0 && center.y >= 0 && center.x < viewportWidth && center.y < viewportHeight; + const enabled = !element.disabled && element.getAttribute("aria-disabled") !== "true"; + const hitTarget = + visible && centerInViewport && typeof ownerDocument.elementFromPoint === "function" + ? ownerDocument.elementFromPoint(center.x, center.y) + : null; + const centerHitTarget = Boolean(hitTarget && (hitTarget === element || element.contains(hitTarget))); + + return { + center, + centerHitTarget, + centerInViewport, + computedVisibilityBlocker: describeElement(computedVisibilityBlocker), + connected: element.isConnected, + effectiveOpacity, + enabled, + href: includeHref ? element.getAttribute("href") : null, + hitTarget: hitTarget + ? { + className: typeof hitTarget.className === "string" ? hitTarget.className : "", + id: hitTarget.id || null, + tagName: hitTarget.tagName.toLowerCase(), + } + : null, + rect: { height: rect.height, width: rect.width, x: rect.x, y: rect.y }, + semanticVisibilityBlocker: describeElement(semanticVisibilityBlocker), + visible, + viewport: { height: viewportHeight, width: viewportWidth }, + }; +} + +function scrollElementIntoViewAndWaitForPaint(element) { + element.scrollIntoView?.({ block: "center", inline: "nearest" }); + const scheduleFrame = element.ownerDocument?.defaultView?.requestAnimationFrame; + if (typeof scheduleFrame !== "function") return Promise.resolve(); + return new Promise((resolve) => scheduleFrame(() => scheduleFrame(resolve))); +} + +async function readSettledElementActionability(element, { includeHref = false, timeout = 5_000 } = {}) { + await element.evaluate(scrollElementIntoViewAndWaitForPaint, undefined, { timeout }); + return element.evaluate(readElementActionability, { includeHref, scroll: false }, { timeout }); +} + +function readNavigationLinkActionability(element) { + return readElementActionability(element, { includeHref: true }); +} + +function assertElementActionable(state, label) { + assert.equal(state?.connected, true, `${label} became disconnected before activation.`); + assert.equal(state?.visible, true, `${label} is not visibly rendered: ${JSON.stringify(state)}.`); + assert.equal(state?.centerInViewport, true, `${label} center is outside the viewport: ${JSON.stringify(state)}.`); + assert.equal(state?.enabled, true, `${label} is disabled.`); + assert.equal( + state?.centerHitTarget, + true, + `${label} center is covered by another element: ${JSON.stringify(state?.hitTarget ?? null)}.`, + ); + return state; +} + +const assertNavigationLinkActionable = assertElementActionable; + +function assertElementReadyForViewportMeasurement(state, label) { + assert.equal(state?.connected, true, `${label} became disconnected before measurement.`); + assert.equal(state?.visible, true, `${label} is not visibly rendered: ${JSON.stringify(state)}.`); + assert.equal(state?.centerInViewport, true, `${label} center is outside the viewport: ${JSON.stringify(state)}.`); + return state; +} + +async function prepareElementForViewportMeasurement(element, label, { timeout = 5_000 } = {}) { + const state = await readSettledElementActionability(element, { timeout }); + return assertElementReadyForViewportMeasurement(state, label); +} + +async function clickHitTestedElement(element, { beforeClick = null, label = "control", timeout = 5_000 } = {}) { + const actionability = await readSettledElementActionability(element, { timeout }); + assertElementActionable(actionability, label); + if (beforeClick) await beforeClick(actionability); + // Skip only Playwright's stability waiter after independently proving that + // the intended control is visible, enabled, in-viewport, and hit-testable. + await element.click({ force: true, timeout }); + return actionability; +} + +async function pressHitTestedElement(element, { beforePress = null, label = "control", timeout = 5_000 } = {}) { + const actionability = await readSettledElementActionability(element, { timeout }); + assertElementActionable(actionability, label); + await element.focus({ timeout }); + const focusedActionability = await element.evaluate(readElementActionability, { scroll: false }, { timeout }); + assertElementActionable(focusedActionability, label); + if (beforePress) await beforePress(focusedActionability); + await element.press("Enter", { timeout }); + return focusedActionability; +} + +function assertNavigationLinkTarget(state, { expectedKind, expectedVideoId, label, origin }) { + let url; + try { + url = new URL(state?.href, origin); + } catch { + assert.fail(`${label} has an invalid target URL: ${state?.href ?? "missing"}.`); + } + assert.equal(url.origin, origin, `${label} unexpectedly targets another origin.`); + if (expectedKind === "short") { + assert.equal(url.pathname, `/shorts/${expectedVideoId}`, `${label} changed to another Shorts target.`); + } else { + assert.equal(url.pathname, "/watch", `${label} changed to a non-Watch target.`); + assert.equal(url.searchParams.get("v"), expectedVideoId, `${label} changed to another Watch video.`); + } + return state; +} + +async function waitForSettledNavigationLinkActionability( + link, + { + expectedKind = null, + expectedVideoId = null, + label = "navigation link", + origin = "https://www.youtube.com", + timeout = 5_000, + } = {}, +) { + await link.evaluate(scrollElementIntoViewAndWaitForPaint, undefined, { timeout }); + const deadline = Date.now() + timeout; + let actionability = null; + do { + const remaining = Math.max(1, deadline - Date.now()); + actionability = await link.evaluate( + readElementActionability, + { includeHref: true, scroll: false }, + { timeout: Math.min(1_000, remaining) }, + ); + if (expectedVideoId !== null) { + assertNavigationLinkTarget(actionability, { expectedKind, expectedVideoId, label, origin }); + } + if ( + !actionability?.connected || + actionability.semanticVisibilityBlocker || + actionability.computedVisibilityBlocker || + !actionability.enabled || + !actionability.centerInViewport + ) { + assertNavigationLinkActionable(actionability, label); + } + if ( + actionability.visible && + actionability.centerInViewport && + actionability.enabled && + actionability.centerHitTarget + ) { + break; + } + if (Date.now() < deadline) await delay(Math.min(50, deadline - Date.now())); + } while (Date.now() < deadline); + + assertNavigationLinkActionable(actionability, label); + return actionability; +} + +async function clickHitTestedNavigationLink(link, options = {}) { + const timeout = options.timeout ?? 5_000; + const actionability = await waitForSettledNavigationLinkActionability(link, options); + // YouTube can keep a visible link in continuous layout motion. `force` skips + // that native stability waiter only after our explicit safety checks pass. + await link.click({ force: true, timeout }); + return actionability; +} + async function clickWithSingleNavigationRetry({ click, + failureMessage = "YouTube did not navigate after the first Shorts Next click or its single retry.", + firstTimeout = SHORTS_NEXT_FIRST_CLICK_TIMEOUT, hasNavigated, reportProgress, + retryProgressEvent = "shorts-next-control.retrying", retryDetails, + retryTimeout = SHORTS_NEXT_RETRY_TIMEOUT, waitForNavigation, }) { const runAttempt = async (attempt, timeout) => { @@ -279,26 +1782,26 @@ async function clickWithSingleNavigationRetry({ }; let firstError; try { - await runAttempt(1, SHORTS_NEXT_FIRST_CLICK_TIMEOUT); + await runAttempt(1, firstTimeout); return { retried: false }; } catch (error) { firstError = error; if (hasNavigated()) return { retried: false }; } - reportProgress("shorts-next-control.retrying", { + reportProgress(retryProgressEvent, { ...retryDetails, firstFailure: String(firstError?.message ?? firstError), - firstTimeoutMs: SHORTS_NEXT_FIRST_CLICK_TIMEOUT, - retryTimeoutMs: SHORTS_NEXT_RETRY_TIMEOUT, + firstTimeoutMs: firstTimeout, + retryTimeoutMs: retryTimeout, }); try { - await runAttempt(2, SHORTS_NEXT_RETRY_TIMEOUT); + await runAttempt(2, retryTimeout); return { retried: true }; } catch (retryError) { if (hasNavigated()) return { retried: true }; throw new Error( - `YouTube did not navigate after the first Shorts Next click or its single retry. First failure: ${String(firstError?.message ?? firstError)}. Retry failure: ${String(retryError?.message ?? retryError)}`, + `${failureMessage} First failure: ${String(firstError?.message ?? firstError)}. Retry failure: ${String(retryError?.message ?? retryError)}`, { cause: retryError }, ); } @@ -333,8 +1836,13 @@ function assertLogicalVoteHandshake(records, videoId, value) { const votes = records.slice(0, voteCount); const confirmation = records[voteCount]; const userId = votes[0]?.body?.userId; - assert.equal(typeof userId, "string", "The vote request has no user ID."); + assert.match(userId ?? "", USER_ID_PATTERN, "The vote request must use a 36-character protocol user ID."); for (const vote of votes) { + assert.equal( + hasExactKeys(vote.body, ["userId", "value", "videoId"]), + true, + "The vote request body must contain exactly userId, value, and videoId.", + ); assert.equal(vote.body?.userId, userId, "Vote puzzle retries used different user IDs."); assert.equal(vote.body?.videoId, videoId, "Vote puzzle retry targeted a different video."); assert.equal(vote.body?.value, value, "Vote puzzle retry changed the requested vote value."); @@ -342,8 +1850,18 @@ function assertLogicalVoteHandshake(records, videoId, value) { assert.equal(vote.responseError, null, `Vote response could not be read: ${vote.responseError}`); } + assert.equal( + hasExactKeys(confirmation.body, ["solution", "userId", "videoId"]), + true, + "The confirmation body must contain exactly solution, userId, and videoId.", + ); assert.equal(confirmation.body?.userId, userId, "Vote and confirmation used different user IDs."); assert.equal(confirmation.body?.videoId, videoId, "Vote confirmation targeted a different video."); + assert.equal( + isFourByteProofSolution(confirmation.body?.solution), + true, + "The vote confirmation must contain one canonical base64-encoded four-byte proof.", + ); assert.ok( confirmation.status >= 200 && confirmation.status < 300, `Vote confirmation failed with HTTP ${confirmation.status}.`, @@ -354,6 +1872,11 @@ function assertLogicalVoteHandshake(records, videoId, value) { `Confirmation response could not be read: ${confirmation.responseError}`, ); assert.equal(confirmation.responseBody, true, "The production API did not confirm the vote."); + assert.equal( + isVoteProtocolBodyPairValid(votes[0].body, confirmation.body, { value, videoId }), + true, + "The live vote handshake did not match the shared hermetic protocol contract.", + ); return userId; } @@ -364,10 +1887,17 @@ function assertVisibleBox(box, label) { function assertBoxInsideViewport(box, viewport, label, tolerance = 1) { assertVisibleBox(box, label); - assert.ok(box.x >= -tolerance, `${label} is clipped past the viewport's left edge.`); - assert.ok(box.y >= -tolerance, `${label} is clipped past the viewport's top edge.`); - assert.ok(box.x + box.width <= viewport.width + tolerance, `${label} is clipped past the viewport's right edge.`); - assert.ok(box.y + box.height <= viewport.height + tolerance, `${label} is clipped past the viewport's bottom edge.`); + const geometry = ` box=${JSON.stringify(box)} viewport=${JSON.stringify(viewport)} tolerance=${tolerance}`; + assert.ok(box.x >= -tolerance, `${label} is clipped past the viewport's left edge.${geometry}`); + assert.ok(box.y >= -tolerance, `${label} is clipped past the viewport's top edge.${geometry}`); + assert.ok( + box.x + box.width <= viewport.width + tolerance, + `${label} is clipped past the viewport's right edge.${geometry}`, + ); + assert.ok( + box.y + box.height <= viewport.height + tolerance, + `${label} is clipped past the viewport's bottom edge.${geometry}`, + ); } function assertShortsActionStackGeometry(boxes, viewport, tolerance = 1) { @@ -389,46 +1919,189 @@ function assertWatchRatioViewportAlignment(containerBox, likeBox, dislikeBox, vi assertVisibleBox(containerBox, "Watch ratio bar"); assertVisibleBox(likeBox, "Watch like control"); assertVisibleBox(dislikeBox, "Watch dislike control"); - const controls = [likeBox, dislikeBox]; const nativeLeft = Math.min(...controls.map((box) => box.x)); const nativeRight = Math.max(...controls.map((box) => box.x + box.width)); const nativeControlsAreHorizontallyClipped = nativeLeft < -tolerance || nativeRight > viewport.width + tolerance; - if (!nativeControlsAreHorizontallyClipped) { - assertBoxInsideViewport(containerBox, viewport, "Watch ratio bar", tolerance); - return { nativeControlsAreHorizontallyClipped, nativeLeft, nativeRight }; - } - for (const [box, label] of [ [likeBox, "Watch like control"], [dislikeBox, "Watch dislike control"], [containerBox, "Watch ratio bar"], ]) { - assert.ok(box.y >= -tolerance, `${label} is clipped past the viewport's top edge.`); + assertBoxInsideViewport(box, viewport, label, tolerance); + } + return { nativeControlsAreHorizontallyClipped, nativeLeft, nativeRight }; +} + +function assertWatchRatioSurroundings(surroundings, viewport, tolerance = 1) { + assert.ok(surroundings && typeof surroundings === "object", "The Watch ratio-bar surroundings are missing."); + assertVisibleBox(surroundings.topRow, "Watch top row"); + assertBoxInsideViewport(surroundings.hitArea, viewport, "Watch ratio bar hit area", tolerance); + assert.ok(Array.isArray(surroundings.nearbyActions), "The nearby Watch action geometry is missing."); + surroundings.nearbyActions.forEach((actionBox, index) => { + const label = `Watch nearby action ${index + 1}`; + assertBoxInsideViewport(actionBox, viewport, label, tolerance); + assert.equal(boxesOverlap(surroundings.hitArea, actionBox, 0), false, `${label} overlaps the ratio bar hit area.`); + }); + return surroundings; +} + +function sortedSignatures(values) { + return [...values].sort((left, right) => left.localeCompare(right)); +} + +function duplicateSignatures(values) { + const seen = new Set(); + const duplicates = new Set(); + for (const value of values) { + if (seen.has(value)) duplicates.add(value); + seen.add(value); + } + return sortedSignatures(duplicates); +} + +function boxesOverlap(first, second, tolerance = 1) { + if (!first || !second) return false; + const horizontal = Math.min(first.x + first.width, second.x + second.width) - Math.max(first.x, second.x); + const vertical = Math.min(first.y + first.height, second.y + second.height) - Math.max(first.y, second.y); + return horizontal > tolerance && vertical > tolerance; +} + +function assertBoxInsideBox(child, parent, label, tolerance = 2) { + assertVisibleBox(child, label); + assertVisibleBox(parent, `${label} parent`); + assert.ok(child.x >= parent.x - tolerance, `${label} escapes the top row on the left.`); + assert.ok(child.y >= parent.y - tolerance, `${label} escapes the top row above.`); + assert.ok(child.x + child.width <= parent.x + parent.width + tolerance, `${label} escapes the top row on the right.`); + assert.ok(child.y + child.height <= parent.y + parent.height + tolerance, `${label} escapes the top row below.`); +} + +function assertWatchActionTopologySnapshot( + snapshot, + { + expectedInventorySignatures = null, + expectedTopLevelOptionalSignatures = null, + minimumTopLevelOptionalActions = 0, + } = {}, +) { + assert.ok(snapshot && typeof snapshot === "object", "The Watch action-topology snapshot is missing."); + assert.match(snapshot.videoId ?? "", VIDEO_ID_PATTERN, "The Watch action topology has no current video ID."); + assert.equal(snapshot.videoMatches, true, "The visible Watch action row belongs to a stale video root."); + assert.equal(snapshot.counts?.visibleCurrentWatchRoots, 1, "Expected exactly one visible current-video Watch root."); + assert.equal(snapshot.counts?.visibleTopRows, 1, "Expected exactly one visible current-video Watch top row."); + assert.equal(snapshot.counts?.visibleMenus, 1, "Expected exactly one visible current Watch action menu."); + assert.equal(snapshot.counts?.visibleSurfaces, 1, "Expected exactly one visible current Watch action surface."); + assert.equal(snapshot.counts?.reactionGroups, 1, "Expected exactly one visible Like/Dislike reaction group."); + assert.equal(snapshot.counts?.likeButtons, 1, "Expected exactly one visible Like button in the reaction group."); + assert.equal( + snapshot.counts?.dislikeButtons, + 1, + "Expected exactly one visible Dislike button in the reaction group.", + ); + assert.equal(snapshot.counts?.moreButtons, 1, "Expected exactly one visible More-actions button."); + assert.equal( + snapshot.counts?.visibleOverflowMenus, + 1, + "Expected exactly one visible More-actions menu after opening it.", + ); + + const topLevel = snapshot.topLevelOptionalSignatures ?? []; + const overflow = snapshot.overflowSignatures ?? []; + assert.ok( + Number.isSafeInteger(minimumTopLevelOptionalActions) && minimumTopLevelOptionalActions >= 0, + "The minimum top-level action count must be a non-negative integer.", + ); + assert.ok( + topLevel.length >= minimumTopLevelOptionalActions, + `Expected at least ${minimumTopLevelOptionalActions} optional action(s) at top level; found ${topLevel.length}.`, + ); + assert.ok(overflow.length > 0, "The visible More-actions menu contains no discoverable actions."); + for (const [location, signatures] of [ + ["top level", topLevel], + ["More menu", overflow], + ]) { assert.ok( - box.y + box.height <= viewport.height + tolerance, - `${label} is clipped past the viewport's bottom edge.`, + signatures.every((value) => typeof value === "string" && value.trim() !== ""), + `A visible ${location} action has no accessible signature.`, + ); + assert.deepEqual(duplicateSignatures(signatures), [], `Duplicate actions are visible in the ${location}.`); + } + const crossLocationDuplicates = sortedSignatures(new Set(topLevel.filter((value) => overflow.includes(value)))); + assert.deepEqual( + crossLocationDuplicates, + [], + "The same optional action is visible both at top level and in the More menu.", + ); + + const inventory = sortedSignatures([...topLevel, ...overflow]); + assert.deepEqual(snapshot.inventorySignatures, inventory, "The Watch action inventory snapshot is inconsistent."); + if (expectedInventorySignatures !== null) { + assert.deepEqual( + inventory, + sortedSignatures(expectedInventorySignatures), + "A Watch action disappeared or duplicated while the layout changed.", + ); + } + if (expectedTopLevelOptionalSignatures !== null) { + assert.deepEqual( + sortedSignatures(topLevel), + sortedSignatures(expectedTopLevelOptionalSignatures), + "The top-level Watch actions did not return to their baseline layout.", ); } - const containerRight = containerBox.x + containerBox.width; - assertNear( - containerBox.x, - nativeLeft, - tolerance, - "Clipped watch ratio bar left edge alignment with native reaction controls", + const { boxes, viewport } = snapshot; + assertVisibleBox(boxes?.topRow, "Watch top row"); + assertVisibleBox(boxes?.menu, "Watch action menu"); + assertVisibleBox(boxes?.actionSurface, "Watch top-level action surface"); + assertVisibleBox(boxes?.reactionGroup, "Watch reaction group"); + assertVisibleBox(boxes?.likeButton, "Watch Like button"); + assertVisibleBox(boxes?.dislikeButton, "Watch Dislike button"); + assertVisibleBox(boxes?.moreButton, "Watch More-actions button"); + assertVisibleBox(snapshot.overflowBox, "Watch More-actions menu"); + assertBoxInsideViewport(boxes.topRow, viewport, "Watch top row", 2); + assertBoxInsideViewport(snapshot.overflowBox, viewport, "Watch More-actions menu", 2); + assertBoxInsideViewport(boxes.menu, viewport, "Watch action menu", 2); + assertBoxInsideBox(boxes.menu, boxes.topRow, "Watch action menu", 5); + for (const [box, label] of [ + [boxes.actionSurface, "Watch top-level action surface"], + [boxes.reactionGroup, "Watch reaction group"], + [boxes.likeButton, "Watch Like button"], + [boxes.dislikeButton, "Watch Dislike button"], + [boxes.moreButton, "Watch More-actions button"], + ...(boxes.topLevelActionHosts ?? []).map((box, index) => [box, `Watch top-level optional action ${index + 1}`]), + ]) { + assertBoxInsideViewport(box, viewport, label, 2); + assertBoxInsideBox(box, boxes.topRow, label); + } + assert.equal(boxesOverlap(boxes.likeButton, boxes.dislikeButton), false, "Watch Like and Dislike buttons overlap."); + const topLevelHosts = [boxes.reactionGroup, ...(boxes.topLevelActionHosts ?? []), boxes.moreButton]; + for (let left = 0; left < topLevelHosts.length; left += 1) { + for (let right = left + 1; right < topLevelHosts.length; right += 1) { + assert.equal( + boxesOverlap(topLevelHosts[left], topLevelHosts[right]), + false, + `Watch top-row controls ${left + 1} and ${right + 1} overlap.`, + ); + } + } + const overflowItemBoxes = snapshot.overflowItemBoxes ?? []; + assert.equal( + overflowItemBoxes.length, + overflow.length, + "The visible More-actions inventory and its rendered item geometry are inconsistent.", ); - assertNear( - containerRight, - nativeRight, - tolerance, - "Clipped watch ratio bar right edge alignment with native reaction controls", - ); - assert.ok( - containerBox.x >= nativeLeft - tolerance && containerRight <= nativeRight + tolerance, - "The watch ratio bar adds horizontal overflow beyond the native reaction controls.", - ); - return { nativeControlsAreHorizontallyClipped, nativeLeft, nativeRight }; + overflowItemBoxes.forEach((box, index) => assertVisibleBox(box, `Watch More-actions item ${index + 1}`)); + for (let left = 0; left < overflowItemBoxes.length; left += 1) { + for (let right = left + 1; right < overflowItemBoxes.length; right += 1) { + assert.equal( + boxesOverlap(overflowItemBoxes[left], overflowItemBoxes[right]), + false, + `Watch More-actions items ${left + 1} and ${right + 1} overlap.`, + ); + } + } + return snapshot; } function croppedScreenshotClip(boxes, viewport, margin = 12) { @@ -526,6 +2199,85 @@ function assertBoxSize(box, width, height, label, tolerance = SHORTS_GEOMETRY.ge assertNear(box.height, height, tolerance, `${label} height`); } +function assertHorizontallyContains(outer, inner, outerLabel, innerLabel, tolerance) { + assert.ok( + inner.x >= outer.x - tolerance && inner.x + inner.width <= outer.x + outer.width + tolerance, + `${outerLabel} does not horizontally contain ${innerLabel}.`, + ); +} + +function assertShortsControlShellGeometry(control, label, { nativeColumnWidth = null, reference = null } = {}) { + assertVisibleBox(control.host, `${label} action host`); + assertVisibleBox(control.label, `${label} label`); + assertVisibleBox(control.button, `${label} button`); + assertVisibleBox(control.count, `${label} count`); + + const tolerance = SHORTS_GEOMETRY.geometryTolerance; + if (reference) { + assertNear( + control.host.height, + reference.host.height, + tolerance, + `${label} action host height parity with native Like`, + ); + assertNear( + control.label.height, + reference.label.height, + tolerance, + `${label} label height parity with native Like`, + ); + } else { + assertNear(control.host.height, SHORTS_GEOMETRY.controlHeight, tolerance, `${label} action host height`); + assertNear(control.label.height, SHORTS_GEOMETRY.labelHeight, tolerance, `${label} label height`); + } + assertNear(control.host.width, control.label.width, tolerance, `${label} action host and label width`); + + const minimumWidth = Math.max(control.button.width, control.count.width); + const maximumWidth = Math.max( + control.button.width, + control.count.width + SHORTS_GEOMETRY.countHorizontalAllowance, + Number.isFinite(nativeColumnWidth) ? nativeColumnWidth : 0, + ); + for (const [box, part] of [ + [control.host, "action host"], + [control.label, "label"], + ]) { + assert.ok( + box.width >= minimumWidth - tolerance, + `${label} ${part} width is too narrow for its button or rendered count.`, + ); + assert.ok( + box.width <= maximumWidth + tolerance, + `${label} ${part} width exceeds the content-aware maximum of ${maximumWidth}px; received ${box.width}px.`, + ); + } + + const actionCenter = boxCenterX(control.host); + for (const [box, part] of [ + [control.button, "button"], + [control.icon, "icon container"], + [control.svg, "SVG"], + [control.label, "label"], + [control.count, "count"], + ]) { + assertNear(boxCenterX(box), actionCenter, tolerance, `${label} ${part} horizontal center`); + } + for (const [box, part] of [ + [control.icon, "icon container"], + [control.svg, "SVG"], + ]) { + assertNear(boxCenterY(box), boxCenterY(control.button), tolerance, `${label} ${part} vertical center`); + } + + for (const [outer, outerPart] of [ + [control.host, "action host"], + [control.label, "label"], + ]) { + assertHorizontallyContains(outer, control.button, `${label} ${outerPart}`, `${label} button`, tolerance); + assertHorizontallyContains(outer, control.count, `${label} ${outerPart}`, `${label} count`, tolerance); + } +} + function assertHostInsets(style, label) { assert.ok(style, `${label} computed style is missing.`); for (const [property, expected] of Object.entries(SHORTS_ACTION_HOST_INSETS)) { @@ -581,37 +2333,50 @@ function assertReactionPressedStates(actual, expectedState) { function assertSyntheticShortsGeometry(measurement) { assert.ok(measurement, "Synthetic Shorts geometry measurement is missing."); - const { like, next, synthetic } = measurement; + const { like, nativeActionHosts, next, synthetic } = measurement; assert.ok(like, "Native Like geometry is missing."); assert.ok(synthetic, "Synthetic Shorts geometry is missing."); assert.ok(next, "The action following the synthetic Shorts control is missing."); - - assertBoxSize(like.host, SHORTS_GEOMETRY.controlWidth, SHORTS_GEOMETRY.controlHeight, "Native Like action host"); - assertBoxSize( - synthetic.host, - SHORTS_GEOMETRY.controlWidth, - SHORTS_GEOMETRY.controlHeight, - "Synthetic Shorts action host", + assert.ok( + Array.isArray(nativeActionHosts) && nativeActionHosts.length > 0, + "Native Shorts action-column geometry is missing.", ); - assertBoxSize(like.label, SHORTS_GEOMETRY.controlWidth, SHORTS_GEOMETRY.labelHeight, "Native Like label"); - assertBoxSize(synthetic.label, SHORTS_GEOMETRY.controlWidth, SHORTS_GEOMETRY.labelHeight, "Synthetic Shorts label"); - assertBoxSize(like.button, SHORTS_GEOMETRY.buttonSize, SHORTS_GEOMETRY.buttonSize, "Native Like button"); - assertBoxSize(synthetic.button, SHORTS_GEOMETRY.buttonSize, SHORTS_GEOMETRY.buttonSize, "Synthetic Shorts button"); - assertBoxSize(like.icon, SHORTS_GEOMETRY.iconSize, SHORTS_GEOMETRY.iconSize, "Native Like icon container"); - assertBoxSize(synthetic.icon, SHORTS_GEOMETRY.iconSize, SHORTS_GEOMETRY.iconSize, "Synthetic Shorts icon container"); - assertBoxSize(like.svg, SHORTS_GEOMETRY.iconSize, SHORTS_GEOMETRY.iconSize, "Native Like SVG"); - assertBoxSize(synthetic.svg, SHORTS_GEOMETRY.iconSize, SHORTS_GEOMETRY.iconSize, "Synthetic Shorts SVG"); + + for (const [box, label] of [ + [like.button, "Native Like button"], + [like.icon, "Native Like icon container"], + [like.svg, "Native Like SVG"], + [synthetic.button, "Synthetic Shorts button"], + [synthetic.icon, "Synthetic Shorts icon container"], + [synthetic.svg, "Synthetic Shorts SVG"], + ]) { + assertVisibleBox(box, label); + } assertVisibleBox(like.count, "Native Like count"); assertVisibleBox(synthetic.count, "Synthetic Shorts count"); assertVisibleBox(next.host, "Action following the synthetic Shorts control"); + nativeActionHosts.forEach((host, index) => assertVisibleBox(host, `Native Shorts action host ${index + 1}`)); + + const tolerance = SHORTS_GEOMETRY.geometryTolerance; + for (const [actual, reference, label] of [ + [synthetic.button, like.button, "Synthetic Shorts button"], + [synthetic.icon, like.icon, "Synthetic Shorts icon container"], + [synthetic.svg, like.svg, "Synthetic Shorts SVG"], + ]) { + assertNear(actual.width, reference.width, tolerance, `${label} width parity with native Like`); + assertNear(actual.height, reference.height, tolerance, `${label} height parity with native Like`); + } + assertShortsControlShellGeometry(like, "Native Like"); + const nativeColumnWidth = Math.max(...nativeActionHosts.map((host) => host.width)); + assertShortsControlShellGeometry(synthetic, "Synthetic Shorts", { nativeColumnWidth, reference: like }); assertHostInsets(like.hostStyle, "Native Like action host"); assertHostInsets(synthetic.hostStyle, "Synthetic Shorts action host"); assertCountTypography(synthetic.countStyle, like.countStyle); - const tolerance = SHORTS_GEOMETRY.geometryTolerance; const actionCenter = boxCenterX(like.button); for (const [box, label] of [ + ...nativeActionHosts.map((host, index) => [host, `Native Shorts action host ${index + 1}`]), [like.host, "Native Like action host"], [like.label, "Native Like label"], [like.icon, "Native Like icon container"], @@ -670,12 +2435,11 @@ function assertNativeShortsPairGeometry(measurement) { [like, "Native Shorts Like"], [dislike, "Native Shorts Dislike"], ]) { - assertBoxSize(action.host, SHORTS_GEOMETRY.controlWidth, SHORTS_GEOMETRY.controlHeight, `${label} action host`); - assertBoxSize(action.label, SHORTS_GEOMETRY.controlWidth, SHORTS_GEOMETRY.labelHeight, `${label} label`); assertBoxSize(action.button, SHORTS_GEOMETRY.buttonSize, SHORTS_GEOMETRY.buttonSize, `${label} button`); assertBoxSize(action.icon, SHORTS_GEOMETRY.iconSize, SHORTS_GEOMETRY.iconSize, `${label} icon container`); assertBoxSize(action.svg, SHORTS_GEOMETRY.iconSize, SHORTS_GEOMETRY.iconSize, `${label} SVG`); assertVisibleBox(action.count, `${label} count`); + assertShortsControlShellGeometry(action, label); assertHostInsets(action.hostStyle, `${label} action host`); } @@ -700,7 +2464,7 @@ function assertNativeShortsPairGeometry(measurement) { ); } -function readNativeShortsActionMeasurement(element, expectedVideoId) { +function readNativeShortsActionMeasurement(element) { const reelSelector = "ytd-reel-video-renderer, ytm-reel-video-renderer"; const reel = element.closest(reelSelector); const host = @@ -719,17 +2483,6 @@ function readNativeShortsActionMeasurement(element, expectedVideoId) { const count = [ ...host.querySelectorAll("#text, [role='text'], .yt-spec-button-shape-next__button-text-content"), ].find((candidate) => /\d/.test(candidate.innerText ?? candidate.textContent ?? "")); - const expectedPath = `/shorts/${expectedVideoId}`; - const rendererVideoId = reel?.getAttribute("video-id"); - const videoMatches = rendererVideoId - ? rendererVideoId === expectedVideoId - : [...(reel?.querySelectorAll('a[href*="/shorts/"]') ?? [])].some((link) => { - try { - return new URL(link.getAttribute("href"), location.origin).pathname === expectedPath; - } catch { - return false; - } - }); const readBox = (node) => { if (!node) return null; const box = node.getBoundingClientRect(); @@ -795,14 +2548,122 @@ function readNativeShortsActionMeasurement(element, expectedVideoId) { label: readBox(label), reelIndex: reel ? [...document.querySelectorAll(reelSelector)].indexOf(reel) : -1, svg: readBox(svg), - videoMatches, }; } +class AttributedRuntimeTrafficLedger { + constructor(context, page, attributeRequest) { + assert.equal(typeof context?.on, "function", "The traffic ledger requires a browser context event source."); + assert.ok(page, "The traffic ledger requires the selected live YouTube page."); + assert.equal(typeof attributeRequest, "function", "The traffic ledger requires a request attribution function."); + this.attributeRequest = attributeRequest; + this.context = context; + this.page = page; + this.lastAttributedAt = Date.now(); + this.nextRequestId = 0; + this.records = []; + this.recordsByRequest = new WeakMap(); + this.onRequest = this.onRequest.bind(this); + context.on("request", this.onRequest); + } + + onRequest(request) { + let url; + try { + url = new URL(request.url()); + } catch { + return; + } + if (url.origin !== API_ORIGIN || (url.pathname !== "/votes" && !url.pathname.startsWith("/interact/"))) { + return; + } + const attribution = this.attributeRequest(request); + if (!attribution) return; + + const videoId = requestVideoId(request, url); + this.nextRequestId += 1; + const record = { + ...attribution, + claimedBy: new Set(), + method: request.method(), + pathname: url.pathname, + requestId: this.nextRequestId, + requestedAt: Date.now(), + sameVideoOtherPagesAtRequest: snapshotSameVideoOtherPages(this.context, this.page, attribution, videoId), + url: url.toString(), + videoId, + }; + this.lastAttributedAt = record.requestedAt; + this.records.push(record); + this.recordsByRequest.set(request, record); + } + + claim(request, owner) { + const record = this.recordsByRequest.get(request); + if (!record) return false; + record.claimedBy.add(owner); + return true; + } + + async assertNoUnclaimed({ label = "live run", quietMs = 500, timeoutMs = 5_000 } = {}) { + assert.ok(Number.isFinite(quietMs) && quietMs >= 0, "The traffic-ledger quiet period must be non-negative."); + assert.ok(Number.isFinite(timeoutMs) && timeoutMs >= quietMs, "The traffic-ledger timeout is too short."); + const deadline = Date.now() + timeoutMs; + while (Date.now() - this.lastAttributedAt < quietMs) { + if (Date.now() >= deadline) { + throw new Error(`Attributed runtime traffic did not become quiet before ${label} completed.`); + } + await delay(Math.min(50, quietMs - (Date.now() - this.lastAttributedAt))); + } + + for (const record of this.records) { + assertWorkerRequestAttributionIsUnambiguous(this.context, this.page, [record], record.videoId); + } + const unclaimed = this.records + .filter((record) => record.claimedBy.size === 0) + .map(({ method, pathname, requestId, source, url, videoId, workerUrl }) => ({ + method, + pathname, + requestId, + source, + url, + videoId, + workerUrl, + })); + assert.deepEqual( + unclaimed, + [], + `Attributed runtime traffic escaped every active audit before ${label} completed: ${JSON.stringify(unclaimed)}.`, + ); + return { attributedRequests: this.records.length, label }; + } + + stop() { + this.context.off("request", this.onRequest); + } +} + class VoteTrafficRecorder { - constructor(context, videoId, { handshakeTimeout = 120_000 } = {}) { + constructor( + context, + videoId, + { handshakeTimeout = 120_000, page, runtime, selectedExtensionId = null, trafficLedger = null } = {}, + ) { + assert.ok(runtime === "extension" || runtime === "userscript", `Unsupported vote-recorder runtime: ${runtime}`); + assert.ok(page, "The vote recorder requires the selected live YouTube page."); + if (runtime === "extension") { + assert.match( + selectedExtensionId ?? "", + EXTENSION_ID_PATTERN, + "The extension vote recorder requires the exact selected extension ID.", + ); + } this.context = context; this.handshakeTimeout = handshakeTimeout; + this.page = page; + this.runtime = runtime; + this.selectedExtensionId = selectedExtensionId; + this.trafficLedger = trafficLedger; this.videoId = videoId; this.records = []; this.recordsByRequest = new WeakMap(); @@ -815,15 +2676,26 @@ class VoteTrafficRecorder { onRequest(request) { const url = new URL(request.url()); if (url.origin !== API_ORIGIN || request.method() !== "POST" || !url.pathname.startsWith("/interact/")) return; + const attribution = attributeRuntimeRequest(request, this.page, this.runtime, this.selectedExtensionId); + if (!attribution) return; + if (this.trafficLedger) { + assert.equal( + this.trafficLedger.claim(request, `vote-recorder:${this.videoId}`), + true, + "The vote recorder observed attributed traffic that escaped the run-wide ledger.", + ); + } const body = readJsonBody(request); const record = { + ...attribution, body, pathname: url.pathname, requestedAt: Date.now(), responseBody: undefined, responseError: null, respondedAt: null, + sameVideoOtherPagesAtRequest: snapshotSameVideoOtherPages(this.context, this.page, attribution, body?.videoId), status: null, }; this.records.push(record); @@ -919,6 +2791,7 @@ class VoteTrafficRecorder { } const records = this.records.slice(startIndex); + assertWorkerRequestAttributionIsUnambiguous(this.context, this.page, records, this.videoId); return assertLogicalVoteHandshake(records, this.videoId, value); } @@ -929,16 +2802,115 @@ class VoteTrafficRecorder { } class LiveYoutubeDriver { - constructor(page, context, { reportProgress = () => {}, visualTooltipTimeout = VISUAL_TOOLTIP_TIMEOUT } = {}) { + constructor( + page, + context, + { + authenticatedHandle = null, + expectedBuildId = null, + readOnlyInteractionGuardCleanupTimeout = READ_ONLY_INTERACTION_GUARD_CLEANUP_TIMEOUT_MS, + reportProgress = () => {}, + selectedExtensionId = null, + visualTooltipTimeout = VISUAL_TOOLTIP_TIMEOUT, + } = {}, + ) { + if (selectedExtensionId !== null) { + assert.match( + selectedExtensionId, + EXTENSION_ID_PATTERN, + "The selected extension ID must be a 32-character Chrome extension ID.", + ); + } + if (expectedBuildId !== null) { + assert.match( + expectedBuildId, + LIVE_BUILD_ID_PATTERN, + "The expected live build ID must be a 32-character hexadecimal value.", + ); + } + assert.ok( + Number.isFinite(readOnlyInteractionGuardCleanupTimeout) && readOnlyInteractionGuardCleanupTimeout > 0, + "The read-only interaction guard cleanup timeout must be positive.", + ); this.page = page; this.context = context; + this.authenticatedHandle = authenticatedHandle === null ? null : normalizeChannelHandle(authenticatedHandle); + this.expectedBuildId = expectedBuildId; + this.readOnlyInteractionGuardCleanupTimeout = readOnlyInteractionGuardCleanupTimeout; this.reportProgress = reportProgress; + this.selectedExtensionId = selectedExtensionId; this.visualTooltipTimeout = visualTooltipTimeout; this.readOnlyInteractionGuard = null; + this.requestAttributionRuntime = null; + this.trafficLedger = null; + this.nextVotesRequestId = 0; + this.votesRequestIds = new WeakMap(); page.setDefaultTimeout(20_000); page.setDefaultNavigationTimeout(30_000); } + identifyVotesRequest(request) { + const existing = this.votesRequestIds.get(request); + if (existing) return existing; + this.nextVotesRequestId += 1; + this.votesRequestIds.set(request, this.nextVotesRequestId); + return this.nextVotesRequestId; + } + + configureRequestAttributionRuntime(runtime) { + assert.ok(runtime === "extension" || runtime === "userscript", `Unsupported live request runtime: ${runtime}`); + if (runtime === "extension") { + assert.match( + this.selectedExtensionId ?? "", + EXTENSION_ID_PATTERN, + "The extension live driver requires the exact selected extension ID for request attribution.", + ); + assert.match( + this.expectedBuildId ?? "", + LIVE_BUILD_ID_PATTERN, + "The extension live driver requires the exact live build ID for request attribution.", + ); + } + if (this.requestAttributionRuntime !== null) { + assert.equal( + this.requestAttributionRuntime, + runtime, + "The live driver cannot attribute requests for two runtimes in the same browser tab.", + ); + } + this.requestAttributionRuntime = runtime; + if (!this.trafficLedger) { + this.trafficLedger = new AttributedRuntimeTrafficLedger(this.context, this.page, (request) => + attributeRuntimeRequest( + request, + this.page, + this.requestAttributionRuntime, + this.selectedExtensionId, + this.expectedBuildId, + ), + ); + } + } + + claimAttributedTraffic(request, owner) { + if (!this.trafficLedger) return; + assert.equal( + this.trafficLedger.claim(request, owner), + true, + "A stage observed attributed traffic that escaped the run-wide ledger.", + ); + } + + async assertNoUnclaimedAttributedTraffic(label, options = {}) { + assert.ok(this.trafficLedger, "The live request-attribution runtime was not configured."); + return this.trafficLedger.assertNoUnclaimed({ ...options, label }); + } + + stop() { + this.trafficLedger?.stop(); + this.trafficLedger = null; + } + async readViewportSize() { return this.page.evaluate(() => ({ height: innerHeight, width: innerWidth })); } @@ -955,22 +2927,57 @@ class LiveYoutubeDriver { ); } - async readLargestVisibleVideoBox(viewport) { + async readLargestVisibleVideoBox(viewport, expectedShortVideoId = null) { const videos = this.page.locator("video"); let largest = null; let largestVisibleArea = 0; + let largestCurrentShort = null; + let largestCurrentShortVisibleArea = 0; for (let index = 0; index < (await videos.count()); index += 1) { const video = videos.nth(index); if (!(await video.isVisible())) continue; - const box = await video.boundingBox(); + const [box, identity] = await Promise.all([ + video.boundingBox(), + expectedShortVideoId === null + ? Promise.resolve(null) + : video.evaluate(readShortsRendererIdentityState, { expectedShortVideoId }), + ]); + if (identity && !identity.elementRenderedInViewport) continue; const visibleBox = boxIntersectionWithViewport(box, viewport); const visibleArea = visibleBox ? visibleBox.width * visibleBox.height : 0; if (visibleArea > largestVisibleArea) { largest = box; largestVisibleArea = visibleArea; } + if (identity?.videoMatches && identity.reelRenderedInViewport && visibleArea > largestCurrentShortVisibleArea) { + largestCurrentShort = box; + largestCurrentShortVisibleArea = visibleArea; + } } - return largest; + return largestCurrentShort ?? largest; + } + + async wakeCurrentShortsNativeControls(videoId, measurement, pulseIndex = 0) { + this.assertCurrentVideo(videoId); + if (typeof this.page.mouse?.move !== "function") return null; + assert.equal( + measurement?.currentVideoId, + videoId, + "The Shorts native-control wake measurement must belong to the current video.", + ); + + const viewport = measurement.viewport; + const visibleVideoBox = boxIntersectionWithViewport(measurement.currentVideoBox, viewport); + if (!visibleVideoBox || visibleVideoBox.width < 4 || visibleVideoBox.height < 4) return null; + + const horizontalPulse = pulseIndex % 2 === 0 ? -1 : 1; + const point = { + x: visibleVideoBox.x + visibleVideoBox.width / 2 + horizontalPulse, + y: visibleVideoBox.y + visibleVideoBox.height / 2, + }; + await this.page.mouse.move(point.x, point.y); + this.assertCurrentVideo(videoId); + return point; } async captureCroppedScreenshot(screenshotPath, boxes, { waitForVisualReadiness = null } = {}) { @@ -1017,6 +3024,416 @@ class LiveYoutubeDriver { ); } + async waitForCurrentShortsNativeControls( + videoId, + { + intervalMs = 200, + operationTimeoutMs = SHORTS_NATIVE_CONTROLS_OPERATION_TIMEOUT_MS, + timeoutMs = SHORTS_NATIVE_CONTROLS_TIMEOUT_MS, + } = {}, + ) { + assert.match(videoId ?? "", VIDEO_ID_PATTERN, "A valid current video ID is required for the Shorts native rail."); + assert.ok(Number.isFinite(timeoutMs) && timeoutMs >= 0, "The Shorts native-control timeout must be non-negative."); + assert.ok(Number.isFinite(intervalMs) && intervalMs > 0, "The Shorts native-control interval must be positive."); + assert.ok( + Number.isFinite(operationTimeoutMs) && operationTimeoutMs > 0, + "The Shorts native-control operation timeout must be positive.", + ); + + const startedAt = Date.now(); + const observationDeadline = startedAt + timeoutMs; + const invalidObservationMessage = `Timed out probing Shorts native controls for ${videoId}; the ${timeoutMs}ms observation window is invalid.`; + const measure = () => + withOperationTimeout( + () => + this.page.evaluate(readCurrentShortsNativeControlState, { + actionBarSelector: SHORTS_NATIVE_ACTION_BAR_SELECTOR, + minimumActionControlCount: SHORTS_MINIMUM_NATIVE_ACTION_CONTROLS, + syntheticSelector: SYNTHETIC_SHORTS_SELECTOR, + videoId, + }), + operationTimeoutMs, + invalidObservationMessage, + ); + let measurement; + let lastNativeUiWakeAt = Number.NEGATIVE_INFINITY; + let lastNativeUiWakePoint = null; + let nativeUiWakeAttempts = 0; + measurement = await measure(); + do { + this.assertCurrentVideo(videoId); + const hasMatchingNativeControls = + measurement.matchingVisibleNativeControls > 0 || measurement.matchingVisibleIndependentActions > 0; + if ( + hasMatchingNativeControls || + measurement.matchingCompleteNativeActionBars > 0 || + measurement.matchingCompleteIndependentActionBars > 0 + ) { + const result = { + ...measurement, + lastNativeUiWakePoint, + nativeUiWakeAttempts, + observedForMs: Date.now() - startedAt, + status: "present", + }; + this.reportProgress("shorts-native-controls.present", result); + return result; + } + + const now = Date.now(); + if (now >= observationDeadline) break; + if (now - lastNativeUiWakeAt >= SHORTS_NATIVE_CONTROLS_WAKE_INTERVAL_MS) { + const wakeTimeoutMs = Math.max(1, Math.min(operationTimeoutMs, observationDeadline - now)); + const wakePoint = await withOperationTimeout( + () => this.wakeCurrentShortsNativeControls(videoId, measurement, nativeUiWakeAttempts), + wakeTimeoutMs, + invalidObservationMessage, + ); + lastNativeUiWakeAt = Date.now(); + if (wakePoint) { + lastNativeUiWakePoint = wakePoint; + nativeUiWakeAttempts += 1; + measurement = await measure(); + continue; + } + } + + const remainingMs = observationDeadline - Date.now(); + if (remainingMs <= 0) break; + await delay(Math.min(intervalMs, remainingMs)); + measurement = await measure(); + } while (true); + + const hasUnownedNativeControls = measurement.visibleNativeControls > 0 || measurement.visibleIndependentActions > 0; + const result = { + ...measurement, + lastNativeUiWakePoint, + nativeUiWakeAttempts, + observedForMs: Date.now() - startedAt, + reason: hasUnownedNativeControls + ? "visible-native-shorts-actions-not-owned-by-current-video" + : "no-visible-native-shorts-actions", + status: hasUnownedNativeControls ? "present" : "blank", + }; + this.reportProgress( + hasUnownedNativeControls ? "shorts-native-controls.present-unowned" : "shorts-native-controls.blank", + result, + ); + return result; + } + + reportBlankShortsSample(details) { + this.reportProgress("shorts-sample.skipped", details); + } + + async captureBlankShortsDiagnostics( + videoId, + screenshotPath, + { + evidenceTimeoutMs = SHORTS_BLANK_EVIDENCE_TIMEOUT_MS, + operationTimeoutMs = SHORTS_NATIVE_CONTROLS_OPERATION_TIMEOUT_MS, + } = {}, + ) { + this.assertCurrentVideo(videoId); + assert.match(videoId ?? "", VIDEO_ID_PATTERN, "A valid current video ID is required for blank Shorts evidence."); + assert.ok(screenshotPath, "A screenshot path is required for blank Shorts evidence."); + assert.ok( + Number.isFinite(operationTimeoutMs) && operationTimeoutMs > 0, + "The blank Shorts evidence operation timeout must be positive.", + ); + assert.ok( + Number.isFinite(evidenceTimeoutMs) && evidenceTimeoutMs > 0, + "The blank Shorts evidence timeout must be positive.", + ); + + const evidenceDeadline = Date.now() + evidenceTimeoutMs; + const runBrowserOperation = (action, description) => { + const remainingEvidenceMs = evidenceDeadline - Date.now(); + const overallTimeoutMessage = `Timed out capturing blank Shorts diagnostics for ${videoId} within ${evidenceTimeoutMs}ms while ${description}`; + if (remainingEvidenceMs <= 0) throw new Error(overallTimeoutMessage); + const timeoutMs = Math.min(operationTimeoutMs, remainingEvidenceMs); + return withOperationTimeout( + () => action(timeoutMs), + timeoutMs, + timeoutMs < operationTimeoutMs ? overallTimeoutMessage : `Timed out ${description} for blank Short ${videoId}`, + ); + }; + + const reelLocator = this.page.locator("ytd-reel-video-renderer, ytm-reel-video-renderer"); + const reels = []; + const reelCount = await runBrowserOperation(() => reelLocator.count(), "counting Shorts renderers"); + for (let index = 0; index < reelCount; index += 1) { + const reel = reelLocator.nth(index); + const [identity, dom] = await runBrowserOperation( + (timeoutMs) => + Promise.all([ + reel.evaluate(readShortsRendererIdentityState, { expectedShortVideoId: videoId }, { timeout: timeoutMs }), + reel.evaluate( + readShortsReelDiagnosticState, + { + actionBarSelector: SHORTS_NATIVE_ACTION_BAR_SELECTOR, + syntheticSelector: SYNTHETIC_SHORTS_SELECTOR, + }, + { timeout: timeoutMs }, + ), + ]), + `reading Shorts renderer ${index} diagnostics`, + ); + reels.push({ dom, identity, index }); + } + + await runBrowserOperation( + () => + this.page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))), + "waiting for pre-screenshot paint", + ); + await runBrowserOperation( + (timeoutMs) => + this.page.screenshot({ animations: "disabled", caret: "hide", path: screenshotPath, timeout: timeoutMs }), + "capturing screenshot evidence", + ); + await runBrowserOperation( + () => + this.page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))), + "waiting for post-screenshot paint", + ); + const nativeControlsAfterEvidence = await runBrowserOperation( + (timeoutMs) => + this.waitForCurrentShortsNativeControls(videoId, { + operationTimeoutMs: timeoutMs, + timeoutMs: 0, + }), + "rechecking native Shorts controls after evidence capture", + ); + const viewport = await runBrowserOperation( + () => this.readViewportSize(), + "reading the viewport after evidence capture", + ); + const evidence = { + capturedAt: new Date().toISOString(), + currentUrl: this.page.url(), + expectedVideoId: videoId, + nativeControlsAfterEvidence, + reels, + screenshotPath, + viewport, + }; + this.reportProgress("shorts-blank-evidence.captured", { + renderedReels: reels.filter(({ dom }) => dom.renderedInViewport).length, + screenshotPath, + statusAfterEvidence: nativeControlsAfterEvidence.status, + videoId, + visibleActions: reels.reduce((count, { dom }) => count + dom.visibleActions.length, 0), + }); + return evidence; + } + + async withShortsSampleVotesResponse( + action, + { + allowLikeCountRefinement = false, + nativeControlsTimeoutMs = SHORTS_NATIVE_CONTROLS_TIMEOUT_MS, + quietMs = VOTES_RESPONSE_REQUEST_QUIET_MS, + trafficTimeoutMs = 30_000, + } = {}, + ) { + assert.equal(typeof action, "function", "A Shorts navigation action is required for the sample audit."); + assert.ok(Number.isFinite(quietMs) && quietMs >= 0, "The /votes request quiet period must be non-negative."); + assert.ok( + Number.isFinite(trafficTimeoutMs) && trafficTimeoutMs >= 0, + "The /votes traffic timeout must be non-negative.", + ); + + const baselineRequestId = this.nextVotesRequestId; + const requests = []; + const requestsByObject = new WeakMap(); + let lastActivityAt = Date.now(); + const onRequest = (request) => { + const url = new URL(request.url()); + if (url.origin !== API_ORIGIN || url.pathname !== "/votes") return; + const attribution = attributeRuntimeRequest( + request, + this.page, + this.requestAttributionRuntime, + this.selectedExtensionId, + this.expectedBuildId, + ); + if (!attribution) return; + this.claimAttributedTraffic(request, "shorts-sample-votes-response"); + const videoId = url.searchParams.get("videoId"); + const record = { + ...attribution, + method: request.method(), + requestId: this.identifyVotesRequest(request), + response: null, + responseBody: undefined, + responseStatus: null, + sameVideoOtherPagesAtRequest: snapshotSameVideoOtherPages(this.context, this.page, attribution, videoId), + url: url.toString(), + videoId, + }; + requests.push(record); + requestsByObject.set(request, record); + lastActivityAt = Date.now(); + }; + const onResponse = (response) => { + const record = requestsByObject.get(response.request()); + if (!record) return; + record.response = response; + record.responseStatus = response.status(); + lastActivityAt = Date.now(); + }; + + this.context.on("request", onRequest); + this.context.on("response", onResponse); + let result; + let videoId; + let nativeControls; + try { + result = await action(); + videoId = result?.videoId; + assert.match(videoId ?? "", VIDEO_ID_PATTERN, "The Shorts sample produced an invalid video ID."); + nativeControls = await this.waitForCurrentShortsNativeControls(videoId, { + timeoutMs: nativeControlsTimeoutMs, + }); + this.reportProgress("ryd-votes-response.waiting", { + allowMissing: nativeControls.status === "blank", + baselineRequestId, + videoId, + }); + await waitForValue( + () => + Promise.resolve({ + allResponsesReceived: requests.every((request) => request.response !== null), + quietForMs: Date.now() - lastActivityAt, + requestCount: requests.length, + }), + (state) => + state.allResponsesReceived && + state.quietForMs >= quietMs && + (nativeControls.status === "blank" || state.requestCount > 0), + nativeControls.status === "blank" + ? `Timed out waiting for attributed production /votes traffic on blank Short ${videoId} to settle` + : `Timed out waiting for new attributed production /votes traffic for Short ${videoId}`, + trafficTimeoutMs, + ); + } finally { + this.context.off("request", onRequest); + this.context.off("response", onResponse); + } + + const staleRequests = requests.filter((request) => request.videoId !== videoId); + assert.deepEqual( + staleRequests, + [], + `The Shorts transition emitted a stale /votes request while targeting ${videoId}.`, + ); + if (requests.length === 0) { + assert.equal( + nativeControls.status, + "blank", + `The current Short ${videoId} rendered native controls but the selected runtime issued no /votes request.`, + ); + this.reportProgress("ryd-votes-response.skipped", { + baselineRequestId, + reason: nativeControls.reason, + videoId, + }); + return { body: null, nativeControls, request: null, result, status: null, videoId }; + } + + const requestSequence = assertExactVotesRequestSequenceAudit(requests, videoId, { allowLikeCountRefinement }); + assertWorkerRequestAttributionIsUnambiguous(this.context, this.page, requests, videoId); + for (const request of requestSequence) { + assert.equal( + request.responseStatus, + 200, + `The production /votes request for ${videoId} returned HTTP ${request.responseStatus ?? "no response"}.`, + ); + try { + request.responseBody = await request.response.json(); + } catch (error) { + throw new Error(`The production /votes response for ${videoId} is not valid JSON. ${error.message}`, { + cause: error, + }); + } + } + const request = assertExactVotesResponseAudit(requests, videoId, baselineRequestId, { allowLikeCountRefinement }); + const responseRecord = { + method: request.method, + requestId: request.requestId, + source: request.source, + url: request.url, + videoId: request.videoId, + workerUrl: request.workerUrl, + }; + this.reportProgress("ryd-votes-response.received", { + baselineRequestId, + requestCount: requests.length, + requestIds: requests.map((candidate) => candidate.requestId), + requestId: request.requestId, + source: request.source, + status: request.responseStatus, + videoId, + }); + return { + body: request.responseBody, + nativeControls, + request: responseRecord, + result, + status: request.responseStatus, + videoId, + }; + } + + async withExactVotesRequest(action, { quietMs = SHORTS_VOTES_REQUEST_QUIET_MS } = {}) { + assert.equal(typeof action, "function", "A Shorts Next action is required for the /votes request audit."); + assert.ok(Number.isFinite(quietMs) && quietMs >= 0, "The /votes request quiet period must be non-negative."); + + const requests = []; + const onRequest = (request) => { + const url = new URL(request.url()); + if (url.origin !== API_ORIGIN || url.pathname !== "/votes") return; + const attribution = attributeRuntimeRequest( + request, + this.page, + this.requestAttributionRuntime, + this.selectedExtensionId, + this.expectedBuildId, + ); + if (!attribution) return; + this.claimAttributedTraffic(request, "exact-votes-request"); + const videoId = url.searchParams.get("videoId"); + requests.push({ + ...attribution, + method: request.method(), + requestId: this.identifyVotesRequest(request), + sameVideoOtherPagesAtRequest: snapshotSameVideoOtherPages(this.context, this.page, attribution, videoId), + url: url.toString(), + videoId, + }); + }; + + this.context.on("request", onRequest); + let result; + try { + result = await action(); + if (quietMs > 0) await delay(quietMs); + } finally { + this.context.off("request", onRequest); + } + + const request = assertExactVotesRequestAudit(requests, result?.videoId); + assertWorkerRequestAttributionIsUnambiguous(this.context, this.page, requests, result.videoId); + this.reportProgress("shorts-votes-request-audit.confirmed", { + method: request.method, + source: request.source, + url: request.url, + videoId: request.videoId, + }); + return result; + } + async withNoProductionInteractions(action) { if (this.readOnlyInteractionGuard) { this.readOnlyInteractionGuard.depth += 1; @@ -1059,6 +3476,7 @@ class LiveYoutubeDriver { let routeInstalled = false; let result; let actionError; + let cleanupError; try { await this.context.route(routeMatcher, onRoute); routeInstalled = true; @@ -1073,36 +3491,251 @@ class LiveYoutubeDriver { } finally { if (requestListenerInstalled) this.context.off("request", onRequest); try { - if (routeInstalled) await this.context.unroute(routeMatcher, onRoute); + if (routeInstalled) { + const timeoutMs = this.readOnlyInteractionGuardCleanupTimeout; + this.reportProgress("read-only-interaction-guard.unroute-started", { timeoutMs }); + try { + await withOperationTimeout( + () => this.context.unroute(routeMatcher, onRoute), + timeoutMs, + `Timed out after ${timeoutMs}ms while removing the live read-only production-interaction route.`, + ); + this.reportProgress("read-only-interaction-guard.unroute-completed", { timeoutMs }); + } catch (error) { + cleanupError = error; + this.reportProgress("read-only-interaction-guard.unroute-failed", { + message: error.message, + timeoutMs, + }); + } + } } finally { this.readOnlyInteractionGuard = null; } } - assert.deepEqual( - { - abortedRequests: guard.abortedRequests, - observedRequests: guard.observedRequests, - routeErrors: guard.routeErrors, - }, - { abortedRequests: [], observedRequests: [], routeErrors: [] }, - "The read-only live scenario attempted a production interaction. The request was blocked before transmission.", - ); - if (actionError) throw actionError; + let interactionError; + try { + assert.deepEqual( + { + abortedRequests: guard.abortedRequests, + observedRequests: guard.observedRequests, + routeErrors: guard.routeErrors, + }, + { abortedRequests: [], observedRequests: [], routeErrors: [] }, + "The read-only live scenario attempted a production interaction. The request was blocked before transmission.", + ); + } catch (error) { + interactionError = error; + } + const failures = [actionError, interactionError, cleanupError].filter(Boolean); + if (failures.length > 1) { + throw new AggregateError( + failures, + "The read-only live scenario and its production-interaction guard both failed.", + ); + } + if (failures.length === 1) throw failures[0]; return result; } + async inspectWatchActionTopology( + runtime, + { + expectedCounts = null, + expectedInventorySignatures = null, + expectedTopLevelOptionalSignatures = null, + minimumTopLevelOptionalActions = 0, + presenceTimeoutMs = 20_000, + } = {}, + ) { + if (!RATE_BAR_SELECTORS[runtime]) throw new Error(`Unsupported live topology runtime: ${runtime}`); + const videoId = videoIdFromUrl(this.page.url()); + assert.match(videoId ?? "", VIDEO_ID_PATTERN, "The action-topology check requires a current Watch video ID."); + assert.ok(this.page.url().includes("/watch"), "The action-topology check requires a Watch page."); + this.assertCurrentVideo(videoId); + this.reportProgress("watch-action-topology.waiting", { runtime, videoId }); + + const ratio = await this.inspectWatchRatioVisual(runtime, { + expectedCounts, + expectedVideoId: videoId, + presenceTimeoutMs, + waitForPresence: true, + }); + const resolveCurrentTopology = async () => { + this.assertCurrentVideo(videoId); + const watchRoots = this.page.locator( + `ytd-watch-flexy[video-id="${videoId}"], ytd-watch-grid[video-id="${videoId}"]`, + ); + const visibleRootIndexes = await waitForValue( + () => visibleLocatorIndexes(watchRoots), + (indexes) => indexes.length > 0, + `Timed out waiting for the visible current-video Watch root for ${videoId}`, + presenceTimeoutMs, + ); + const watchRoot = watchRoots.nth(visibleRootIndexes[0]); + const topRows = watchRoot.locator("#top-row"); + const visibleTopRowIndexes = await waitForValue( + () => visibleLocatorIndexes(topRows), + (indexes) => indexes.length > 0, + `Timed out waiting for the visible Watch top row for ${videoId}`, + presenceTimeoutMs, + ); + const topRow = topRows.nth(visibleTopRowIndexes[0]); + await prepareElementForViewportMeasurement(topRow, "current Watch top row", { + timeout: presenceTimeoutMs, + }); + await this.page.evaluate( + () => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))), + ); + + const topology = await topRow.evaluate(readWatchTopRowTopology, videoId); + const menu = await firstVisible( + topRow.locator("ytd-menu-renderer.ytd-watch-metadata"), + "current Watch action menu", + { + timeout: presenceTimeoutMs, + }, + ); + return { + moreButtons: await currentWatchMoreButtons(menu), + topology, + visibleRootIndexes, + visibleTopRowIndexes, + }; + }; + + let currentTopology = await resolveCurrentTopology(); + let overflow; + let overflowActivation = "pointer"; + let popupResult; + try { + assert.equal( + currentTopology.moreButtons.length, + 1, + `Expected exactly one visible More-actions button; found ${currentTopology.moreButtons.length}.`, + ); + await clickHitTestedElement(currentTopology.moreButtons[0], { + beforeClick: () => this.assertCurrentVideo(videoId), + label: `Watch More-actions button for ${videoId}`, + timeout: presenceTimeoutMs, + }); + const firstOpenTimeoutMs = Math.min(WATCH_OVERFLOW_FIRST_OPEN_TIMEOUT_MS, presenceTimeoutMs); + try { + popupResult = await firstVisibleWatchOverflowMenu(this.page, firstOpenTimeoutMs); + } catch (firstOpenError) { + popupResult = await visibleWatchOverflowMenu(this.page); + if (!popupResult) { + const retryOpenTimeoutMs = Math.max(1, presenceTimeoutMs - firstOpenTimeoutMs); + this.reportProgress("watch-action-topology.more-menu.retrying", { + firstFailure: String(firstOpenError?.message ?? firstOpenError), + firstTimeoutMs: firstOpenTimeoutMs, + retryTimeoutMs: retryOpenTimeoutMs, + runtime, + videoId, + }); + currentTopology = await resolveCurrentTopology(); + assert.equal( + currentTopology.moreButtons.length, + 1, + `Expected exactly one visible More-actions button before retry; found ${currentTopology.moreButtons.length}.`, + ); + await pressHitTestedElement(currentTopology.moreButtons[0], { + beforePress: () => this.assertCurrentVideo(videoId), + label: `Watch More-actions button retry for ${videoId}`, + timeout: presenceTimeoutMs, + }); + overflowActivation = "keyboard-retry"; + try { + popupResult = await firstVisibleWatchOverflowMenu(this.page, retryOpenTimeoutMs); + } catch (retryOpenError) { + throw new Error( + `YouTube did not open the Watch More-actions menu after a hit-tested pointer activation or its single keyboard retry. First failure: ${String(firstOpenError?.message ?? firstOpenError)}. Retry failure: ${String(retryOpenError?.message ?? retryOpenError)}`, + { cause: retryOpenError }, + ); + } + } + } + overflow = await popupResult.locator.evaluate(readOverflowMenuTopology); + } finally { + await this.page.keyboard.press("Escape").catch(() => {}); + await waitForValue( + async () => { + return (await visibleWatchOverflowMenu(this.page)) === null; + }, + Boolean, + "Timed out closing the Watch More-actions menu after topology inspection", + 5_000, + ); + } + + const snapshot = { + ...currentTopology.topology, + counts: { + ...currentTopology.topology.counts, + visibleCurrentWatchRoots: currentTopology.visibleRootIndexes.length, + visibleOverflowMenus: popupResult?.visibleCount ?? 0, + visibleTopRows: currentTopology.visibleTopRowIndexes.length, + }, + inventorySignatures: sortedSignatures([ + ...currentTopology.topology.topLevelOptionalSignatures, + ...(overflow?.signatures ?? []), + ]), + overflowActivation, + overflowBox: overflow?.box ?? null, + overflowItemBoxes: overflow?.itemBoxes ?? [], + overflowSignatures: overflow?.signatures ?? [], + ratio, + viewport: await this.readViewportSize(), + }; + assertWatchActionTopologySnapshot(snapshot, { + expectedInventorySignatures, + expectedTopLevelOptionalSignatures, + minimumTopLevelOptionalActions, + }); + this.reportProgress("watch-action-topology.confirmed", { + inventoryCount: snapshot.inventorySignatures.length, + overflowCount: snapshot.overflowSignatures.length, + runtime, + topLevelOptionalCount: snapshot.topLevelOptionalSignatures.length, + videoId, + viewport: snapshot.viewport, + }); + return snapshot; + } + + async captureWatchActionTopologyVisual(runtime, screenshotPath, options = {}) { + const snapshot = await this.inspectWatchActionTopology(runtime, options); + const screenshotClip = await this.captureCroppedScreenshot(screenshotPath, [snapshot.boxes.topRow]); + return { ...snapshot, screenshotClip, screenshotPath }; + } + async inspectWatchRatioVisual( runtime, - { expectedCount = null, presenceTimeoutMs = 20_000, waitForPresence = false } = {}, + { + expectedCount = null, + expectedCounts = null, + expectedVideoId = null, + presenceTimeoutMs = 20_000, + waitForPresence = false, + } = {}, ) { const selectors = RATE_BAR_SELECTORS[runtime]; if (!selectors) throw new Error(`Unsupported live visual runtime: ${runtime}`); + const videoId = expectedVideoId ?? videoIdFromUrl(typeof this.page.url === "function" ? this.page.url() : ""); + assert.match(videoId ?? "", VIDEO_ID_PATTERN, "The Watch ratio audit requires the exact current video ID."); + this.assertCurrentVideo(videoId); + if (expectedCounts !== null) assertExpectedWatchCounts(expectedCounts); const containers = this.page.locator(selectors.container); const bars = this.page.locator(selectors.bar); + let presenceLatencyMs = null; if (waitForPresence) { - await firstVisible(containers, `${runtime} watch ratio bar`, { timeout: presenceTimeoutMs }); - await firstVisible(bars, `${runtime} watch ratio fill`, { timeout: presenceTimeoutMs }); + const presenceStartedAt = Date.now(); + await Promise.all([ + firstVisible(containers, `${runtime} watch ratio bar`, { timeout: presenceTimeoutMs }), + firstVisible(bars, `${runtime} watch ratio fill`, { timeout: presenceTimeoutMs }), + ]); + presenceLatencyMs = Date.now() - presenceStartedAt; } const [visibleContainerIndexes, visibleBarIndexes] = await Promise.all([ @@ -1128,24 +3761,33 @@ class LiveYoutubeDriver { const bar = bars.nth(visibleBarIndexes[0]); const likeButton = await this.visibleLikeButton(); const dislikeButton = await this.visibleDislikeButton(); - await container.scrollIntoViewIfNeeded(); + await prepareElementForViewportMeasurement(container, `${runtime} watch ratio bar`, { + timeout: presenceTimeoutMs, + }); const wrapper = container.locator( "xpath=ancestor::*[contains(concat(' ', normalize-space(@class), ' '), ' ryd-tooltip ')][1]", ); - const [barBox, containerBox, dislikeBox, likeBox, wrapperBox, viewport] = await Promise.all([ - bar.boundingBox(), - container.boundingBox(), - dislikeButton.boundingBox(), - likeButton.boundingBox(), - wrapper.boundingBox(), - this.readViewportSize(), - ]); + const [appearance, barBox, containerBox, dislikeBox, likeBox, wrapperBox, surroundings, viewport] = + await Promise.all([ + wrapper.evaluate(readWatchRatioAppearance, selectors), + bar.boundingBox(), + container.boundingBox(), + dislikeButton.boundingBox(), + likeButton.boundingBox(), + wrapper.boundingBox(), + wrapper.evaluate(readWatchRatioSurroundings), + this.readViewportSize(), + ]); assert.match(count, /\d/, "The watch dislike control has no rendered count."); + const countAudit = + expectedCounts === null ? null : await this.assertRenderedDislikeCount(count, expectedCounts.dislikes, runtime); assertVisibleBox(barBox, "Watch ratio fill"); assertVisibleBox(wrapperBox, "Watch ratio wrapper"); const viewportAlignment = assertWatchRatioViewportAlignment(containerBox, likeBox, dislikeBox, viewport); + assertBoxInsideViewport(wrapperBox, viewport, "Watch ratio wrapper"); + assertWatchRatioSurroundings(surroundings, viewport); assert.ok(barBox.x >= containerBox.x - 1, "The watch ratio fill starts outside its container."); assert.ok( barBox.x + barBox.width <= containerBox.x + containerBox.width + 1, @@ -1160,10 +3802,34 @@ class LiveYoutubeDriver { wrapperBox.width >= expectedWrapperWidth * 0.75 && wrapperBox.width <= expectedWrapperWidth * 1.25, `The watch ratio wrapper width ${wrapperBox.width} does not track the reaction controls (${expectedWrapperWidth}).`, ); + const ratioAudit = assertWatchRatioData( + { appearance, geometry: { bar: barBox, container: containerBox } }, + { + expectedCounts, + expectedTooltipCandidates: + expectedCounts === null ? [] : formattedWatchTooltipCandidates(expectedCounts, appearance.numberLocale), + runtime, + videoId, + }, + ); return { + appearance, count, - geometry: { bar: barBox, container: containerBox, dislike: dislikeBox, like: likeBox, wrapper: wrapperBox }, + countAudit, + geometry: { + bar: barBox, + container: containerBox, + dislike: dislikeBox, + hitArea: surroundings.hitArea, + like: likeBox, + nearbyActions: surroundings.nearbyActions, + topRow: surroundings.topRow, + wrapper: wrapperBox, + }, + ratioAudit, + presenceLatencyMs, + videoId, viewport, viewportAlignment, }; @@ -1173,14 +3839,28 @@ class LiveYoutubeDriver { return this.inspectWatchRatioVisual(runtime, options); } - async captureWatchRatioVisual(runtime, screenshotPath, { expectedCount = null, presenceTimeoutMs = 20_000 } = {}) { + async captureWatchRatioVisual( + runtime, + screenshotPath, + { expectedCount = null, expectedCounts = null, expectedVideoId = null, presenceTimeoutMs = 20_000 } = {}, + ) { const measurement = await this.inspectWatchRatioVisual(runtime, { expectedCount, + expectedCounts, + expectedVideoId, presenceTimeoutMs, waitForPresence: true, }); - const { container, dislike, like, wrapper } = measurement.geometry; - const clip = await this.captureCroppedScreenshot(screenshotPath, [like, dislike, container, wrapper]); + const { container, dislike, hitArea, like, nearbyActions, topRow, wrapper } = measurement.geometry; + const clip = await this.captureCroppedScreenshot(screenshotPath, [ + topRow, + like, + dislike, + container, + wrapper, + hitArea, + ...nearbyActions, + ]); return { ...measurement, screenshotPath, @@ -1190,9 +3870,16 @@ class LiveYoutubeDriver { async soakWatchRatioVisual( runtime, - { durationMs = WATCH_RATIO_SOAK_DURATION_MS, expectedCount, intervalMs = WATCH_RATIO_SOAK_INTERVAL_MS, videoId }, + { + durationMs = WATCH_RATIO_SOAK_DURATION_MS, + expectedCount, + expectedCounts, + intervalMs = WATCH_RATIO_SOAK_INTERVAL_MS, + videoId, + }, ) { assert.match(expectedCount, /\d/, "A rendered dislike count is required before soaking the watch ratio bar."); + assertExpectedWatchCounts(expectedCounts); assert.match(videoId, VIDEO_ID_PATTERN, "A valid current video ID is required for the watch ratio-bar soak."); assert.ok( Number.isFinite(durationMs) && durationMs >= 0, @@ -1206,7 +3893,11 @@ class LiveYoutubeDriver { this.reportProgress("watch-ratio-soak.start", { durationMs, expectedCount, runtime, videoId }); do { this.assertCurrentVideo(videoId); - lastMeasurement = await this.assertWatchRatioVisual(runtime, { expectedCount }); + lastMeasurement = await this.assertWatchRatioVisual(runtime, { + expectedCount, + expectedCounts, + expectedVideoId: videoId, + }); sampleCount += 1; const remaining = deadline - Date.now(); if (remaining <= 0) break; @@ -1214,7 +3905,71 @@ class LiveYoutubeDriver { } while (true); this.reportProgress("watch-ratio-soak.complete", { durationMs, expectedCount, runtime, sampleCount, videoId }); - return { count: lastMeasurement.count, durationMs, sampleCount, videoId }; + return { count: lastMeasurement.count, durationMs, ratioAudit: lastMeasurement.ratioAudit, sampleCount, videoId }; + } + + async assertRenderedDislikeCount(renderedCount, dislikes, runtime) { + const locale = await this.page.evaluate(() => document.documentElement.lang || navigator.language || "en"); + return assertRenderedDislikeCountMatchesApi(renderedCount, dislikes, locale, runtime); + } + + async assertDislikeCountChangesObservable(changes, runtime) { + const locale = await this.page.evaluate(() => document.documentElement.lang || navigator.language || "en"); + return assertDislikeCountChangesObservable(changes, locale, runtime); + } + + async assertCurrentWatchResult( + videoId, + runtime, + expectedCounts, + { durationMs = WATCH_RESULT_SOAK_DURATION_MS, presenceTimeoutMs = 20_000 } = {}, + ) { + const selectors = RATE_BAR_SELECTORS[runtime]; + if (!selectors) throw new Error(`Unsupported live visual runtime: ${runtime}`); + assertExpectedWatchCounts(expectedCounts); + this.assertCurrentVideo(videoId); + + const visual = await this.inspectWatchRatioVisual(runtime, { + expectedCounts, + expectedVideoId: videoId, + presenceTimeoutMs, + waitForPresence: true, + }); + const countAudit = await this.assertRenderedDislikeCount(visual.count, expectedCounts.dislikes, runtime); + const currentRoots = this.page.locator( + `ytd-watch-flexy[video-id="${videoId}"], ytd-watch-grid[video-id="${videoId}"]`, + ); + const [currentContainers, currentBars] = await Promise.all([ + visibleLocatorIndexes(currentRoots.locator(selectors.container)), + visibleLocatorIndexes(currentRoots.locator(selectors.bar)), + ]); + assert.equal( + currentContainers.length, + 1, + `Expected exactly one visible ${runtime} ratio bar owned by current Watch video ${videoId}; found ${currentContainers.length}.`, + ); + assert.equal( + currentBars.length, + 1, + `Expected exactly one visible ${runtime} ratio fill owned by current Watch video ${videoId}; found ${currentBars.length}.`, + ); + + const stability = await this.soakWatchRatioVisual(runtime, { + durationMs, + expectedCount: visual.count, + expectedCounts, + videoId, + }); + this.assertCurrentVideo(videoId); + this.reportProgress("watch-result.confirmed", { + apiDislikes: expectedCounts.dislikes, + apiLikes: expectedCounts.likes, + count: visual.count, + runtime, + sampleCount: stability.sampleCount, + videoId, + }); + return { count: visual.count, countAudit, stability, videoId, visual }; } async captureSyntheticShortsVisual(videoId, screenshotPath) { @@ -1234,130 +3989,145 @@ class LiveYoutubeDriver { const countLocator = control.locator("#text, [role='text']").first(); const count = await waitForValue( () => countLocator.evaluate((element) => (element.innerText ?? element.textContent ?? "").trim()), - (text) => /\d/.test(text), + (text) => RENDERED_NUMBER_PATTERN.test(text), "The synthetic Shorts dislike control has no rendered count", 30_000, ); - await control.scrollIntoViewIfNeeded(); + await this.waitForShortsVisualPaint([likeButton, button]); + const [measurement, identityState] = await Promise.all([ + control.evaluate((element) => { + const actionBar = element.closest("reel-action-bar-view-model"); + const reel = element.closest("ytd-reel-video-renderer, ytm-reel-video-renderer"); + const likeHost = element.previousElementSibling; + const nextHost = element.nextElementSibling; + const likeLabel = likeHost?.querySelector("label") ?? null; + const likeButton = likeHost?.querySelector("button") ?? null; + const likeIcon = likeButton?.querySelector(".ytSpecButtonShapeNextIcon") ?? null; + const likeSvg = likeIcon?.querySelector("svg") ?? likeButton?.querySelector("svg") ?? null; + const likeCount = likeHost?.querySelector("#text, [role='text']") ?? null; + const syntheticLabel = element.querySelector("label"); + const syntheticButton = element.querySelector("button"); + const syntheticIcon = syntheticButton?.querySelector(".ytSpecButtonShapeNextIcon") ?? null; + const syntheticSvg = syntheticIcon?.querySelector("svg") ?? syntheticButton?.querySelector("svg") ?? null; + const syntheticCount = element.querySelector("#text, [role='text']"); - const measurement = await control.evaluate((element, expectedVideoId) => { - const actionBar = element.closest("reel-action-bar-view-model"); - const reel = element.closest("ytd-reel-video-renderer, ytm-reel-video-renderer"); - const likeHost = element.previousElementSibling; - const nextHost = element.nextElementSibling; - const likeLabel = likeHost?.querySelector("label") ?? null; - const likeButton = likeHost?.querySelector("button") ?? null; - const likeIcon = likeButton?.querySelector(".ytSpecButtonShapeNextIcon") ?? null; - const likeSvg = likeIcon?.querySelector("svg") ?? likeButton?.querySelector("svg") ?? null; - const likeCount = likeHost?.querySelector("#text, [role='text']") ?? null; - const syntheticLabel = element.querySelector("label"); - const syntheticButton = element.querySelector("button"); - const syntheticIcon = syntheticButton?.querySelector(".ytSpecButtonShapeNextIcon") ?? null; - const syntheticSvg = syntheticIcon?.querySelector("svg") ?? syntheticButton?.querySelector("svg") ?? null; - const syntheticCount = element.querySelector("#text, [role='text']"); - const expectedPath = `/shorts/${expectedVideoId}`; - const rendererVideoId = reel?.getAttribute("video-id"); - const videoMatches = rendererVideoId - ? rendererVideoId === expectedVideoId - : [...(reel?.querySelectorAll('a[href*="/shorts/"]') ?? [])].some((link) => { - try { - return new URL(link.getAttribute("href"), location.origin).pathname === expectedPath; - } catch { - return false; - } - }); - - const readBox = (node) => { - if (!node) return null; - const box = node.getBoundingClientRect(); - return { height: box.height, width: box.width, x: box.x, y: box.y }; - }; - const cssPixels = (value) => { - const number = Number.parseFloat(value); - return Number.isFinite(number) ? number : null; - }; - const readHostStyle = (node) => { - if (!node) return null; - const style = getComputedStyle(node); - return { - marginBottom: cssPixels(style.marginBottom), - marginLeft: cssPixels(style.marginLeft), - marginRight: cssPixels(style.marginRight), - marginTop: cssPixels(style.marginTop), - paddingBottom: cssPixels(style.paddingBottom), - paddingLeft: cssPixels(style.paddingLeft), - paddingRight: cssPixels(style.paddingRight), - paddingTop: cssPixels(style.paddingTop), + const readBox = (node) => { + if (!node) return null; + const box = node.getBoundingClientRect(); + return { height: box.height, width: box.width, x: box.x, y: box.y }; }; - }; - const readCountStyle = (node) => { - if (!node) return null; - const style = getComputedStyle(node); - return { - fontFamily: style.fontFamily, - fontSize: cssPixels(style.fontSize), - fontStyle: style.fontStyle, - fontWeight: style.fontWeight, - lineHeight: cssPixels(style.lineHeight), + const cssPixels = (value) => { + const number = Number.parseFloat(value); + return Number.isFinite(number) ? number : null; }; - }; - const actionHosts = [...(actionBar?.children ?? [])] - .filter((candidate) => { - const button = candidate.matches("button") ? candidate : candidate.querySelector("button"); - if (!button) return false; - const box = candidate.getBoundingClientRect(); - const style = getComputedStyle(candidate); - return ( - box.width > 0 && - box.height > 0 && - box.bottom > 0 && - box.right > 0 && - box.top < innerHeight && - box.left < innerWidth && - style.display !== "none" && - style.visibility !== "hidden" - ); - }) - .map(readBox); + const readHostStyle = (node) => { + if (!node) return null; + const style = getComputedStyle(node); + return { + marginBottom: cssPixels(style.marginBottom), + marginLeft: cssPixels(style.marginLeft), + marginRight: cssPixels(style.marginRight), + marginTop: cssPixels(style.marginTop), + paddingBottom: cssPixels(style.paddingBottom), + paddingLeft: cssPixels(style.paddingLeft), + paddingRight: cssPixels(style.paddingRight), + paddingTop: cssPixels(style.paddingTop), + }; + }; + const readCountStyle = (node) => { + if (!node) return null; + const style = getComputedStyle(node); + return { + fontFamily: style.fontFamily, + fontSize: cssPixels(style.fontSize), + fontStyle: style.fontStyle, + fontWeight: style.fontWeight, + lineHeight: cssPixels(style.lineHeight), + }; + }; + const actionHosts = [...(actionBar?.children ?? [])] + .filter((candidate) => { + const button = candidate.matches("button") ? candidate : candidate.querySelector("button"); + if (!button) return false; + const box = candidate.getBoundingClientRect(); + const style = getComputedStyle(candidate); + return ( + box.width > 0 && + box.height > 0 && + box.bottom > 0 && + box.right > 0 && + box.top < innerHeight && + box.left < innerWidth && + style.display !== "none" && + style.visibility !== "hidden" + ); + }) + .map(readBox); + const nativeActionHosts = [...(actionBar?.children ?? [])] + .filter( + (candidate) => + candidate !== element && + !candidate.matches("[data-ryd-synthetic-shorts-dislike]") && + !candidate.querySelector("[data-ryd-synthetic-shorts-dislike]"), + ) + .filter((candidate) => { + const button = candidate.matches("button") ? candidate : candidate.querySelector("button"); + if (!button) return false; + const box = candidate.getBoundingClientRect(); + const style = getComputedStyle(candidate); + return ( + box.width > 0 && + box.height > 0 && + box.bottom > 0 && + box.right > 0 && + box.top < innerHeight && + box.left < innerWidth && + style.display !== "none" && + style.visibility !== "hidden" + ); + }) + .map(readBox); - return { - actionHosts, - activeVideoId: element.getAttribute("data-ryd-video-id"), - geometry: { - like: { - button: readBox(likeButton), - count: readBox(likeCount), - countStyle: readCountStyle(likeCount), - host: readBox(likeHost), - hostStyle: readHostStyle(likeHost), - icon: readBox(likeIcon), - label: readBox(likeLabel), - svg: readBox(likeSvg), + return { + actionHosts, + activeVideoId: element.getAttribute("data-ryd-video-id"), + geometry: { + like: { + button: readBox(likeButton), + count: readBox(likeCount), + countStyle: readCountStyle(likeCount), + host: readBox(likeHost), + hostStyle: readHostStyle(likeHost), + icon: readBox(likeIcon), + label: readBox(likeLabel), + svg: readBox(likeSvg), + }, + nativeActionHosts, + next: { host: readBox(nextHost) }, + synthetic: { + button: readBox(syntheticButton), + count: readBox(syntheticCount), + countStyle: readCountStyle(syntheticCount), + host: readBox(element), + hostStyle: readHostStyle(element), + icon: readBox(syntheticIcon), + label: readBox(syntheticLabel), + svg: readBox(syntheticSvg), + }, }, - next: { host: readBox(nextHost) }, - synthetic: { - button: readBox(syntheticButton), - count: readBox(syntheticCount), - countStyle: readCountStyle(syntheticCount), - host: readBox(element), - hostStyle: readHostStyle(element), - icon: readBox(syntheticIcon), - label: readBox(syntheticLabel), - svg: readBox(syntheticSvg), - }, - }, - likeIsImmediatePreviousAction: - likeHost?.matches("like-button-view-model") === true && likeHost?.parentElement === actionBar, - nativeDislikes: actionBar?.querySelectorAll("dislike-button-view-model, #dislike-button").length ?? -1, - nextIsImmediateAction: nextHost !== null && nextHost.parentElement === actionBar, - syntheticControls: actionBar?.querySelectorAll("[data-ryd-synthetic-shorts-dislike]").length ?? -1, - videoMatches, - }; - }, videoId); + likeIsImmediatePreviousAction: + likeHost?.matches("like-button-view-model") === true && likeHost?.parentElement === actionBar, + nativeDislikes: actionBar?.querySelectorAll("dislike-button-view-model, #dislike-button").length ?? -1, + nextIsImmediateAction: nextHost !== null && nextHost.parentElement === actionBar, + syntheticControls: actionBar?.querySelectorAll("[data-ryd-synthetic-shorts-dislike]").length ?? -1, + }; + }), + control.evaluate(readShortsRendererIdentityState, { expectedShortVideoId: videoId }), + ]); const viewport = await this.readViewportSize(); assert.equal(measurement.activeVideoId, videoId, "The synthetic Shorts control targets the wrong video."); - assert.equal(measurement.videoMatches, true, "The synthetic Shorts control is outside the active reel."); + assert.equal(identityState.videoMatches, true, "The synthetic Shorts control is outside the active reel."); assert.equal(measurement.nativeDislikes, 0, "The modern Shorts action bar unexpectedly has a native dislike."); assert.equal(measurement.syntheticControls, 1, "The active Shorts action bar has duplicate synthetic controls."); assert.equal( @@ -1373,7 +4143,7 @@ class LiveYoutubeDriver { assert.equal(await button.getAttribute("aria-label"), "Dislike this video"); assert.equal(await button.getAttribute("aria-disabled"), "false"); assert.ok(["true", "false"].includes(await button.getAttribute("aria-pressed")), "Invalid Shorts pressed state."); - assert.match(count, /\d/, "The synthetic Shorts dislike count is not numeric."); + assert.match(count, RENDERED_NUMBER_PATTERN, "The synthetic Shorts dislike count is not numeric."); assertSyntheticShortsGeometry(measurement.geometry); assertBoxInsideViewport(measurement.geometry.like.host, viewport, "Native Like action host"); assertBoxInsideViewport(measurement.geometry.synthetic.host, viewport, "Synthetic Shorts action host"); @@ -1395,14 +4165,18 @@ class LiveYoutubeDriver { async captureNativeShortsVisual(videoId, screenshotPath) { this.assertCurrentVideo(videoId); const count = await this.waitForDislikeText(); - assert.match(count, /\d/, "The native Shorts dislike control has no rendered numeric count."); + assert.match(count, RENDERED_NUMBER_PATTERN, "The native Shorts dislike control has no rendered numeric count."); const [likeButton, dislikeButton] = await Promise.all([this.visibleLikeButton(), this.visibleDislikeButton()]); - await Promise.all([likeButton.scrollIntoViewIfNeeded(), dislikeButton.scrollIntoViewIfNeeded()]); - const [dislike, like, viewport] = await Promise.all([ - dislikeButton.evaluate(readNativeShortsActionMeasurement, videoId), - likeButton.evaluate(readNativeShortsActionMeasurement, videoId), + await this.waitForShortsVisualPaint([likeButton, dislikeButton]); + const [dislike, like, dislikeIdentityState, likeIdentityState, viewport] = await Promise.all([ + dislikeButton.evaluate(readNativeShortsActionMeasurement), + likeButton.evaluate(readNativeShortsActionMeasurement), + dislikeButton.evaluate(readShortsRendererIdentityState, { expectedShortVideoId: videoId }), + likeButton.evaluate(readShortsRendererIdentityState, { expectedShortVideoId: videoId }), this.readViewportSize(), ]); + dislike.videoMatches = dislikeIdentityState.videoMatches; + like.videoMatches = likeIdentityState.videoMatches; const geometry = { dislike, like }; assertNativeShortsPairGeometry(geometry); assertBoxInsideViewport(like.host, viewport, "Shorts Like action"); @@ -1420,25 +4194,47 @@ class LiveYoutubeDriver { }; } - async captureReactionStateVisual({ expectedState, isShort, runtime, screenshotPath, videoId }) { + async captureReactionStateVisual({ + expectedCounts, + expectedState, + isShort, + runtime, + screenshotPath, + shortsVisualModel, + videoId, + }) { this.assertCurrentVideo(videoId); + assertExpectedWatchCounts(expectedCounts); const pressedStates = await this.readReactionPressedStates(); assertReactionPressedStates(pressedStates, expectedState); const count = await this.waitForDislikeText(); assert.match(count, /\d/, "The dislike control has no rendered numeric count."); + const countAudit = await this.assertRenderedDislikeCount(count, expectedCounts.dislikes, runtime); let visual; if (!isShort) { - visual = await this.captureWatchRatioVisual(runtime, screenshotPath); - } else if (runtime === "userscript") { - visual = await this.captureSyntheticShortsVisual(videoId, screenshotPath); + visual = await this.captureWatchRatioVisual(runtime, screenshotPath, { + expectedCounts, + expectedVideoId: videoId, + }); } else { - visual = await this.captureNativeShortsVisual(videoId, screenshotPath); + const captureShortsVisual = { + "native-pair": () => this.captureNativeShortsVisual(videoId, screenshotPath), + "strict-synthetic": () => this.captureSyntheticShortsVisual(videoId, screenshotPath), + }[shortsVisualModel]; + assert.equal( + typeof captureShortsVisual, + "function", + `Unsupported reaction Shorts visual model: ${shortsVisualModel ?? "missing"}.`, + ); + visual = await captureShortsVisual(); } return { ...visual, count, + countAudit, + expectedCounts, expectedState, isShort, pressedStates, @@ -1470,34 +4266,17 @@ class LiveYoutubeDriver { } async assertSignedIn(expectedChannel) { - this.reportProgress("signed-in-account.waiting", { expectedChannel }); - const avatar = await firstVisible(this.page.locator("#avatar-btn"), "signed-in YouTube account avatar"); - await avatar.click(); - try { - await waitForValue( - () => - this.page.locator("ytd-multi-page-menu-renderer a[href], tp-yt-iron-dropdown a[href]").evaluateAll( - (links, channel) => - links.filter((link) => { - const rect = link.getBoundingClientRect(); - if (rect.width <= 0 || rect.height <= 0) return false; - try { - return ( - new URL(link.getAttribute("href"), location.origin).pathname.replace(/\/$/, "") === `/${channel}` - ); - } catch { - return false; - } - }).length, - expectedChannel, - ), - (matches) => matches === 1, - `Timed out waiting for the signed-in account menu entry for ${expectedChannel}`, - ); - } finally { - await this.page.keyboard.press("Escape"); - } - this.reportProgress("signed-in-account.confirmed", { expectedChannel }); + const normalizedExpectedChannel = normalizeChannelHandle(expectedChannel); + this.reportProgress("signed-in-account.waiting", { expectedChannel: normalizedExpectedChannel }); + assert.equal( + this.authenticatedHandle, + normalizedExpectedChannel, + `The live driver is not bound to the authenticated browser context for ${normalizedExpectedChannel}.`, + ); + const documentState = await this.page.evaluate(inspectYoutubeSessionDocument); + assert.equal(documentState?.committed, true, "The current live page is not a committed HTTPS YouTube document."); + assert.equal(documentState?.configuredLoggedIn, true, "The current YouTube page reports LOGGED_IN is not true."); + this.reportProgress("signed-in-account.confirmed", { expectedChannel: normalizedExpectedChannel }); } async assertRuntime(runtime, expectedVersion, expectedBuildId) { @@ -1528,133 +4307,334 @@ class LiveYoutubeDriver { this.reportProgress("runtime.confirmed", { expectedBuildId, expectedVersion, runtime }); } - async assertCurrentShortsControl(videoId, runtime) { + async assertCurrentShortsControl(videoId, runtime, { expectedDislikes = null, presenceTimeoutMs = 20_000 } = {}) { this.reportProgress("shorts-control.waiting", { runtime, videoId }); this.assertCurrentVideo(videoId); assert.ok(this.page.url().includes("/shorts/"), "The current live page is not a Shorts page."); - const button = await this.visibleDislikeButton(); - const [count, measurement] = await Promise.all([ + const button = await this.visibleDislikeButton({ timeout: presenceTimeoutMs }); + const [count, measurement, identityState] = await Promise.all([ button.evaluate(readDislikeControlText), button.evaluate( (element, settings) => { const syntheticHost = element.closest(settings.syntheticSelector); const reel = element.closest("ytd-reel-video-renderer, ytm-reel-video-renderer"); - const expectedPath = `/shorts/${settings.videoId}`; - const rendererVideoId = reel?.getAttribute("video-id"); - const videoMatches = rendererVideoId - ? rendererVideoId === settings.videoId - : [...(reel?.querySelectorAll('a[href*="/shorts/"]') ?? [])].some((link) => { - try { - return new URL(link.getAttribute("href"), location.origin).pathname === expectedPath; - } catch { - return false; - } - }); - const visibleSyntheticControls = [...(reel?.querySelectorAll(settings.syntheticSelector) ?? [])].filter( - (control) => { - const rect = control.getBoundingClientRect(); - return ( - rect.width > 0 && - rect.height > 0 && - rect.bottom > 0 && - rect.right > 0 && - rect.top < innerHeight && - rect.left < innerWidth - ); - }, - ); - const actionBar = element.closest("reel-action-bar-view-model, ytd-reel-player-overlay-renderer"); - const visibleActionButtons = [...(actionBar?.querySelectorAll("button") ?? [])].filter((control) => { + const visibleInViewport = (control) => { + if (control.closest("[hidden], [aria-hidden='true'], [inert]")) return false; + let effectiveOpacity = 1; + for (let current = control; current; current = current.parentElement) { + const style = getComputedStyle(current); + if (style.display === "none" || style.visibility === "hidden" || style.visibility === "collapse") { + return false; + } + const opacity = Number.parseFloat(style.opacity); + if (Number.isFinite(opacity)) effectiveOpacity *= opacity; + } + if (effectiveOpacity <= 0.01) return false; const rect = control.getBoundingClientRect(); - const style = getComputedStyle(control); return ( rect.width > 0 && rect.height > 0 && rect.bottom > 0 && rect.right > 0 && rect.top < innerHeight && - rect.left < innerWidth && - style.display !== "none" && - style.visibility !== "hidden" + rect.left < innerWidth ); - }); + }; + const visibleSyntheticControls = [...(reel?.querySelectorAll(settings.syntheticSelector) ?? [])].filter( + visibleInViewport, + ); + const visibleDocumentSyntheticControls = [...document.querySelectorAll(settings.syntheticSelector)].filter( + visibleInViewport, + ); + const actionBar = element.closest("reel-action-bar-view-model, ytd-reel-player-overlay-renderer"); + const activationSelector = "button, a[role='button'][tabindex='0'], tp-yt-paper-button#button"; + const visibleActionButtons = [...(actionBar?.children ?? [])] + .map((host) => (host.matches(activationSelector) ? host : host.querySelector(activationSelector))) + .filter((control) => control && visibleInViewport(control)); + const hitTestedAtCenter = (control) => { + const rect = control.getBoundingClientRect(); + const x = Math.min(innerWidth - 1, Math.max(0, rect.left + rect.width / 2)); + const y = Math.min(innerHeight - 1, Math.max(0, rect.top + rect.height / 2)); + const hit = + typeof document.elementFromPoint === "function" + ? document.elementFromPoint(x, y) + : document.elementsFromPoint?.(x, y)?.[0] ?? null; + return hit === control || control.contains(hit); + }; + const actionButtonHitTests = visibleActionButtons.map(hitTestedAtCenter); return { actionLabels: visibleActionButtons.map( (control) => control.getAttribute("aria-label") ?? control.textContent?.trim() ?? "", ), + hitTestedActionButtons: actionButtonHitTests.filter(Boolean).length, + nonHitTestedActionLabels: visibleActionButtons + .filter((_control, index) => !actionButtonHitTests[index]) + .map((control) => control.getAttribute("aria-label") ?? control.textContent?.trim() ?? ""), pressed: element.getAttribute("aria-pressed"), synthetic: syntheticHost !== null, syntheticVideoId: syntheticHost?.getAttribute("data-ryd-video-id") ?? null, - videoMatches, + activeActionBarSyntheticControls: actionBar?.querySelectorAll(settings.syntheticSelector).length ?? -1, + currentReelSyntheticControls: reel?.querySelectorAll(settings.syntheticSelector).length ?? -1, + visibleDocumentSyntheticControls: visibleDocumentSyntheticControls.length, + visibleStaleSyntheticControls: visibleDocumentSyntheticControls.filter( + (control) => control.getAttribute("data-ryd-video-id") !== settings.videoId, + ).length, visibleSyntheticControls: visibleSyntheticControls.length, visibleActionButtons: visibleActionButtons.length, }; }, { syntheticSelector: SYNTHETIC_SHORTS_SELECTOR, videoId }, ), + button.evaluate(readShortsRendererIdentityState, { expectedShortVideoId: videoId }), ]); - assert.equal(measurement.videoMatches, true, `The rendered Shorts control is not owned by video ${videoId}.`); + assert.equal(identityState.videoMatches, true, `The rendered Shorts control is not owned by video ${videoId}.`); assert.ok( ["true", "false"].includes(measurement.pressed), "The current Shorts control has no valid pressed state.", ); - assert.match(count, /\d/, "The current Shorts dislike control has no rendered count."); + assert.match(count, RENDERED_NUMBER_PATTERN, "The current Shorts dislike control has no rendered count."); + let countAudit = null; + if (expectedDislikes !== null) { + const locale = await this.page.evaluate(() => document.documentElement.lang || navigator.language || "en"); + countAudit = assertRenderedDislikeCountMatchesApi(count, expectedDislikes, locale, runtime); + } assert.ok( measurement.visibleActionButtons >= 5, `The current Shorts reel rendered only ${measurement.visibleActionButtons} visible action controls: ${measurement.actionLabels.join( ", ", )}`, ); - if (runtime === "userscript") { - assert.equal(measurement.synthetic, true, "The userscript did not render its synthetic current Shorts control."); + if (runtime === "userscript" || runtime === "extension") { + assert.equal(measurement.synthetic, true, `The ${runtime} did not render its synthetic current Shorts control.`); assert.equal(measurement.syntheticVideoId, videoId, "The synthetic Shorts control targets a stale video ID."); + assert.equal( + measurement.activeActionBarSyntheticControls, + 1, + `The current Shorts action stack must contain exactly one ${runtime} synthetic dislike control.`, + ); + assert.equal( + measurement.currentReelSyntheticControls, + 1, + `The current Shorts reel must contain exactly one ${runtime} synthetic dislike control, including hidden controls.`, + ); assert.equal( measurement.visibleSyntheticControls, 1, - "The current Shorts reel must contain exactly one visible userscript synthetic dislike control.", + `The current Shorts reel must contain exactly one visible ${runtime} synthetic dislike control.`, + ); + assert.equal( + measurement.visibleStaleSyntheticControls, + 0, + `The viewport contains a visible ${runtime} synthetic dislike control for a stale video ID.`, + ); + assert.equal( + measurement.visibleDocumentSyntheticControls, + 1, + `The viewport must contain exactly one visible ${runtime} synthetic dislike control.`, + ); + assert.equal( + measurement.hitTestedActionButtons, + measurement.visibleActionButtons, + `Every visible Shorts action must be hit-testable at its center; blocked controls: ${measurement.nonHitTestedActionLabels.join( + ", ", + )}.`, ); } const result = { + activeActionBarSyntheticControls: measurement.activeActionBarSyntheticControls, actionLabels: measurement.actionLabels, count, + countAudit, + currentReelSyntheticControls: measurement.currentReelSyntheticControls, + hitTestedActionButtons: measurement.hitTestedActionButtons, + nonHitTestedActionLabels: measurement.nonHitTestedActionLabels, synthetic: measurement.synthetic, + syntheticVideoId: measurement.syntheticVideoId, videoId, visibleActionButtons: measurement.visibleActionButtons, + visibleDocumentSyntheticControls: measurement.visibleDocumentSyntheticControls, + visibleStaleSyntheticControls: measurement.visibleStaleSyntheticControls, + visibleSyntheticControls: measurement.visibleSyntheticControls, }; this.reportProgress("shorts-control.confirmed", result); return result; } - async withVotesResponse(videoId, action) { - this.reportProgress("ryd-votes-response.waiting", { videoId }); - const responsePromise = this.context.waitForEvent("response", { - predicate: (response) => { - const url = new URL(response.url()); - if (url.origin !== API_ORIGIN || url.pathname !== "/votes" || url.searchParams.get("videoId") !== videoId) { - return false; - } - try { - return response.request().frame().page() === this.page; - } catch { - return false; - } - }, - timeout: 30_000, + async soakCurrentShortsControl( + videoId, + runtime, + expectedDislikes, + { + durationMs = SHORTS_CONTROL_SOAK_DURATION_MS, + intervalMs = SHORTS_CONTROL_SOAK_INTERVAL_MS, + minimumSamples = 2, + presenceTimeoutMs = 2_000, + } = {}, + ) { + assert.match(videoId ?? "", VIDEO_ID_PATTERN, "A valid current video ID is required for the Shorts control soak."); + assert.ok(Number.isFinite(durationMs) && durationMs >= 0, "The Shorts control soak duration must be non-negative."); + assert.ok(Number.isFinite(intervalMs) && intervalMs > 0, "The Shorts control soak interval must be positive."); + assert.ok( + Number.isSafeInteger(minimumSamples) && minimumSamples >= 2, + "The Shorts control soak needs at least two samples.", + ); + assert.ok( + Number.isFinite(presenceTimeoutMs) && presenceTimeoutMs > 0, + "The Shorts control soak presence timeout must be positive.", + ); + + const deadline = Date.now() + durationMs; + const samples = []; + this.reportProgress("shorts-control-soak.start", { durationMs, expectedDislikes, runtime, videoId }); + do { + this.assertCurrentVideo(videoId); + samples.push( + await this.assertCurrentShortsControl(videoId, runtime, { + expectedDislikes, + presenceTimeoutMs, + }), + ); + const remaining = deadline - Date.now(); + if (samples.length >= minimumSamples && remaining <= 0) break; + await delay(Math.max(1, Math.min(intervalMs, Math.max(remaining, 1)))); + } while (true); + this.reportProgress("shorts-control-soak.complete", { + durationMs, + expectedDislikes, + runtime, + sampleCount: samples.length, + videoId, }); - const [response, result] = await Promise.all([responsePromise, action()]); - assert.ok(response.ok(), `The production /votes request for ${videoId} failed with HTTP ${response.status()}.`); - const body = await response.json(); - assert.equal(typeof body.dislikes, "number", `The production /votes response for ${videoId} has no dislike count.`); - this.reportProgress("ryd-votes-response.received", { status: response.status(), videoId }); - return { body, result }; + return { durationMs, expectedDislikes, sampleCount: samples.length, samples, videoId }; + } + + async withExactVotesResponse(videoId, action, { quietMs = VOTES_RESPONSE_REQUEST_QUIET_MS } = {}) { + assert.match(videoId ?? "", VIDEO_ID_PATTERN, "A valid destination video ID is required for the /votes audit."); + assert.equal(typeof action, "function", "A navigation action is required for the /votes response audit."); + assert.ok(Number.isFinite(quietMs) && quietMs >= 0, "The /votes request quiet period must be non-negative."); + + const baselineRequestId = this.nextVotesRequestId; + const requests = []; + const requestsByObject = new WeakMap(); + let lastActivityAt = Date.now(); + const onRequest = (request) => { + const url = new URL(request.url()); + if (url.origin !== API_ORIGIN || url.pathname !== "/votes") return; + const attribution = attributeRuntimeRequest( + request, + this.page, + this.requestAttributionRuntime, + this.selectedExtensionId, + this.expectedBuildId, + ); + if (!attribution) return; + this.claimAttributedTraffic(request, "exact-votes-response"); + const requestVideoId = url.searchParams.get("videoId"); + const record = { + ...attribution, + method: request.method(), + requestId: this.identifyVotesRequest(request), + response: null, + responseBody: undefined, + responseStatus: null, + sameVideoOtherPagesAtRequest: snapshotSameVideoOtherPages(this.context, this.page, attribution, requestVideoId), + url: url.toString(), + videoId: requestVideoId, + }; + requests.push(record); + requestsByObject.set(request, record); + lastActivityAt = Date.now(); + }; + const onResponse = (response) => { + const record = requestsByObject.get(response.request()); + if (!record) return; + record.response = response; + record.responseStatus = response.status(); + lastActivityAt = Date.now(); + }; + + this.reportProgress("ryd-votes-response.waiting", { baselineRequestId, videoId }); + this.context.on("request", onRequest); + this.context.on("response", onResponse); + let result; + try { + result = await action(); + await waitForValue( + () => + Promise.resolve({ + allResponsesReceived: requests.length > 0 && requests.every((request) => request.response !== null), + quietForMs: Date.now() - lastActivityAt, + requestCount: requests.length, + responseCount: requests.filter((request) => request.response !== null).length, + }), + (state) => state.allResponsesReceived && state.quietForMs >= quietMs, + `Timed out waiting for new attributed production /votes traffic for ${videoId} to finish and settle`, + 30_000, + ); + } finally { + this.context.off("request", onRequest); + this.context.off("response", onResponse); + } + + const allowLikeCountRefinement = this.requestAttributionRuntime === "extension"; + const requestSequence = assertExactVotesRequestSequenceAudit(requests, videoId, { + allowLikeCountRefinement, + }); + assertWorkerRequestAttributionIsUnambiguous(this.context, this.page, requests, videoId); + for (const request of requestSequence) { + assert.equal( + request.responseStatus, + 200, + `The production /votes request for ${videoId} returned HTTP ${request.responseStatus ?? "no response"}.`, + ); + try { + request.responseBody = await request.response.json(); + } catch (error) { + throw new Error(`The production /votes response for ${videoId} is not valid JSON. ${error.message}`, { + cause: error, + }); + } + } + const request = assertExactVotesResponseAudit(requests, videoId, baselineRequestId, { + allowLikeCountRefinement, + }); + const responseRecord = { + method: request.method, + requestId: request.requestId, + source: request.source, + url: request.url, + videoId: request.videoId, + workerUrl: request.workerUrl, + }; + this.reportProgress("ryd-votes-response.received", { + baselineRequestId, + requestCount: requests.length, + requestIds: requests.map((candidate) => candidate.requestId), + requestId: request.requestId, + source: request.source, + status: request.responseStatus, + videoId, + }); + return { + body: request.responseBody, + request: responseRecord, + result, + status: request.responseStatus, + videoId, + }; } async navigateFromColdChannel(channelUrl, videoId, kind) { - const expectedChannelUrl = new URL(channelUrl); - this.reportProgress("cold-channel.load.start", { channelUrl, kind, videoId }); - await this.page.goto(channelUrl, { waitUntil: "domcontentloaded" }); + const expectedChannelUrl = channelTabUrl(channelUrl, kind); + this.reportProgress("cold-channel.load.start", { + channelTabUrl: expectedChannelUrl.toString(), + channelUrl, + kind, + videoId, + }); + await this.page.goto(expectedChannelUrl.toString(), { waitUntil: "domcontentloaded" }); await this.page.reload({ waitUntil: "domcontentloaded" }); const actualChannelUrl = new URL(this.page.url()); assert.equal(actualChannelUrl.origin, expectedChannelUrl.origin, "The cold channel load left youtube.com."); @@ -1666,63 +4646,81 @@ class LiveYoutubeDriver { this.reportProgress("cold-channel.load.complete", { channelUrl: this.page.url(), kind, videoId }); this.reportProgress("cold-channel.target-link.waiting", { kind, videoId }); - const target = await firstVisibleExactVideoLink(this.page, videoId, kind); + await firstVisibleExactVideoLink(this.page, videoId, kind); this.reportProgress("cold-channel.target-link.found", { kind, videoId }); const documentMarker = await this.page.evaluate(() => { const value = `${Date.now()}-${Math.random()}`; globalThis.__rydLiveSpaDocumentMarker = value; return value; }); - await this.withVotesResponse(videoId, async () => { - this.reportProgress("cold-channel.target-link.clicking", { kind, videoId }); - await target.scrollIntoViewIfNeeded(); - await Promise.all([this.page.waitForURL((url) => videoIdFromUrl(url.toString()) === videoId), target.click()]); + const navigate = async () => { + const target = { kind, origin: expectedChannelUrl.origin, videoId }; + this.reportProgress("cold-channel.target-link.clicking", { activation: "trusted-pointer", kind, videoId }); + await clickWithSingleNavigationRetry({ + click: async (attempt, timeout) => { + const activation = attempt === 1 ? "trusted-pointer" : "keyboard"; + this.reportProgress("cold-channel.target-link.activating", { activation, attempt, kind, videoId }); + return clickStabilizedExactVideoLink(this.page, videoId, kind, { + activation, + label: `cold-channel ${kind} link for ${videoId}`, + origin: expectedChannelUrl.origin, + timeout, + }); + }, + failureMessage: `YouTube did not navigate to the exact channel ${kind} target ${videoId} after a hit-tested pointer activation or its single keyboard retry.`, + firstTimeout: CHANNEL_TARGET_FIRST_NAVIGATION_TIMEOUT, + hasNavigated: () => isExactVideoUrl(this.page.url(), target), + reportProgress: this.reportProgress, + retryDetails: { kind, videoId }, + retryProgressEvent: "cold-channel.target-link.retrying", + retryTimeout: CHANNEL_TARGET_RETRY_NAVIGATION_TIMEOUT, + waitForNavigation: (timeout) => + this.page.waitForURL((url) => isExactVideoUrl(url, target), { timeout, waitUntil: "commit" }), + }); await this.waitForVideo(videoId); - }); + return { videoId }; + }; + const navigation = + kind === "short" + ? await this.withShortsSampleVotesResponse(navigate, { + allowLikeCountRefinement: this.requestAttributionRuntime === "extension", + }) + : await this.withExactVotesResponse(videoId, navigate); assert.equal( await this.page.evaluate(() => globalThis.__rydLiveSpaDocumentMarker), documentMarker, `The channel-to-${kind} transition replaced the document instead of using YouTube SPA navigation.`, ); this.reportProgress("cold-channel.navigation.confirmed", { kind, url: this.page.url(), videoId }); + return navigation; } async navigateFromColdChannelToShort(channelUrl, videoId) { - await this.navigateFromColdChannel(channelUrl, videoId, "short"); + return this.navigateFromColdChannel(channelUrl, videoId, "short"); } async navigateFromColdChannelToWatch(channelUrl, videoId) { - await this.navigateFromColdChannel(channelUrl, videoId, "watch"); + return this.navigateFromColdChannel(channelUrl, videoId, "watch"); } - async navigateToNextShort(previousVideoId) { + async navigateToNextShort(previousVideoId, sampleAuditOptions = {}) { this.assertCurrentVideo(previousVideoId); - this.reportProgress("shorts-next-control.waiting", { previousVideoId }); - const nextButton = await firstVisible(this.page.locator(SHORTS_NEXT_BUTTON_SELECTOR), "Shorts Next video button", { - expectedShortVideoId: previousVideoId, - requireViewport: true, - }); - this.reportProgress("shorts-next-control.found", { previousVideoId }); const documentMarker = await this.page.evaluate(() => { const value = `${Date.now()}-${Math.random()}`; globalThis.__rydLiveSpaDocumentMarker = value; return value; }); - const votesResponses = []; - const onResponse = (response) => { - const url = new URL(response.url()); - if (url.origin !== API_ORIGIN || url.pathname !== "/votes") return; - try { - if (response.request().frame().page() !== this.page) return; - } catch { - return; - } - votesResponses.push(response); - }; - - this.context.on("response", onResponse); - let nextVideoId; - try { + const navigation = await this.withShortsSampleVotesResponse(async () => { + this.reportProgress("shorts-next-control.waiting", { previousVideoId }); + const nextButton = await firstVisible( + this.page.locator(SHORTS_NEXT_BUTTON_SELECTOR), + "Shorts Next video button", + { + expectedShortVideoId: previousVideoId, + requireViewport: true, + }, + ); + this.reportProgress("shorts-next-control.found", { previousVideoId }); const isNextShortUrl = (value) => { const url = value instanceof URL ? value : new URL(value); const candidate = videoIdFromUrl(url.toString()); @@ -1733,39 +4731,23 @@ class LiveYoutubeDriver { await clickWithSingleNavigationRetry({ click: async (attempt, timeout) => { this.reportProgress("shorts-next-control.clicking", { attempt, previousVideoId }); - await nextButton.scrollIntoViewIfNeeded({ timeout }); - await nextButton.click({ timeout }); + await clickHitTestedElement(nextButton, { + beforeClick: () => this.assertCurrentVideo(previousVideoId), + label: `Shorts Next video button after ${previousVideoId}`, + timeout, + }); }, hasNavigated: () => isNextShortUrl(this.page.url()), reportProgress: this.reportProgress, retryDetails: { previousVideoId }, - waitForNavigation: (timeout) => this.page.waitForURL(isNextShortUrl, { timeout }), + waitForNavigation: (timeout) => this.page.waitForURL(isNextShortUrl, { timeout, waitUntil: "commit" }), }); - nextVideoId = videoIdFromUrl(this.page.url()); + const nextVideoId = videoIdFromUrl(this.page.url()); assert.match(nextVideoId, VIDEO_ID_PATTERN, "The Shorts Next video navigation produced an invalid video ID."); assert.notEqual(nextVideoId, previousVideoId, "The Shorts Next video control did not advance to a new video."); await this.waitForVideoUrl(nextVideoId); - - this.reportProgress("ryd-votes-response.waiting", { videoId: nextVideoId }); - const response = await waitForValue( - () => - Promise.resolve( - votesResponses.find((candidate) => new URL(candidate.url()).searchParams.get("videoId") === nextVideoId), - ), - Boolean, - `Timed out waiting for the production /votes response for next Short ${nextVideoId}`, - 30_000, - ); - assert.ok( - response.ok(), - `The production /votes request for ${nextVideoId} failed with HTTP ${response.status()}.`, - ); - const body = await response.json(); - assert.equal(typeof body.dislikes, "number", `The production /votes response for ${nextVideoId} has no count.`); - this.reportProgress("ryd-votes-response.received", { status: response.status(), videoId: nextVideoId }); - } finally { - this.context.off("response", onResponse); - } + return { videoId: nextVideoId }; + }, sampleAuditOptions); assert.equal( await this.page.evaluate(() => globalThis.__rydLiveSpaDocumentMarker), @@ -1775,9 +4757,9 @@ class LiveYoutubeDriver { this.reportProgress("shorts-next-navigation.confirmed", { previousVideoId, url: this.page.url(), - videoId: nextVideoId, + videoId: navigation.videoId, }); - return nextVideoId; + return navigation; } async waitForVideoUrl(videoId) { @@ -1804,37 +4786,63 @@ class LiveYoutubeDriver { } async openPlaylist(url, videoId) { - return this.withVotesResponse(videoId, async () => { + return this.withExactVotesResponse(videoId, async () => { await this.page.goto(url, { waitUntil: "domcontentloaded" }); await this.waitForVideo(videoId); }); } async openWatch(videoId) { - return this.withVotesResponse(videoId, async () => { + return this.withExactVotesResponse(videoId, async () => { await this.page.goto(`https://www.youtube.com/watch?v=${videoId}`, { waitUntil: "domcontentloaded" }); await this.waitForVideo(videoId); }); } async reload(videoId) { - return this.withVotesResponse(videoId, async () => { + return this.withExactVotesResponse(videoId, async () => { await this.page.reload({ waitUntil: "domcontentloaded" }); await this.waitForVideo(videoId); }); } - async navigateWithinPlaylist(videoId) { - const links = this.page.locator(`a[href*="watch?v=${videoId}"]`); - const link = await firstVisible(links, `playlist link for ${videoId}`); + async navigateWithinPlaylist(target = {}) { + const currentVideoId = videoIdFromUrl(this.page.url()); + assert.match(currentVideoId, VIDEO_ID_PATTERN, "Playlist navigation must start on a watch video."); + const dynamicTarget = typeof target !== "string"; + const targetOptions = target && typeof target === "object" ? target : {}; + const excludedVideoIds = dynamicTarget ? [...new Set(targetOptions.excludedVideoIds ?? [])] : []; + for (const excludedVideoId of excludedVideoIds) { + assert.match(excludedVideoId, VIDEO_ID_PATTERN, "Playlist navigation exclusions must be valid video IDs."); + } + const videoId = dynamicTarget + ? ( + await firstActionableUnvisitedWatchLink( + this.page, + PLAYLIST_WATCH_LINK_SELECTOR, + currentVideoId, + [...new Set([...excludedVideoIds, currentVideoId])], + "playlist watch link", + ) + ).videoId + : target; + assert.match(videoId, VIDEO_ID_PATTERN, "Playlist navigation requires a valid destination video ID."); + assert.notEqual(videoId, currentVideoId, "Playlist navigation must select a different video."); + assert.ok(!excludedVideoIds.includes(videoId), `Playlist navigation selected excluded video ${videoId}.`); const documentMarker = await this.page.evaluate(() => { const value = `${Date.now()}-${Math.random()}`; globalThis.__rydLiveSpaDocumentMarker = value; return value; }); - await this.withVotesResponse(videoId, async () => { - await link.scrollIntoViewIfNeeded(); - await Promise.all([this.page.waitForURL((url) => videoIdFromUrl(url.toString()) === videoId), link.click()]); + const navigation = await this.withExactVotesResponse(videoId, async () => { + await Promise.all([ + this.page.waitForURL((url) => videoIdFromUrl(url.toString()) === videoId, { waitUntil: "commit" }), + clickStabilizedExactVideoLink(this.page, videoId, "watch", { + label: `playlist link for ${videoId}`, + origin: new URL(this.page.url()).origin, + selector: PLAYLIST_WATCH_LINK_SELECTOR, + }), + ]); await this.waitForVideo(videoId); }); assert.equal( @@ -1842,6 +4850,7 @@ class LiveYoutubeDriver { documentMarker, "The playlist transition replaced the document instead of using YouTube SPA navigation.", ); + return { ...navigation, videoId }; } async navigateToRelatedWatch(excludedVideoIds = []) { @@ -1853,9 +4862,14 @@ class LiveYoutubeDriver { } this.reportProgress("related-watch-link.waiting", { currentVideoId, excludedVideoIds: exclusions }); - const { link, videoId } = await firstVisibleRelatedWatchLink(this.page, currentVideoId, [ - ...new Set([...exclusions, currentVideoId]), - ]); + const relatedExclusions = [...new Set([...exclusions, currentVideoId])]; + const { videoId } = await firstActionableUnvisitedWatchLink( + this.page, + RELATED_WATCH_LINK_SELECTOR, + currentVideoId, + relatedExclusions, + "#related watch link", + ); this.reportProgress("related-watch-link.found", { currentVideoId, videoId }); const documentMarker = await this.page.evaluate(() => { const value = `${Date.now()}-${Math.random()}`; @@ -1863,16 +4877,17 @@ class LiveYoutubeDriver { return value; }); - const { body } = await this.withVotesResponse(videoId, async () => { - await link.scrollIntoViewIfNeeded(); - const targetVideoId = await link.evaluate(relatedWatchVideoId, { - currentVideoId, - excludedVideoIds: [...new Set([...exclusions, currentVideoId])], - origin: new URL(this.page.url()).origin, - }); - assert.equal(targetVideoId, videoId, "The selected #related link changed before it could be clicked."); + const navigation = await this.withExactVotesResponse(videoId, async () => { + assert.ok(!relatedExclusions.includes(videoId), `The selected #related video ${videoId} is excluded.`); this.reportProgress("related-watch-link.clicking", { currentVideoId, videoId }); - await Promise.all([this.page.waitForURL((url) => videoIdFromUrl(url.toString()) === videoId), link.click()]); + await Promise.all([ + this.page.waitForURL((url) => videoIdFromUrl(url.toString()) === videoId, { waitUntil: "commit" }), + clickStabilizedExactVideoLink(this.page, videoId, "watch", { + label: `#related watch link for ${videoId}`, + origin: new URL(this.page.url()).origin, + selector: RELATED_WATCH_LINK_SELECTOR, + }), + ]); await this.waitForVideo(videoId); }); @@ -1882,31 +4897,31 @@ class LiveYoutubeDriver { "The #related watch transition replaced the document instead of using YouTube SPA navigation.", ); this.reportProgress("related-watch-navigation.confirmed", { - apiDislikes: body.dislikes, + apiDislikes: navigation.body.dislikes, currentVideoId, url: this.page.url(), videoId, }); - return { body, videoId }; + return { ...navigation, videoId }; } async openShort(videoId) { - return this.withVotesResponse(videoId, async () => { + return this.withExactVotesResponse(videoId, async () => { await this.page.goto(`https://www.youtube.com/shorts/${videoId}`, { waitUntil: "domcontentloaded" }); await this.waitForVideo(videoId); }); } - async visibleDislikeButton() { - return this.visibleActionButton("dislike"); + async visibleDislikeButton(options) { + return this.visibleActionButton("dislike", options); } - async visibleLikeButton() { - return this.visibleActionButton("like"); + async visibleLikeButton(options) { + return this.visibleActionButton("like", options); } - async visibleActionButton(action) { - const selectors = ACTION_BUTTON_SELECTORS[action]; + async visibleActionButton(action, { timeout = 20_000 } = {}) { + const selectors = VISIBLE_ACTION_BUTTON_SELECTORS[action]; if (!selectors) throw new Error(`Unsupported YouTube reaction action: ${action}`); const isShort = this.page.url().includes("/shorts/"); @@ -1916,11 +4931,21 @@ class LiveYoutubeDriver { : this.page .locator(`ytd-watch-flexy[video-id="${videoId}"], ytd-watch-grid[video-id="${videoId}"]`) .locator(selectors); - return firstVisible(locator, `YouTube ${action} button`, { + const button = await firstVisible(locator, `YouTube ${action} button`, { expectedShortVideoId: isShort ? videoIdFromUrl(this.page.url()) : null, requireActiveShort: isShort, + requireEnabled: true, + // A valid Watch action row can initially be below the fold (for example + // on a 639px-high authenticated desktop viewport). Rejecting that button + // before scrolling made a healthy runtime look absent. Shorts must keep + // the viewport gate because YouTube retains multiple pre-rendered reels. requireViewport: isShort, + timeout, }); + if (!isShort) { + await prepareElementForViewportMeasurement(button, `YouTube ${action} button`, { timeout }); + } + return button; } async waitForDislikeText({ differentFrom = null } = {}) { @@ -1981,44 +5006,70 @@ class LiveYoutubeDriver { async clickAction(videoId, action) { this.assertCurrentVideo(videoId); - const selectors = ACTION_BUTTON_SELECTORS[action]; - if (!selectors) throw new Error(`Unsupported YouTube reaction action: ${action}`); - - const isShort = this.page.url().includes("/shorts/"); - const locator = isShort - ? this.page.locator(selectors) - : this.page - .locator(`ytd-watch-flexy[video-id="${videoId}"], ytd-watch-grid[video-id="${videoId}"]`) - .locator(selectors); - const button = await firstVisible(locator, `${action} button for allowlisted video ${videoId}`, { - expectedShortVideoId: isShort ? videoId : null, - requireActiveShort: isShort, - requireViewport: isShort, + const button = await this.visibleActionButton(action); + this.assertCurrentVideo(videoId); + await clickHitTestedElement(button, { + beforeClick: () => this.assertCurrentVideo(videoId), + label: `YouTube ${action} button for ${videoId}`, }); - this.assertCurrentVideo(videoId); - await button.scrollIntoViewIfNeeded(); - this.assertCurrentVideo(videoId); - await button.click(); } } module.exports = { + AttributedRuntimeTrafficLedger, LiveYoutubeDriver, + SHORTS_CONTROL_SOAK_DURATION_MS, + SHORTS_CONTROL_SOAK_INTERVAL_MS, + SHORTS_NATIVE_CONTROLS_TIMEOUT_MS, + READ_ONLY_INTERACTION_GUARD_CLEANUP_TIMEOUT_MS, VoteTrafficRecorder, WATCH_RATIO_SOAK_DURATION_MS, + assertDislikeCountChangesObservable, + assertExactVotesRequestAudit, + assertExactVotesResponseAudit, + assertWorkerRequestAttributionIsUnambiguous, + assertElementActionable, + assertElementReadyForViewportMeasurement, assertReactionPressedStates, + assertRenderedDislikeCountMatchesApi, assertLogicalVoteHandshake, assertNativeShortsPairGeometry, assertShortsActionStackGeometry, assertSyntheticShortsGeometry, + assertWatchActionTopologySnapshot, assertWatchRatioViewportAlignment, + assertWatchRatioSurroundings, + assertNavigationLinkActionable, + attributeRuntimeRequest, + channelTabUrl, + clickHitTestedElement, + clickHitTestedNavigationLink, + clickStabilizedExactVideoLink, clickWithSingleNavigationRetry, croppedScreenshotClip, firstVisibleRelatedWatchLink, + formattedDislikeCountCandidates, isShortCandidateEligible, isShortsIconVisualReady, + isExactVideoUrl, + normalizeRenderedCount, + parseLiveExtensionAcceptHeader, pointerPositionAwayFromBoxes, + prepareElementForViewportMeasurement, + readCurrentShortsNativeControlState, readDislikeControlText, + readElementActionability, + readNavigationLinkActionability, + scrollElementIntoViewAndWaitForPaint, + readOverflowMenuTopology, + readShortsReelDiagnosticState, + readShortsRendererIdentityState, + readWatchRatioAppearance, + readWatchTopRowTopology, + readWatchRatioSurroundings, relatedWatchVideoId, + requestVideoId, + snapshotSameVideoOtherPages, videoIdFromUrl, + withOperationTimeout, }; diff --git a/Extensions/UserScript/e2e/live/live-youtube.live.e2e.js b/Extensions/UserScript/e2e/live/live-youtube.live.e2e.js index cafaa62..262c818 100644 --- a/Extensions/UserScript/e2e/live/live-youtube.live.e2e.js +++ b/Extensions/UserScript/e2e/live/live-youtube.live.e2e.js @@ -4,39 +4,79 @@ const { createUserscriptLiveRuntimeAdapter, } = require("../../../e2e/live-runtime-adapter"); const { createSharedLiveScenarioRunner } = require("../../../e2e/shared-live-scenarios"); -const { consumeLiveVoteApproval, hasFreshVoteApproval, readLiveOptions } = require("../../live/live-options"); -const { LiveYoutubeDriver, VoteTrafficRecorder } = require("./live-youtube-driver"); +const { readLiveOptions } = require("../../live/live-options"); +const { selectAuthenticatedYoutubeContext } = require("./live-authenticated-context"); +const { LiveReadOnlyGate, LiveRunDiagnostics, runLoggedStage } = require("./live-diagnostics"); +const { reloadLiveExtensionInBrowser } = require("./live-extension-reloader"); +const { REQUIRED_INTERACTIVE_READ_ONLY_SCENARIO_IDS } = require("./live-interactive-scenario-contract"); +const { LiveYoutubeDriver } = require("./live-youtube-driver"); const options = readLiveOptions(); const scenarioRunner = createSharedLiveScenarioRunner(); +const REQUIRED_READ_ONLY_SCENARIOS = REQUIRED_INTERACTIVE_READ_ONLY_SCENARIO_IDS; -test.describe("live YouTube RYD smoke", () => { - test.skip(options === null, "Set RYD_LIVE_YOUTUBE=1 and the documented allowlist variables to opt in."); - test.describe.configure({ mode: "serial" }); +if (options === null) { + throw new Error( + "No live YouTube scenarios ran. Set RYD_LIVE_YOUTUBE=1 and every documented runtime/allowlist variable.", + ); +} +test.describe("live YouTube RYD read-only smoke", () => { let browser; let context; + let diagnostics; let page; let driver; let runtimeAdapter; + let selectedExtensionId = null; + let sessionPage; + let currentReadOnlyScenarioId = null; + const readOnlyGate = new LiveReadOnlyGate(REQUIRED_READ_ONLY_SCENARIOS); - test.beforeAll(async () => { + test.beforeAll(async ({}, testInfo) => { + testInfo.setTimeout(options.cdpConnectTimeoutMilliseconds + 15_000); try { browser = await chromium.connectOverCDP(options.cdpEndpoint, { isLocal: true, noDefaults: true, - timeout: 15_000, + timeout: options.cdpConnectTimeoutMilliseconds, }); } catch (error) { throw new Error( - `Could not attach to the running Brave/Chromium profile. Enable remote debugging at brave://inspect/#remote-debugging and set RYD_CDP_ENDPOINT to its explicit CDP endpoint, then retry. ${error.message}`, + `Could not attach to the running Chrome/Chromium profile. Enable remote debugging in the target browser and set RYD_CDP_ENDPOINT to its WebSocket, HTTP, host:port, or browser-alias endpoint, then retry. ${error.message}`, ); } - [context] = browser.contexts(); - if (!context) throw new Error("The attached Brave/Chromium browser has no default context."); + const selectedSession = await selectAuthenticatedYoutubeContext(browser, options.expectedChannel); + context = selectedSession.context; + sessionPage = selectedSession.sessionPage; + console.log( + `AUTHENTICATED_CHROMIUM_CONTEXT_SELECTED context=${selectedSession.contextIndex + 1} page=${selectedSession.pageIndex + 1} handle=${options.expectedChannel}`, + ); + if (options.runtime === "extension") { + const reloaded = await reloadLiveExtensionInBrowser({ + browser, + context, + expectedBuildId: options.expectedBuildId, + expectedVersion: options.expectedVersion, + sessionPage, + }); + selectedExtensionId = reloaded.extensionId; + console.log(`LIVE_EXTENSION_RELOADED ${JSON.stringify(reloaded)}`); + } + }); + + test.beforeEach(async () => { + currentReadOnlyScenarioId = null; page = await context.newPage(); - driver = new LiveYoutubeDriver(page, context); + diagnostics = new LiveRunDiagnostics(page, context, { runtime: options.runtime, selectedExtensionId }); + await diagnostics.start(); + driver = new LiveYoutubeDriver(page, context, { + authenticatedHandle: options.expectedChannel, + expectedBuildId: options.expectedBuildId, + reportProgress: (name, details) => diagnostics.checkpoint(name, details), + selectedExtensionId, + }); const createAdapter = options.runtime === "extension" ? createExtensionLiveRuntimeAdapter : createUserscriptLiveRuntimeAdapter; runtimeAdapter = createAdapter({ @@ -46,68 +86,119 @@ test.describe("live YouTube RYD smoke", () => { }); }); + test.afterEach(async ({}, testInfo) => { + if (!diagnostics) return; + let lateSignalError = null; + let snapshotError = null; + try { + if (driver?.trafficLedger) { + await driver.assertNoUnclaimedAttributedTraffic(`Playwright scenario ${testInfo.title}`); + } + } catch (error) { + lateSignalError = error; + } + try { + diagnostics.consumeFatalSignals(`after ${testInfo.title}`); + } catch (error) { + lateSignalError = lateSignalError + ? new AggregateError([lateSignalError, error], "Late traffic and browser diagnostics both failed.") + : error; + } + try { + if (lateSignalError || testInfo.status !== testInfo.expectedStatus) { + const error = + lateSignalError ?? testInfo.errors.at(-1) ?? new Error(`${testInfo.title} failed without an attached error.`); + const snapshotPath = await diagnostics.persistFailureSnapshot(error); + await testInfo.attach("live-failure-snapshot", { contentType: "application/json", path: snapshotPath }); + } + } catch (error) { + snapshotError = error; + } finally { + if (currentReadOnlyScenarioId) { + const completed = !lateSignalError && !snapshotError && testInfo.status === "passed"; + readOnlyGate.record(currentReadOnlyScenarioId, completed ? testInfo.status : "failed"); + } + driver?.stop(); + diagnostics.stop(); + if (page && !page.isClosed()) await page.close(); + diagnostics = null; + driver = null; + page = null; + runtimeAdapter = null; + } + if (lateSignalError && snapshotError) { + throw new AggregateError( + [lateSignalError, snapshotError], + "A late fatal browser signal and snapshot failure occurred.", + ); + } + if (lateSignalError) throw lateSignalError; + if (snapshotError) throw snapshotError; + }); + test.afterAll(async () => { - if (page && !page.isClosed()) await page.close(); - if (browser) await browser.close(); + try { + readOnlyGate.assertPassed(); + console.log( + `LIVE_VALIDATION_READ_ONLY_COMPLETE ${JSON.stringify({ + classification: "read-only", + productionReactionsCompleted: false, + releaseReady: false, + })}`, + ); + } finally { + if (browser) await browser.close(); + } }); - test(`${options?.runtime || "runtime"}: initializes after cold channel-to-Short and Next-video SPA navigation`, async () => { - test.setTimeout(150_000); - await scenarioRunner.run(runtimeAdapter, "channel-shorts-navigation", options); - }); - - test(`${options?.runtime || "runtime"}: initializes after optional cold channel-to-watch SPA navigation`, async () => { - test.setTimeout(120_000); - test.skip( - !options?.navigation.watch, - "Set RYD_LIVE_NAV_WATCH only when the configured channel page contains an exact visible link to that video.", + const runScenario = (scenarioId, services) => + runLoggedStage(diagnostics, `playwright.${scenarioId}`, () => + scenarioRunner.run(runtimeAdapter, scenarioId, options, services), ); - await scenarioRunner.run(runtimeAdapter, "channel-watch-navigation", options); + + const runReadOnlyScenario = (scenarioId) => { + currentReadOnlyScenarioId = scenarioId; + return runScenario(scenarioId); + }; + + test(`${options?.runtime || "runtime"}: renders on an allowlisted Short`, async () => { + await runReadOnlyScenario("shorts-render"); + }); + + test(`${options?.runtime || "runtime"}: audits ten-plus successful Shorts Next-video SPA samples`, async () => { + test.setTimeout(120_000 + options.navigation.shortsNextHops * 2 * 45_000); + await runReadOnlyScenario("channel-shorts-navigation"); + }); + + test(`${options?.runtime || "runtime"}: initializes after cold channel-to-watch SPA navigation`, async () => { + test.setTimeout(120_000); + await runReadOnlyScenario("channel-watch-navigation"); }); test(`${options?.runtime || "runtime"}: renders on a signed-in watch page`, async () => { - await scenarioRunner.run(runtimeAdapter, "watch-render", options); + await runReadOnlyScenario("watch-render"); }); test(`${options?.runtime || "runtime"}: reinitializes after reload`, async () => { - await scenarioRunner.run(runtimeAdapter, "reload", options); + await runReadOnlyScenario("reload"); }); test(`${options?.runtime || "runtime"}: reinitializes after real playlist SPA navigation`, async () => { - await scenarioRunner.run(runtimeAdapter, "spa-navigation", options); + await runReadOnlyScenario("spa-navigation"); + }); + + test(`${options?.runtime || "runtime"}: preserves the complete Watch action topology`, async () => { + test.setTimeout(240_000); + await runReadOnlyScenario("watch-action-topology"); }); test(`${options?.runtime || "runtime"}: survives consecutive real sidebar SPA navigations`, async () => { test.setTimeout(60_000 + options.sidebar.hopCount * 75_000); - await scenarioRunner.run(runtimeAdapter, "sidebar-navigation-stress", options); - }); - - test(`${options?.runtime || "runtime"}: renders on an allowlisted Short`, async () => { - await scenarioRunner.run(runtimeAdapter, "shorts-render", options); + await runReadOnlyScenario("sidebar-navigation-stress"); }); test(`${options?.runtime || "runtime"}: preserves reaction UI geometry across responsive widths`, async () => { test.setTimeout(180_000); - await scenarioRunner.run(runtimeAdapter, "responsive-visual", options); - }); - - test(`${options?.runtime || "runtime"}: covers all reaction transitions on watch and Shorts`, async () => { - test.setTimeout(0); - const voteApproval = process.env.RYD_LIVE_VOTES; - test.skip( - !hasFreshVoteApproval(voteApproval, options.runtime, options.watchB, Date.now()), - "Create the documented short-lived RYD_LIVE_VOTES token only after approving the real vote pair.", - ); - - await scenarioRunner.run(runtimeAdapter, "reaction-matrix", options, { - createRecorder: (videoId) => new VoteTrafficRecorder(context, videoId), - consumeVoteApproval: async () => { - driver.assertCurrentVideo(options.watchB); - await driver.assertRuntime(options.runtime, options.expectedVersion, options.expectedBuildId); - if (!consumeLiveVoteApproval(voteApproval, options.runtime, options.watchB)) { - throw new Error("The live reaction approval expired or was already used. No reaction was clicked."); - } - }, - }); + await runReadOnlyScenario("responsive-visual"); }); }); diff --git a/Extensions/UserScript/e2e/live/reload-live-extension.js b/Extensions/UserScript/e2e/live/reload-live-extension.js new file mode 100644 index 0000000..3ea9d13 --- /dev/null +++ b/Extensions/UserScript/e2e/live/reload-live-extension.js @@ -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 }; diff --git a/Extensions/UserScript/e2e/live/watch-ratio-audit.js b/Extensions/UserScript/e2e/live/watch-ratio-audit.js new file mode 100644 index 0000000..3f79fdf --- /dev/null +++ b/Extensions/UserScript/e2e/live/watch-ratio-audit.js @@ -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, +}; diff --git a/Extensions/UserScript/e2e/navigation-matrix.js b/Extensions/UserScript/e2e/navigation-matrix.js index 37a7f47..8262464 100644 --- a/Extensions/UserScript/e2e/navigation-matrix.js +++ b/Extensions/UserScript/e2e/navigation-matrix.js @@ -1,7 +1,23 @@ const { expect } = require("@playwright/test"); const { assertInvariantContinuously, waitForStableInvariant } = require("../../e2e/continuous-invariants"); +const { assertExactSuccessfulVotesTraffic } = require("../../e2e/hermetic-api-contract"); const { VIDEO_A, VIDEO_B } = require("./harness"); +const SINGLE_DESTINATION_DISLIKE_POSTCONDITION = "single-destination-dislike"; +const NO_DESTINATION_DISLIKE_POSTCONDITION = "no-destination-dislike"; + +function expectedDislikeText(counts, delta = 0) { + if (delta === 0 && typeof counts.displayedDislikes === "string") return counts.displayedDislikes; + if (delta === 1 && typeof counts.displayedDislikesAfterIncrement === "string") { + return counts.displayedDislikesAfterIncrement; + } + return String(counts.dislikes + delta); +} + +function expectedTooltipText({ dislikes, likes }) { + return `${likes.toLocaleString("en-US")} / ${dislikes.toLocaleString("en-US")}`; +} + const WATCH_SIDEBAR_MATRIX = [ { coverage: { @@ -15,6 +31,287 @@ const WATCH_SIDEBAR_MATRIX = [ destination: { counts: { dislikes: 300, likes: 100 }, kind: "shorts", videoId: VIDEO_B }, id: "short-next-short-active-reel", origin: { counts: { dislikes: 100, likes: 300 }, kind: "shorts", state: "neutral", videoId: VIDEO_A }, + postcondition: SINGLE_DESTINATION_DISLIKE_POSTCONDITION, + viewport: { height: 720, width: 1280 }, + }, + { + coverage: { + destination: "shorts", + dom: [ + "reuse-exact-renderer", + "replace-action-root", + "exact-href-identity", + "no-is-active", + "no-video-id", + "incomplete-rendered-native-inventory", + "non-rendered-native-action", + "persistent-data-null-action-root", + "stable-action-root-geometry", + ], + origin: "shorts", + timing: ["navigate-start-without-finish", "data-null-past-watchdog", "inert-beyond-fallback-window"], + trigger: "next-control", + width: "wide-desktop", + }, + destination: { + counts: { dislikes: 300, likes: 100 }, + kind: "shorts", + shortIdentity: "exact-href-without-active-or-video-id", + videoId: VIDEO_B, + }, + id: "short-next-short-persistent-data-null-nonrendered-native-stays-inert", + origin: { counts: { dislikes: 100, likes: 300 }, kind: "shorts", state: "neutral", videoId: VIDEO_A }, + postcondition: NO_DESTINATION_DISLIKE_POSTCONDITION, + timing: { inertForMs: 1_700 }, + transition: { + actionRoot: "replace-incomplete-rendered-native-persistent-data-null", + navigateFinish: "none", + renderer: "reuse-exact-node", + }, + viewport: { height: 720, width: 1280 }, + }, + { + coverage: { + destination: "shorts", + dom: [ + "reuse-exact-renderer", + "replace-action-root", + "exact-href-identity", + "no-is-active", + "no-video-id", + "native-dislike-present", + "no-synthetic-mutation", + "persistent-data-null-action-root", + ], + origin: "shorts", + timing: [ + "navigate-start-without-finish", + "native-dislike-after-stability", + "native-dislike-without-synthetic-mutation", + ], + trigger: "next-control", + width: "wide-desktop", + }, + destination: { + counts: { dislikes: 300, likes: 100 }, + kind: "shorts", + shortIdentity: "exact-href-without-active-or-video-id", + shortsDislikeControl: "native", + videoId: VIDEO_B, + }, + id: "short-next-short-persistent-data-null-native-dislike", + origin: { counts: { dislikes: 100, likes: 300 }, kind: "shorts", state: "neutral", videoId: VIDEO_A }, + postcondition: SINGLE_DESTINATION_DISLIKE_POSTCONDITION, + timing: { maxFirstValidMs: 2_500, unsafeWindowMs: 520 }, + transition: { + actionRoot: "replace-native-dislike-persistent-data-null", + navigateFinish: "none", + renderer: "reuse-exact-node", + }, + viewport: { height: 720, width: 1280 }, + }, + { + coverage: { + destination: "shorts", + dom: ["preloaded-sibling", "active-reel-switch"], + origin: "shorts", + timing: ["settled"], + trigger: "next-control", + width: "medium-desktop", + }, + destination: { counts: { dislikes: 300, likes: 100 }, kind: "shorts", videoId: VIDEO_B }, + id: "short-next-short-active-reel-medium", + origin: { counts: { dislikes: 100, likes: 300 }, kind: "shorts", state: "neutral", videoId: VIDEO_A }, + postcondition: SINGLE_DESTINATION_DISLIKE_POSTCONDITION, + viewport: { height: 720, width: 768 }, + }, + { + coverage: { + destination: "shorts", + dom: ["preloaded-sibling", "active-reel-switch"], + origin: "shorts", + timing: ["settled"], + trigger: "next-control", + width: "compact", + }, + destination: { counts: { dislikes: 300, likes: 100 }, kind: "shorts", videoId: VIDEO_B }, + id: "short-next-short-active-reel-compact", + origin: { counts: { dislikes: 100, likes: 300 }, kind: "shorts", state: "neutral", videoId: VIDEO_A }, + postcondition: SINGLE_DESTINATION_DISLIKE_POSTCONDITION, + viewport: { height: 844, width: 390 }, + }, + { + coverage: { + destination: "shorts", + dom: ["reuse-exact-renderer", "reuse-exact-action-root", "exact-href-identity", "no-is-active"], + origin: "shorts", + timing: ["navigate-start-without-finish"], + trigger: "next-control", + width: "wide-desktop", + }, + destination: { + counts: { dislikes: 300, likes: 100 }, + kind: "shorts", + shortIdentity: "exact-href-without-active-or-video-id", + videoId: VIDEO_B, + }, + id: "short-next-short-reuse-renderer-start-no-finish", + origin: { counts: { dislikes: 100, likes: 300 }, kind: "shorts", state: "neutral", videoId: VIDEO_A }, + postcondition: SINGLE_DESTINATION_DISLIKE_POSTCONDITION, + transition: { actionRoot: "reuse-exact-node", navigateFinish: "none", renderer: "reuse-exact-node" }, + viewport: { height: 720, width: 1280 }, + }, + { + coverage: { + destination: "shorts", + dom: [ + "reuse-exact-renderer", + "reuse-exact-action-root", + "exact-href-identity", + "unrelated-description-short-link", + "no-is-active", + "no-video-id", + ], + origin: "shorts", + timing: ["navigate-start-without-finish"], + trigger: "next-control", + width: "wide-desktop", + }, + destination: { + counts: { dislikes: 300, likes: 100 }, + kind: "shorts", + shortIdentity: "exact-href-without-active-or-video-id", + unrelatedDescriptionShortVideoId: "FupY92jTfho", + videoId: VIDEO_B, + }, + id: "short-next-short-exact-href-with-description-crosslink", + origin: { counts: { dislikes: 100, likes: 300 }, kind: "shorts", state: "neutral", videoId: VIDEO_A }, + postcondition: SINGLE_DESTINATION_DISLIKE_POSTCONDITION, + transition: { actionRoot: "reuse-exact-node", navigateFinish: "none", renderer: "reuse-exact-node" }, + viewport: { height: 720, width: 1280 }, + }, + { + coverage: { + destination: "shorts", + dom: [ + "reuse-exact-renderer", + "reuse-exact-action-root", + "remove-synthetic-before-route", + "exact-href-identity", + "no-is-active", + ], + origin: "shorts", + timing: ["repeated-navigate-start", "navigate-start-without-finish"], + trigger: "next-control", + width: "wide-desktop", + }, + destination: { + counts: { dislikes: 300, likes: 100 }, + kind: "shorts", + shortIdentity: "exact-href-without-active-or-video-id", + videoId: VIDEO_B, + }, + id: "short-next-short-reuse-renderer-repeated-start-no-finish", + origin: { counts: { dislikes: 100, likes: 300 }, kind: "shorts", state: "neutral", videoId: VIDEO_A }, + postcondition: SINGLE_DESTINATION_DISLIKE_POSTCONDITION, + transition: { + actionRoot: "reuse-exact-node", + navigateFinish: "none", + navigateStarts: "before-and-after-route", + renderer: "reuse-exact-node", + syntheticDislike: "remove-before-route", + }, + viewport: { height: 720, width: 1280 }, + }, + { + coverage: { + destination: "shorts", + dom: [ + "reuse-exact-renderer", + "replace-action-root", + "exact-href-identity", + "no-is-active", + "no-video-id", + "complete-native-inventory", + "persistent-data-null-action-root", + ], + origin: "shorts", + timing: ["navigate-start-without-finish", "data-null-past-watchdog", "stable-native-inventory"], + trigger: "next-control", + width: "wide-desktop", + }, + destination: { + counts: { dislikes: 300, likes: 100 }, + kind: "shorts", + shortIdentity: "exact-href-without-active-or-video-id", + videoId: VIDEO_B, + }, + id: "short-next-short-reuse-renderer-replace-action-root-exact-href-persistent-data-null", + origin: { counts: { dislikes: 100, likes: 300 }, kind: "shorts", state: "neutral", videoId: VIDEO_A }, + postcondition: SINGLE_DESTINATION_DISLIKE_POSTCONDITION, + timing: { maxFirstValidMs: 2_500, unsafeWindowMs: 520 }, + transition: { + actionRoot: "replace-complete-native-persistent-data-null", + navigateFinish: "none", + renderer: "reuse-exact-node", + }, + viewport: { height: 720, width: 1280 }, + }, + { + coverage: { + destination: "shorts", + dom: [ + "replace-shorts-root", + "destination-action-root-absent", + "staged-data-null-action-root", + "complete-native-inventory-before-hydration", + ], + origin: "shorts", + timing: ["navigate-start-without-finish", "no-controls-over-500ms", "data-null-over-stability-window"], + trigger: "next-control", + width: "wide-desktop", + }, + destination: { counts: { dislikes: 300, likes: 100 }, kind: "shorts", videoId: VIDEO_B }, + id: "short-next-short-replace-root-start-no-finish-staged-hydration", + origin: { counts: { dislikes: 100, likes: 300 }, kind: "shorts", state: "neutral", videoId: VIDEO_A }, + postcondition: SINGLE_DESTINATION_DISLIKE_POSTCONDITION, + transition: { + actionRoot: "absent-then-empty-then-native-then-hydrated", + navigateFinish: "none", + navigateStarts: "before-route", + root: "replace", + }, + viewport: { height: 720, width: 1280 }, + }, + { + coverage: { + destination: "shorts", + dom: [ + "replace-shorts-root", + "destination-action-root-absent", + "staged-data-null-action-root", + "complete-native-inventory-before-hydration", + ], + origin: "shorts", + timing: [ + "repeated-navigate-start", + "navigate-start-without-finish", + "no-controls-over-500ms", + "data-null-over-stability-window", + ], + trigger: "next-control", + width: "wide-desktop", + }, + destination: { counts: { dislikes: 300, likes: 100 }, kind: "shorts", videoId: VIDEO_B }, + id: "short-next-short-replace-root-repeated-start-no-finish-staged-hydration", + origin: { counts: { dislikes: 100, likes: 300 }, kind: "shorts", state: "neutral", videoId: VIDEO_A }, + postcondition: SINGLE_DESTINATION_DISLIKE_POSTCONDITION, + transition: { + actionRoot: "absent-then-empty-then-native-then-hydrated", + navigateFinish: "none", + navigateStarts: "before-and-after-route", + root: "replace", + }, viewport: { height: 720, width: 1280 }, }, { @@ -38,6 +335,7 @@ const WATCH_SIDEBAR_MATRIX = [ state: "neutral", videoId: VIDEO_A, }, + postcondition: SINGLE_DESTINATION_DISLIKE_POSTCONDITION, timing: { destinationCount: "gated", navigateFinish: "before-destination-controls", @@ -53,15 +351,37 @@ const WATCH_SIDEBAR_MATRIX = [ { coverage: { destination: "watch", - dom: ["same-current-root", "reuse-exact-control-nodes", "no-useful-control-mutation"], + dom: [ + "same-current-root", + "reuse-exact-control-nodes", + "no-useful-control-mutation", + "same-compact-dislike-text", + "different-exact-dislike-count", + "different-rate-bar-ratio", + ], origin: "watch", timing: ["navigate-finish", "destination-count-gated"], trigger: "sidebar-link", width: "wide-desktop", }, - destination: { counts: { dislikes: 300, likes: 100 }, kind: "watch", videoId: VIDEO_B }, + destination: { + counts: { + dislikes: 1_409, + displayedDislikes: "1.4K", + displayedDislikesAfterIncrement: "1.4K", + likes: 3_591, + }, + kind: "watch", + videoId: VIDEO_B, + }, id: "watch-sidebar-watch-same-node-route-complete", - origin: { counts: { dislikes: 100, likes: 300 }, kind: "watch", state: "neutral", videoId: VIDEO_A }, + origin: { + counts: { dislikes: 1_401, displayedDislikes: "1.4K", likes: 8_599 }, + kind: "watch", + state: "neutral", + videoId: VIDEO_A, + }, + postcondition: SINGLE_DESTINATION_DISLIKE_POSTCONDITION, transition: { controls: "reuse-exact-nodes", nativeControlMutations: "none" }, viewport: { height: 720, width: 1280 }, }, @@ -72,6 +392,8 @@ const WATCH_SIDEBAR_MATRIX = [ "connected-hidden-rate-bar", "connected-collapsed-rate-bar", "malformed-rate-bar", + "missing-rate-bar-video-owner", + "stale-rate-bar-video-owner", "stripped-rate-bar-wrapper-class", ], origin: "watch", @@ -82,8 +404,16 @@ const WATCH_SIDEBAR_MATRIX = [ destination: { counts: { dislikes: 100, likes: 300 }, kind: "watch", videoId: VIDEO_A }, id: "watch-current-rate-bar-connected-corruption", origin: { counts: { dislikes: 100, likes: 300 }, kind: "watch", state: "neutral", videoId: VIDEO_A }, + postcondition: SINGLE_DESTINATION_DISLIKE_POSTCONDITION, transition: { - corruptions: ["hidden-wrapper", "collapsed-wrapper", "missing-fill", "stripped-wrapper-class"], + corruptions: [ + "hidden-wrapper", + "collapsed-wrapper", + "missing-fill", + "missing-video-owner", + "stale-video-owner", + "stripped-wrapper-class", + ], }, viewport: { height: 720, width: 1280 }, }, @@ -103,6 +433,7 @@ const NAVIGATION_MATRIX = [ destination: { counts: { dislikes: 300, likes: 100 }, kind: "watch", videoId: VIDEO_B }, id: "watch-sidebar-watch-same-root-hidden-first", origin: { counts: { dislikes: 100, likes: 300 }, kind: "watch", state: "neutral", videoId: VIDEO_A }, + postcondition: SINGLE_DESTINATION_DISLIKE_POSTCONDITION, transition: { outgoingPresentation: "hidden" }, viewport: { height: 720, width: 1280 }, }, @@ -118,6 +449,7 @@ const NAVIGATION_MATRIX = [ destination: { counts: { dislikes: 300, likes: 100 }, kind: "watch", videoId: VIDEO_B }, id: "watch-sidebar-watch-same-root-offscreen-first", origin: { counts: { dislikes: 100, likes: 300 }, kind: "watch", state: "neutral", videoId: VIDEO_A }, + postcondition: SINGLE_DESTINATION_DISLIKE_POSTCONDITION, transition: { outgoingPresentation: "offscreen" }, viewport: { height: 720, width: 1280 }, }, @@ -138,6 +470,7 @@ const NAVIGATION_MATRIX = [ destination: { counts: { dislikes: 300, likes: 100 }, kind: "watch", videoId: VIDEO_B }, id: "watch-sidebar-watch-legacy-segmented-duplicate-ids", origin: { counts: { dislikes: 100, likes: 300 }, kind: "watch", state: "neutral", videoId: VIDEO_A }, + postcondition: SINGLE_DESTINATION_DISLIKE_POSTCONDITION, transition: { controlMarkup: "legacy-segmented", outgoingPresentation: "hidden" }, viewport: { height: 720, width: 1280 }, }, @@ -153,6 +486,7 @@ const NAVIGATION_MATRIX = [ destination: { counts: { dislikes: 300, likes: 100 }, kind: "watch", videoId: VIDEO_B }, id: "watch-history-back-forward-replace", origin: { counts: { dislikes: 100, likes: 300 }, kind: "watch", state: "neutral", videoId: VIDEO_A }, + postcondition: SINGLE_DESTINATION_DISLIKE_POSTCONDITION, viewport: { height: 720, width: 1280 }, }, { @@ -167,6 +501,22 @@ const NAVIGATION_MATRIX = [ destination: { counts: { dislikes: 300, likes: 100 }, kind: "watch", videoId: VIDEO_B }, id: "watch-autoplay-watch-replace-no-finish", origin: { counts: { dislikes: 100, likes: 300 }, kind: "watch", state: "neutral", videoId: VIDEO_A }, + postcondition: SINGLE_DESTINATION_DISLIKE_POSTCONDITION, + viewport: { height: 720, width: 1280 }, + }, + { + coverage: { + destination: "watch", + dom: ["replace-page-and-controls-after-start"], + origin: "watch", + timing: ["navigate-start-without-finish"], + trigger: "navigate-start-only", + width: "wide-desktop", + }, + destination: { counts: { dislikes: 300, likes: 100 }, kind: "watch", videoId: VIDEO_B }, + id: "watch-navigate-start-watch-replace-no-finish", + origin: { counts: { dislikes: 100, likes: 300 }, kind: "watch", state: "neutral", videoId: VIDEO_A }, + postcondition: SINGLE_DESTINATION_DISLIKE_POSTCONDITION, viewport: { height: 720, width: 1280 }, }, { @@ -181,6 +531,10 @@ const NAVIGATION_MATRIX = [ destination: { counts: { dislikes: 300, likes: 100 }, kind: "shorts", videoId: VIDEO_B }, id: "watch-direct-short-delayed", origin: { counts: { dislikes: 100, likes: 300 }, kind: "watch", state: "neutral", videoId: VIDEO_A }, + postcondition: SINGLE_DESTINATION_DISLIKE_POSTCONDITION, + // 600ms fixture hydration + one 111ms discovery sample + three 111ms + // samples to exceed 250ms native stability, with scheduler allowance. + timing: { controlDelayMs: 600, maxFirstValidMs: 1_250 }, viewport: { height: 720, width: 1280 }, }, { @@ -195,6 +549,8 @@ const NAVIGATION_MATRIX = [ destination: { counts: { dislikes: 300, likes: 100 }, kind: "watch", videoId: VIDEO_B }, id: "short-direct-watch-delayed", origin: { counts: { dislikes: 100, likes: 300 }, kind: "shorts", state: "neutral", videoId: VIDEO_A }, + postcondition: SINGLE_DESTINATION_DISLIKE_POSTCONDITION, + timing: { controlDelayMs: 600 }, viewport: { height: 720, width: 1280 }, }, { @@ -209,6 +565,7 @@ const NAVIGATION_MATRIX = [ destination: { counts: { dislikes: 100, likes: 300 }, kind: "watch", videoId: VIDEO_A }, id: "watch-current-action-container-replace", origin: { counts: { dislikes: 100, likes: 300 }, kind: "watch", state: "neutral", videoId: VIDEO_A }, + postcondition: SINGLE_DESTINATION_DISLIKE_POSTCONDITION, viewport: { height: 720, width: 1280 }, }, ]; @@ -222,10 +579,26 @@ const USERSCRIPT_MATRIX_RUNTIME = { wrapper: ".ryd-tooltip", shortsDislike: "[data-ryd-synthetic-shorts-dislike]", shortsVideoAttribute: "data-ryd-video-id", + watchDislikeCount: "#text", + wrapperVideoAttribute: "data-ryd-video-id", }, - tooltipText({ dislikes, likes }) { - return `${likes} / ${dislikes}`; + tooltipText: expectedTooltipText, +}; + +const EXTENSION_MATRIX_RUNTIME = { + clearsOutgoingWatchPresentationOnNavigateStart: true, + name: "extension", + selectors: { + bar: "#ryd-bar", + container: "#ryd-bar-container", + tooltip: "#ryd-dislike-tooltip", + wrapper: ".ryd-tooltip", + shortsDislike: "[data-ryd-synthetic-shorts-dislike]", + shortsVideoAttribute: "data-ryd-video-id", + watchDislikeCount: "#text", + wrapperVideoAttribute: "data-ryd-video-id", }, + tooltipText: expectedTooltipText, }; async function installSidebarRetainPruneFixture(context, scenario) { @@ -287,14 +660,18 @@ async function installSidebarRetainPruneFixture(context, scenario) { destinationTopRow.querySelectorAll(".ryd-tooltip").forEach((element) => element.remove()); const destinationControls = destinationTopRow.querySelector("[data-fixture-control-video-id]"); destinationControls.setAttribute("data-fixture-control-video-id", matrixScenario.destination.videoId); + globalThis.__navigationFixture.setNativeLikeCount(destinationControls, matrixScenario.destination.videoId); for (const role of ["like", "dislike"]) { - const control = destinationControls.querySelector(`[data-ryd-role="${role}"]`); + const control = destinationControls.querySelector(`[data-fixture-role="${role}"]`); control.classList.remove("style-default-active"); control.classList.add("style-text"); control.querySelector("button")?.setAttribute("aria-pressed", "false"); } - const destinationDislikeText = destinationControls.querySelector('[data-ryd-role="dislike"] #text'); - destinationDislikeText.textContent = ""; + destinationControls + .querySelectorAll( + '[data-fixture-role="dislike"] .ytSpecButtonShapeNextButtonTextContent, [data-fixture-role="dislike"] #text', + ) + .forEach((element) => element.remove()); transition.destinationTopRow = destinationTopRow; document.dispatchEvent(new Event("yt-navigate-start", { bubbles: true })); @@ -320,8 +697,9 @@ async function installSidebarRetainPruneFixture(context, scenario) { throw new Error(`Cannot hydrate the destination from matrix phase ${transition.phase}.`); } const watchPage = fixturePage.querySelector('[data-fixture-page-kind="watch"]'); - const firstNavigationLink = watchPage?.querySelector("a[data-fixture-page-kind]"); - watchPage.insertBefore(transition.destinationTopRow, firstNavigationLink ?? null); + const watchFlexy = watchPage?.querySelector("ytd-watch-flexy"); + if (!watchFlexy) throw new Error("The destination Watch root is unavailable during hydration."); + watchFlexy.appendChild(transition.destinationTopRow); transition.destinationTopRow = null; record("hydrate-destination-controls"); }, @@ -373,8 +751,8 @@ async function installSameRootHiddenFirstFixture(context, scenario) { while (modernSegmented.firstChild) { legacySegmented.appendChild(modernSegmented.firstChild); } - const legacyLike = legacySegmented.querySelector('[data-ryd-role="like"]'); - const legacyDislike = legacySegmented.querySelector('[data-ryd-role="dislike"]'); + const legacyLike = legacySegmented.querySelector('[data-fixture-role="like"]'); + const legacyDislike = legacySegmented.querySelector('[data-fixture-role="dislike"]'); legacyLike.id = "segmented-like-button"; legacyDislike.id = "segmented-dislike-button"; for (const button of legacySegmented.querySelectorAll("button")) { @@ -407,13 +785,18 @@ async function installSameRootHiddenFirstFixture(context, scenario) { destinationTopRow.querySelectorAll(".ryd-tooltip").forEach((element) => element.remove()); const destinationControls = destinationTopRow.querySelector("[data-fixture-control-video-id]"); destinationControls.setAttribute("data-fixture-control-video-id", matrixScenario.destination.videoId); + globalThis.__navigationFixture.setNativeLikeCount(destinationControls, matrixScenario.destination.videoId); for (const role of ["like", "dislike"]) { - const control = destinationControls.querySelector(`[data-ryd-role="${role}"]`); + const control = destinationControls.querySelector(`[data-fixture-role="${role}"]`); control.classList.remove("style-default-active"); control.classList.add("style-text"); control.querySelector("button")?.setAttribute("aria-pressed", "false"); } - destinationControls.querySelector('[data-ryd-role="dislike"] #text').textContent = ""; + destinationControls + .querySelectorAll( + '[data-fixture-role="dislike"] .ytSpecButtonShapeNextButtonTextContent, [data-fixture-role="dislike"] #text', + ) + .forEach((element) => element.remove()); destinationTopRow.setAttribute("data-fixture-matrix-live-destination", "true"); document.dispatchEvent(new Event("yt-navigate-start", { bubbles: true })); @@ -492,8 +875,8 @@ async function installSameNodeRouteCompletionFixture(context, scenario) { const topRow = watchPage?.querySelector("#top-row"); const buttons = topRow?.querySelector("#top-level-buttons-computed"); const controls = buttons?.querySelector("[data-fixture-control-video-id]"); - const like = controls?.querySelector('[data-ryd-role="like"]'); - const dislike = controls?.querySelector('[data-ryd-role="dislike"]'); + const like = controls?.querySelector('[data-fixture-role="like"]'); + const dislike = controls?.querySelector('[data-fixture-role="dislike"]'); if (!fixturePage || !watchPage || !watchFlexy || !topRow || !buttons || !controls || !like || !dislike) { throw new Error("The same-node matrix requires a complete watch fixture."); } @@ -511,9 +894,12 @@ async function installSameNodeRouteCompletionFixture(context, scenario) { const documentIdentity = `matrix-${Date.now()}-${Math.random()}`; const timeline = []; + let countAfterNavigateStart = null; + let countAfterRouteAndRoot = null; destinationLink.addEventListener("click", (event) => { event.preventDefault(); document.dispatchEvent(new Event("yt-navigate-start", { bubbles: true })); + countAfterNavigateStart = dislike.querySelector("#text")?.textContent ?? null; timeline.push("navigate-start"); history.pushState({}, "", `/watch?v=${matrixScenario.destination.videoId}&rydNavigationFixture=1`); @@ -523,6 +909,7 @@ async function installSameNodeRouteCompletionFixture(context, scenario) { // outside the userscript observer's attribute filter and provides no // runtime ownership evidence. controls.setAttribute("data-fixture-control-video-id", matrixScenario.destination.videoId); + countAfterRouteAndRoot = dislike.querySelector("#text")?.textContent ?? null; timeline.push("route-and-root-only"); document.dispatchEvent(new Event("yt-navigate-finish", { bubbles: true })); @@ -533,10 +920,12 @@ async function installSameNodeRouteCompletionFixture(context, scenario) { snapshot() { return { buttonsReused: buttons === watchFlexy.querySelector("#top-level-buttons-computed"), + countAfterNavigateStart, + countAfterRouteAndRoot, controlsReused: controls === watchFlexy.querySelector("[data-fixture-control-video-id]"), - dislikeReused: dislike === watchFlexy.querySelector('[data-ryd-role="dislike"]'), + dislikeReused: dislike === watchFlexy.querySelector('[data-fixture-role="dislike"]'), documentIdentity, - likeReused: like === watchFlexy.querySelector('[data-ryd-role="like"]'), + likeReused: like === watchFlexy.querySelector('[data-fixture-role="like"]'), rootVideoId: watchFlexy.getAttribute("video-id"), sidebarConnected: sidebar.isConnected, timeline: [...timeline], @@ -549,6 +938,779 @@ async function installSameNodeRouteCompletionFixture(context, scenario) { }, scenario); } +async function installSameRendererShortStartOnlyFixture(context, scenario) { + await context.addInitScript((matrixScenario) => { + if (!location.hostname.endsWith("youtube.com")) return; + + addEventListener( + "DOMContentLoaded", + () => { + if (new URL(location.href).searchParams.get("rydNavigationFixture") !== "1") return; + + const fixturePage = document.getElementById("fixture-page"); + const shortsPage = fixturePage?.querySelector('[data-fixture-page-kind="shorts"]'); + const renderer = shortsPage?.querySelector( + `ytd-reel-video-renderer[video-id="${matrixScenario.origin.videoId}"][is-active]`, + ); + const actionBar = renderer?.querySelector("reel-action-bar-view-model"); + const like = actionBar?.querySelector('[data-fixture-role="like"]'); + const next = document.getElementById("short-next"); + if (!fixturePage || !shortsPage || !renderer || !actionBar || !like || !next) { + throw new Error("The same-renderer Shorts matrix requires complete origin controls and a Next link."); + } + + for (const sibling of shortsPage.querySelectorAll("ytd-reel-video-renderer")) { + if (sibling !== renderer) sibling.remove(); + } + + const documentIdentity = `matrix-${Date.now()}-${Math.random()}`; + const originRenderer = renderer; + const originActionBar = actionBar; + const originLike = like; + const repeatedStart = matrixScenario.transition.navigateStarts === "before-and-after-route"; + const timeline = []; + let immediatelyAfterRoute = null; + let originSyntheticDislike = null; + let originSyntheticNativeButton = null; + + next.addEventListener( + "click", + (event) => { + event.preventDefault(); + event.stopPropagation(); + + originSyntheticDislike = actionBar.querySelector("[data-ryd-synthetic-shorts-dislike]"); + originSyntheticNativeButton = originSyntheticDislike?.querySelector("button") ?? null; + if (!originSyntheticDislike || !originSyntheticNativeButton) { + throw new Error("The same-renderer Shorts matrix requires an initialized synthetic Dislike."); + } + + document.dispatchEvent(new Event("yt-navigate-start", { bubbles: true })); + timeline.push(repeatedStart ? "navigate-start-a" : "navigate-start"); + + if (repeatedStart) { + originSyntheticDislike.remove(); + timeline.push("synthetic-removed-before-route"); + } + + history.pushState({}, "", `/shorts/${matrixScenario.destination.videoId}?rydNavigationFixture=1`); + shortsPage.setAttribute("data-fixture-video-id", matrixScenario.destination.videoId); + renderer.removeAttribute("video-id"); + renderer.removeAttribute("is-active"); + renderer.hidden = false; + let identityLink = renderer.querySelector('a[href*="/shorts/"]'); + if (repeatedStart) { + const destinationIdentityLink = identityLink?.cloneNode(true); + if (!destinationIdentityLink) { + throw new Error("The repeated-start Shorts matrix requires a canonical identity link."); + } + destinationIdentityLink.setAttribute("href", `/shorts/${matrixScenario.destination.videoId}`); + identityLink.replaceWith(destinationIdentityLink); + identityLink = destinationIdentityLink; + } else { + identityLink?.setAttribute("href", `/shorts/${matrixScenario.destination.videoId}`); + } + if (matrixScenario.destination.unrelatedDescriptionShortVideoId) { + const description = document.createElement("yt-attributed-string"); + description.id = "description-text"; + description.setAttribute("data-fixture-short-description", "true"); + const descriptionLink = document.createElement("a"); + descriptionLink.className = + "yt-core-attributed-string__link yt-core-attributed-string__link--call-to-action-color"; + descriptionLink.href = `/shorts/${matrixScenario.destination.unrelatedDescriptionShortVideoId}`; + descriptionLink.textContent = "This knot is very useful"; + description.append(descriptionLink); + actionBar.before(description); + } + globalThis.__navigationFixture.setNativeLikeCount(actionBar, matrixScenario.destination.videoId); + timeline.push("route-and-exact-href-only"); + + if (repeatedStart) { + document.dispatchEvent(new Event("yt-navigate-start", { bubbles: true })); + timeline.push("navigate-start-b"); + } + + const currentSyntheticDislike = actionBar.querySelector("[data-ryd-synthetic-shorts-dislike]"); + immediatelyAfterRoute = { + actionBarReused: actionBar === originActionBar, + href: identityLink?.getAttribute("href") ?? null, + isActive: renderer.hasAttribute("is-active"), + likeReused: like === originLike, + nativeDislikeReused: + actionBar.querySelector("[data-ryd-synthetic-shorts-dislike] button") === originSyntheticNativeButton, + rendererReused: renderer === originRenderer, + rendererVideoId: renderer.getAttribute("video-id"), + shortHrefs: Array.from(renderer.querySelectorAll('a[href*="/shorts/"]')).map( + (link) => new URL(link.getAttribute("href"), location.origin).pathname, + ), + syntheticDisabled: currentSyntheticDislike?.querySelector("button")?.disabled ?? null, + syntheticCount: actionBar.querySelectorAll("[data-ryd-synthetic-shorts-dislike]").length, + syntheticReused: currentSyntheticDislike === originSyntheticDislike, + syntheticText: currentSyntheticDislike?.querySelector("#text, [role='text']")?.textContent ?? null, + }; + }, + true, + ); + + globalThis.__navigationMatrixSameRendererShortFixture = { + snapshot() { + const identityLink = renderer.querySelector('a[href*="/shorts/"]'); + return { + actionBarReused: actionBar === originActionBar, + documentIdentity, + href: identityLink?.getAttribute("href") ?? null, + immediatelyAfterRoute, + isActive: renderer.hasAttribute("is-active"), + likeReused: like === originLike, + nativeDislikeReused: + originSyntheticNativeButton !== null && + actionBar.querySelector("[data-ryd-synthetic-shorts-dislike] button") === originSyntheticNativeButton, + navigateFinishes: 0, + rendererReused: renderer === originRenderer, + rendererVideoId: renderer.getAttribute("video-id"), + shortHrefs: Array.from(renderer.querySelectorAll('a[href*="/shorts/"]')).map( + (link) => new URL(link.getAttribute("href"), location.origin).pathname, + ), + syntheticCount: actionBar.querySelectorAll("[data-ryd-synthetic-shorts-dislike]").length, + syntheticReused: + originSyntheticDislike !== null && + actionBar.querySelector("[data-ryd-synthetic-shorts-dislike]") === originSyntheticDislike, + timeline: [...timeline], + visibleRendererCount: Array.from(shortsPage.querySelectorAll("ytd-reel-video-renderer")).filter( + (candidate) => { + const bounds = candidate.getBoundingClientRect(); + return !candidate.hidden && bounds.width > 0 && bounds.height > 0; + }, + ).length, + }; + }, + }; + }, + { once: true }, + ); + }, scenario); +} + +async function installPersistentDataNullShortFixture(context, scenario) { + await context.addInitScript((matrixScenario) => { + if (!location.hostname.endsWith("youtube.com")) return; + + addEventListener( + "DOMContentLoaded", + () => { + if (new URL(location.href).searchParams.get("rydNavigationFixture") !== "1") return; + + const fixturePage = document.getElementById("fixture-page"); + const shortsPage = fixturePage?.querySelector('[data-fixture-page-kind="shorts"]'); + const renderer = shortsPage?.querySelector( + `ytd-reel-video-renderer[video-id="${matrixScenario.origin.videoId}"][is-active]`, + ); + const originActionBar = renderer?.querySelector("reel-action-bar-view-model"); + const next = document.getElementById("short-next"); + if (!fixturePage || !shortsPage || !renderer || !originActionBar || !next) { + throw new Error("The persistent data-null Shorts matrix requires complete origin controls and a Next link."); + } + + for (const sibling of shortsPage.querySelectorAll("ytd-reel-video-renderer")) { + if (sibling !== renderer) sibling.remove(); + } + + const documentIdentity = `matrix-${Date.now()}-${Math.random()}`; + const timeline = []; + let actionBarDataReadCount = 0; + let actionBarDataWriteCount = 0; + let destinationActionBar = null; + let destinationNativeChildren = []; + let originSyntheticDislike = null; + + function visible(element) { + if (!element?.isConnected || element.closest("[hidden], [aria-hidden='true'], [inert]")) return false; + const bounds = element.getBoundingClientRect(); + const style = getComputedStyle(element); + return ( + style.display !== "none" && + !["hidden", "collapse"].includes(style.visibility) && + Number.parseFloat(style.opacity || "1") > 0.01 && + bounds.width > 0 && + bounds.height > 0 && + bounds.bottom > 0 && + bounds.right > 0 && + bounds.top < innerHeight && + bounds.left < innerWidth + ); + } + + function activationTarget(host) { + return ( + host?.querySelector("button, a[href], a[role='button'][tabindex='0'], tp-yt-paper-button#button") ?? null + ); + } + + function hitTested(element) { + if (!visible(element)) return false; + const bounds = element.getBoundingClientRect(); + const x = Math.min(innerWidth - 1, Math.max(0, bounds.left + bounds.width / 2)); + const y = Math.min(innerHeight - 1, Math.max(0, bounds.top + bounds.height / 2)); + return document.elementsFromPoint(x, y).some((hit) => hit === element || element.contains(hit)); + } + + function actionName(host) { + if (host.matches("[data-ryd-synthetic-shorts-dislike]")) return "dislike"; + if (host.getAttribute("data-fixture-role") === "like") return "like"; + const fixtureControl = host.getAttribute("data-fixture-control"); + return fixtureControl === "sound" ? "pivot" : fixtureControl; + } + + function navigateToPersistentDataNullSurface() { + originSyntheticDislike = originActionBar.querySelector("[data-ryd-synthetic-shorts-dislike]"); + const nativeChildTemplates = Array.from(originActionBar.children) + .filter((child) => !child.matches("[data-ryd-synthetic-shorts-dislike]")) + .map((child) => child.cloneNode(true)); + if (!originSyntheticDislike || nativeChildTemplates.length !== 5) { + throw new Error("The persistent data-null Shorts matrix requires one synthetic and five native controls."); + } + + document.dispatchEvent(new Event("yt-navigate-start", { bubbles: true })); + timeline.push("navigate-start"); + history.pushState({}, "", `/shorts/${matrixScenario.destination.videoId}?rydNavigationFixture=1`); + shortsPage.setAttribute("data-fixture-video-id", matrixScenario.destination.videoId); + renderer.removeAttribute("video-id"); + renderer.removeAttribute("is-active"); + renderer.hidden = false; + const identityLink = renderer.querySelector('a[href*="/shorts/"]'); + if (!identityLink) { + throw new Error("The persistent data-null Shorts matrix requires a canonical identity link."); + } + identityLink.setAttribute("href", `/shorts/${matrixScenario.destination.videoId}`); + + destinationActionBar = document.createElement("reel-action-bar-view-model"); + destinationActionBar.setAttribute("data-fixture-role", "buttons"); + destinationActionBar.setAttribute("data-fixture-painted", "true"); + destinationActionBar.setAttribute("data-fixture-persistent-data-null", matrixScenario.destination.videoId); + Object.defineProperty(destinationActionBar, "data", { + configurable: true, + get() { + actionBarDataReadCount += 1; + return null; + }, + set() { + actionBarDataWriteCount += 1; + }, + }); + destinationActionBar.append(...nativeChildTemplates); + globalThis.__navigationFixture.setNativeLikeCount(destinationActionBar, matrixScenario.destination.videoId); + const destinationLike = destinationActionBar.querySelector('[data-fixture-role="like"]'); + destinationLike?.classList.remove("style-default-active"); + destinationLike?.classList.add("style-text"); + destinationLike?.querySelector("button")?.setAttribute("aria-pressed", "false"); + destinationNativeChildren = Array.from(destinationActionBar.children); + originActionBar.replaceWith(destinationActionBar); + timeline.push("route-and-fresh-five-native-persistent-data-null-action-root"); + } + + next.addEventListener( + "click", + (event) => { + event.preventDefault(); + event.stopImmediatePropagation(); + navigateToPersistentDataNullSurface(); + }, + true, + ); + + globalThis.__navigationMatrixPersistentDataNullShortFixture = { + snapshot() { + const actionHosts = Array.from(destinationActionBar?.children ?? []); + const nativeChildren = actionHosts.filter((child) => !child.matches("[data-ryd-synthetic-shorts-dislike]")); + const activationTargets = actionHosts.map(activationTarget); + const visibleRenderers = Array.from(shortsPage.querySelectorAll("ytd-reel-video-renderer")).filter(visible); + const exactHrefRenderers = visibleRenderers.filter((candidate) => + Array.from(candidate.querySelectorAll('a[href*="/shorts/"]')).some((link) => { + try { + return ( + new URL(link.getAttribute("href"), location.origin).pathname === + `/shorts/${matrixScenario.destination.videoId}` + ); + } catch { + return false; + } + }), + ); + const syntheticDislikes = actionHosts.filter((host) => host.matches("[data-ryd-synthetic-shorts-dislike]")); + return { + actionBarConnected: destinationActionBar?.isConnected ?? false, + actionBarDataReadCount, + actionBarDataReady: Boolean(destinationActionBar?.data), + actionBarDataWriteCount, + actionBarReplaced: destinationActionBar !== null && destinationActionBar !== originActionBar, + actionNames: actionHosts.map(actionName), + currentActionButtonCount: activationTargets.filter(Boolean).length, + currentNativeChildCount: nativeChildren.length, + currentSyntheticCount: syntheticDislikes.length, + currentSyntheticVideoIds: syntheticDislikes.map((control) => control.getAttribute("data-ryd-video-id")), + documentIdentity, + exactHrefRendererCount: exactHrefRenderers.length, + hitTestedActionButtonCount: activationTargets.filter(hitTested).length, + href: renderer.querySelector('a[href*="/shorts/"]')?.getAttribute("href") ?? null, + isActive: renderer.hasAttribute("is-active"), + nativeChildrenStable: + destinationNativeChildren.length === 5 && + nativeChildren.every((child, index) => child === destinationNativeChildren[index]), + nativeDislikeCount: + destinationActionBar?.querySelectorAll("dislike-button-view-model, #dislike-button").length ?? 0, + navigateFinishes: 0, + navigateStarts: timeline.includes("navigate-start") ? 1 : 0, + originActionBarConnected: originActionBar.isConnected, + originSyntheticConnected: originSyntheticDislike?.isConnected ?? false, + rendererReused: renderer.isConnected, + rendererVideoId: renderer.getAttribute("video-id"), + syntheticEnabled: syntheticDislikes[0] + ? !syntheticDislikes[0].querySelector("button")?.disabled && + syntheticDislikes[0].querySelector("button")?.getAttribute("aria-disabled") !== "true" + : false, + timeline: [...timeline], + visibleActionButtonCount: activationTargets.filter(visible).length, + visibleRendererCount: visibleRenderers.length, + }; + }, + }; + }, + { once: true }, + ); + }, scenario); +} + +async function installPersistentDataNullVariantShortFixture(context, scenario) { + await context.addInitScript((matrixScenario) => { + if (!location.hostname.endsWith("youtube.com")) return; + + addEventListener( + "DOMContentLoaded", + () => { + if (new URL(location.href).searchParams.get("rydNavigationFixture") !== "1") return; + + const fixturePage = document.getElementById("fixture-page"); + const shortsPage = fixturePage?.querySelector('[data-fixture-page-kind="shorts"]'); + const renderer = shortsPage?.querySelector( + `ytd-reel-video-renderer[video-id="${matrixScenario.origin.videoId}"][is-active]`, + ); + const originActionBar = renderer?.querySelector("reel-action-bar-view-model"); + const next = document.getElementById("short-next"); + if (!fixturePage || !shortsPage || !renderer || !originActionBar || !next) { + throw new Error("The persistent data-null Shorts variant requires complete origin controls and a Next link."); + } + + for (const sibling of shortsPage.querySelectorAll("ytd-reel-video-renderer")) { + if (sibling !== renderer) sibling.remove(); + } + + const addsNativeDislike = matrixScenario.destination.shortsDislikeControl === "native"; + const documentIdentity = `matrix-${Date.now()}-${Math.random()}`; + const timeline = []; + let actionBarDataReadCount = 0; + let actionBarDataWriteCount = 0; + let blockedActivationTarget = null; + let destinationActionBar = null; + let destinationNativeChildren = []; + let originSyntheticDislike = null; + + function visible(element) { + if (!element?.isConnected || element.closest("[hidden], [aria-hidden='true'], [inert]")) return false; + const bounds = element.getBoundingClientRect(); + const style = getComputedStyle(element); + return ( + style.display !== "none" && + !["hidden", "collapse"].includes(style.visibility) && + Number.parseFloat(style.opacity || "1") > 0.01 && + bounds.width > 0 && + bounds.height > 0 && + bounds.bottom > 0 && + bounds.right > 0 && + bounds.top < innerHeight && + bounds.left < innerWidth + ); + } + + function activationTarget(host) { + return ( + host?.querySelector("button, a[href], a[role='button'][tabindex='0'], tp-yt-paper-button#button") ?? null + ); + } + + function hitTested(element) { + if (!visible(element)) return false; + const bounds = element.getBoundingClientRect(); + const x = Math.min(innerWidth - 1, Math.max(0, bounds.left + bounds.width / 2)); + const y = Math.min(innerHeight - 1, Math.max(0, bounds.top + bounds.height / 2)); + return document.elementsFromPoint(x, y).some((hit) => hit === element || element.contains(hit)); + } + + function actionName(host) { + if (host.getAttribute("data-fixture-role") === "like") return "like"; + if (host.getAttribute("data-fixture-role") === "dislike") return "dislike"; + const fixtureControl = host.getAttribute("data-fixture-control"); + return fixtureControl === "sound" ? "pivot" : fixtureControl; + } + + function bounds(element) { + if (!element?.isConnected) return null; + const rect = element.getBoundingClientRect(); + return { + bottom: rect.bottom, + height: rect.height, + left: rect.left, + right: rect.right, + top: rect.top, + width: rect.width, + }; + } + + function createNativeDislike() { + const label = originSyntheticDislike?.querySelector("label")?.cloneNode(true); + if (!label) + throw new Error("The native-Dislike variant requires the initialized synthetic control template."); + const nativeDislike = document.createElement("dislike-button-view-model"); + nativeDislike.className = + "ytDislikeButtonViewModelHost ytwReelActionBarViewModelHostDesktopActionButton style-text"; + nativeDislike.setAttribute("data-fixture-role", "dislike"); + nativeDislike.append(label); + const button = nativeDislike.querySelector("button"); + if (!button) throw new Error("The native-Dislike variant requires an activation target."); + button.disabled = false; + button.setAttribute("aria-disabled", "false"); + button.setAttribute("aria-pressed", "false"); + const count = nativeDislike.querySelector("#text, [role='text']"); + if (count) count.textContent = ""; + return nativeDislike; + } + + function navigateToVariantSurface() { + originSyntheticDislike = originActionBar.querySelector("[data-ryd-synthetic-shorts-dislike]"); + const nativeChildTemplates = Array.from(originActionBar.children) + .filter((child) => !child.matches("[data-ryd-synthetic-shorts-dislike]")) + .map((child) => child.cloneNode(true)); + if (!originSyntheticDislike || nativeChildTemplates.length !== 5) { + throw new Error("The persistent data-null Shorts variant requires one synthetic and five native controls."); + } + + document.dispatchEvent(new Event("yt-navigate-start", { bubbles: true })); + timeline.push("navigate-start"); + history.pushState({}, "", `/shorts/${matrixScenario.destination.videoId}?rydNavigationFixture=1`); + shortsPage.setAttribute("data-fixture-video-id", matrixScenario.destination.videoId); + renderer.removeAttribute("video-id"); + renderer.removeAttribute("is-active"); + renderer.hidden = false; + const identityLink = renderer.querySelector('a[href*="/shorts/"]'); + if (!identityLink) { + throw new Error("The persistent data-null Shorts variant requires a canonical identity link."); + } + identityLink.setAttribute("href", `/shorts/${matrixScenario.destination.videoId}`); + + destinationActionBar = document.createElement("reel-action-bar-view-model"); + destinationActionBar.setAttribute("data-fixture-role", "buttons"); + destinationActionBar.setAttribute("data-fixture-painted", "true"); + destinationActionBar.setAttribute("data-fixture-persistent-data-null", matrixScenario.destination.videoId); + Object.defineProperty(destinationActionBar, "data", { + configurable: true, + get() { + actionBarDataReadCount += 1; + return null; + }, + set() { + actionBarDataWriteCount += 1; + }, + }); + destinationActionBar.append(...nativeChildTemplates); + globalThis.__navigationFixture.setNativeLikeCount(destinationActionBar, matrixScenario.destination.videoId); + const destinationLike = destinationActionBar.querySelector('[data-fixture-role="like"]'); + destinationLike?.classList.remove("style-default-active"); + destinationLike?.classList.add("style-text"); + destinationLike?.querySelector("button")?.setAttribute("aria-pressed", "false"); + + if (addsNativeDislike) { + destinationLike?.insertAdjacentElement("afterend", createNativeDislike()); + timeline.push("append-native-dislike"); + } else { + blockedActivationTarget = activationTarget(destinationActionBar.lastElementChild); + if (!blockedActivationTarget) { + throw new Error("The inert persistent data-null variant requires a native activation target to hide."); + } + blockedActivationTarget.style.display = "none"; + blockedActivationTarget.setAttribute("data-fixture-non-rendered", "true"); + timeline.push("hide-one-native-activation-target"); + } + + destinationNativeChildren = Array.from(destinationActionBar.children); + originActionBar.replaceWith(destinationActionBar); + timeline.push("route-and-fresh-persistent-data-null-action-root"); + } + + next.addEventListener( + "click", + (event) => { + event.preventDefault(); + event.stopImmediatePropagation(); + navigateToVariantSurface(); + }, + true, + ); + + globalThis.__navigationMatrixPersistentDataNullVariantFixture = { + snapshot() { + const actionHosts = Array.from(destinationActionBar?.children ?? []); + const nativeChildren = actionHosts.filter((child) => !child.matches("[data-ryd-synthetic-shorts-dislike]")); + const activationTargets = actionHosts.map(activationTarget); + const visibleRenderers = Array.from(shortsPage.querySelectorAll("ytd-reel-video-renderer")).filter(visible); + const exactHrefRenderers = visibleRenderers.filter((candidate) => + Array.from(candidate.querySelectorAll('a[href*="/shorts/"]')).some((link) => { + try { + return ( + new URL(link.getAttribute("href"), location.origin).pathname === + `/shorts/${matrixScenario.destination.videoId}` + ); + } catch { + return false; + } + }), + ); + const nativeDislikes = actionHosts.filter((host) => + host.matches("dislike-button-view-model, #dislike-button"), + ); + const syntheticDislikes = actionHosts.filter((host) => host.matches("[data-ryd-synthetic-shorts-dislike]")); + return { + actionBarBounds: bounds(destinationActionBar), + actionBarConnected: destinationActionBar?.isConnected ?? false, + actionBarDataReadCount, + actionBarDataReady: Boolean(destinationActionBar?.data), + actionBarDataWriteCount, + actionBarReplaced: destinationActionBar !== null && destinationActionBar !== originActionBar, + actionNames: actionHosts.map(actionName), + blockedActivationConnected: blockedActivationTarget?.isConnected ?? false, + currentActionButtonCount: activationTargets.filter(Boolean).length, + currentNativeChildCount: nativeChildren.length, + currentSyntheticCount: syntheticDislikes.length, + documentIdentity, + exactHrefRendererCount: exactHrefRenderers.length, + hitTestedActionButtonCount: activationTargets.filter(hitTested).length, + href: renderer.querySelector('a[href*="/shorts/"]')?.getAttribute("href") ?? null, + isActive: renderer.hasAttribute("is-active"), + nativeChildrenStable: + destinationNativeChildren.length === nativeChildren.length && + nativeChildren.every((child, index) => child === destinationNativeChildren[index]), + nativeDislikeCount: nativeDislikes.length, + nativeDislikeText: nativeDislikes[0]?.querySelector("#text, [role='text']")?.textContent?.trim() ?? null, + navigateFinishes: 0, + navigateStarts: timeline.includes("navigate-start") ? 1 : 0, + nonRenderedActionButtonCount: activationTargets.filter((target) => target && !visible(target)).length, + originActionBarConnected: originActionBar.isConnected, + originSyntheticConnected: originSyntheticDislike?.isConnected ?? false, + rendererReused: renderer.isConnected, + rendererVideoId: renderer.getAttribute("video-id"), + timeline: [...timeline], + visibleActionButtonCount: activationTargets.filter(visible).length, + visibleRendererCount: visibleRenderers.length, + }; + }, + }; + }, + { once: true }, + ); + }, scenario); +} + +async function installReplacedRootShortStartOnlyFixture(context, scenario) { + await context.addInitScript((matrixScenario) => { + if (!location.hostname.endsWith("youtube.com")) return; + + addEventListener( + "DOMContentLoaded", + () => { + if (new URL(location.href).searchParams.get("rydNavigationFixture") !== "1") return; + + const fixturePage = document.getElementById("fixture-page"); + const shortsPage = fixturePage?.querySelector('[data-fixture-page-kind="shorts"]'); + const originRoot = shortsPage?.querySelector("ytd-shorts"); + const originRenderer = originRoot?.querySelector( + `ytd-reel-video-renderer[video-id="${matrixScenario.origin.videoId}"][is-active]`, + ); + const originActionBar = originRenderer?.querySelector("reel-action-bar-view-model"); + const next = document.getElementById("short-next"); + if (!fixturePage || !shortsPage || !originRoot || !originRenderer || !originActionBar || !next) { + throw new Error("The replaced-root Shorts matrix requires a complete origin and a Next link."); + } + + const nativeChildTemplates = Array.from(originActionBar.children) + .filter((child) => !child.matches("[data-ryd-synthetic-shorts-dislike]")) + .map((child) => child.cloneNode(true)); + if (nativeChildTemplates.length !== 5) { + throw new Error("The replaced-root Shorts matrix requires exactly five native action controls."); + } + + const documentIdentity = `matrix-${Date.now()}-${Math.random()}`; + const repeatedStart = matrixScenario.transition.navigateStarts === "before-and-after-route"; + const timeline = []; + let destinationActionBar = null; + let destinationActionBarData = null; + let destinationActionBarDataReadCount = 0; + let destinationActionBarFirstDataReadAt = null; + let destinationActionBarMountedAt = null; + let destinationRenderer = null; + let destinationRoot = null; + let originSyntheticDislike = null; + + function visible(element) { + if (!element?.isConnected || element.closest("[hidden], [aria-hidden='true']")) return false; + const bounds = element.getBoundingClientRect(); + const style = getComputedStyle(element); + return style.display !== "none" && style.visibility !== "hidden" && bounds.width > 0 && bounds.height > 0; + } + + function replaceRoot() { + originSyntheticDislike = originActionBar.querySelector("[data-ryd-synthetic-shorts-dislike]"); + if (!originSyntheticDislike) { + throw new Error("The replaced-root Shorts matrix requires an initialized origin Dislike."); + } + + document.dispatchEvent(new Event("yt-navigate-start", { bubbles: true })); + timeline.push(repeatedStart ? "navigate-start-a" : "navigate-start"); + history.pushState({}, "", `/shorts/${matrixScenario.destination.videoId}?rydNavigationFixture=1`); + shortsPage.setAttribute("data-fixture-video-id", matrixScenario.destination.videoId); + + destinationRoot = document.createElement("ytd-shorts"); + destinationRoot.setAttribute("data-fixture-replaced-shorts-root", matrixScenario.destination.videoId); + const sequence = document.createElement("div"); + sequence.className = "reel-video-in-sequence-new"; + sequence.setAttribute("data-fixture-sequence-video-id", matrixScenario.destination.videoId); + const thumbnail = document.createElement("div"); + thumbnail.className = "reel-video-in-sequence-thumbnail"; + destinationRenderer = document.createElement("ytd-reel-video-renderer"); + destinationRenderer.setAttribute("video-id", matrixScenario.destination.videoId); + destinationRenderer.setAttribute("is-active", ""); + destinationRenderer.setAttribute("data-fixture-replaced-renderer", matrixScenario.destination.videoId); + const overlay = document.createElement("div"); + overlay.id = "experiment-overlay"; + overlay.innerHTML = "Ready"; + const identityLink = document.createElement("a"); + identityLink.href = `/shorts/${matrixScenario.destination.videoId}`; + identityLink.setAttribute("aria-label", `Short ${matrixScenario.destination.videoId}`); + destinationRenderer.append(overlay, identityLink); + sequence.append(thumbnail, destinationRenderer); + destinationRoot.append(sequence); + originRoot.replaceWith(destinationRoot); + timeline.push("route-and-root-without-action-bar"); + + if (repeatedStart) { + document.dispatchEvent(new Event("yt-navigate-start", { bubbles: true })); + timeline.push("navigate-start-b"); + } + } + + function appendEmptyActionBar() { + if (!destinationRenderer?.isConnected || destinationActionBar) { + throw new Error("The replaced-root Shorts matrix cannot append its empty action bar."); + } + destinationActionBar = document.createElement("reel-action-bar-view-model"); + destinationActionBar.setAttribute("data-fixture-role", "buttons"); + destinationActionBar.setAttribute("data-fixture-staged-action-bar", matrixScenario.destination.videoId); + destinationActionBarData = null; + destinationActionBarDataReadCount = 0; + destinationActionBarFirstDataReadAt = null; + destinationActionBarMountedAt = performance.now(); + Object.defineProperty(destinationActionBar, "data", { + configurable: true, + get() { + destinationActionBarDataReadCount += 1; + destinationActionBarFirstDataReadAt ??= performance.now(); + return destinationActionBarData; + }, + set(value) { + destinationActionBarData = value; + }, + }); + destinationRenderer.append(destinationActionBar); + timeline.push("append-empty-data-null-action-bar"); + } + + function appendNativeChildren() { + if (!destinationActionBar?.isConnected || destinationActionBar.children.length !== 0) { + throw new Error("The replaced-root Shorts matrix cannot append its native action inventory."); + } + destinationActionBar.append(...nativeChildTemplates.map((child) => child.cloneNode(true))); + globalThis.__navigationFixture.setNativeLikeCount(destinationActionBar, matrixScenario.destination.videoId); + const like = destinationActionBar.querySelector('[data-fixture-role="like"]'); + like?.classList.remove("style-default-active"); + like?.classList.add("style-text"); + like?.querySelector("button")?.setAttribute("aria-pressed", "false"); + timeline.push("append-complete-native-inventory-data-null"); + } + + function hydrateActionBar() { + if (!destinationActionBar?.isConnected || destinationActionBar.children.length !== 5) { + throw new Error("The replaced-root Shorts matrix cannot hydrate an incomplete action bar."); + } + destinationActionBar.data = { hydrated: true, videoId: matrixScenario.destination.videoId }; + timeline.push("hydrate-action-bar-data"); + } + + next.addEventListener( + "click", + (event) => { + event.preventDefault(); + event.stopImmediatePropagation(); + replaceRoot(); + }, + true, + ); + + globalThis.__navigationMatrixReplacedRootShortFixture = { + appendEmptyActionBar, + appendNativeChildren, + hydrateActionBar, + snapshot() { + const currentSyntheticDislikes = Array.from( + document.querySelectorAll("[data-ryd-synthetic-shorts-dislike]"), + ); + return { + actionBarConnected: destinationActionBar?.isConnected ?? false, + actionBarDataReadCount: destinationActionBarDataReadCount, + actionBarDataReady: Boolean(destinationActionBarData), + actionBarFirstDataReadDelayMs: + destinationActionBarFirstDataReadAt === null || destinationActionBarMountedAt === null + ? null + : destinationActionBarFirstDataReadAt - destinationActionBarMountedAt, + currentActionButtonCount: + destinationActionBar === null + ? 0 + : Array.from(destinationActionBar.querySelectorAll("button")).filter(visible).length, + currentNativeChildCount: + destinationActionBar === null + ? 0 + : Array.from(destinationActionBar.children).filter( + (child) => !child.matches("[data-ryd-synthetic-shorts-dislike]"), + ).length, + currentSyntheticCount: currentSyntheticDislikes.length, + currentSyntheticVideoIds: currentSyntheticDislikes.map((control) => + control.getAttribute("data-ryd-video-id"), + ), + destinationRendererConnected: destinationRenderer?.isConnected ?? false, + destinationRendererVisible: visible(destinationRenderer), + destinationRootConnected: destinationRoot?.isConnected ?? false, + documentIdentity, + navigateFinishes: 0, + navigateStarts: repeatedStart ? 2 : 1, + originRootConnected: originRoot.isConnected, + originSyntheticConnected: originSyntheticDislike?.isConnected ?? false, + rendererVideoId: destinationRenderer?.getAttribute("video-id") ?? null, + timeline: [...timeline], + }; + }, + }; + }, + { once: true }, + ); + }, scenario); +} + async function installStandardNavigationMatrixFixture(context, scenario) { await context.addInitScript((matrixScenario) => { if (!location.hostname.endsWith("youtube.com")) return; @@ -568,6 +1730,7 @@ async function installStandardNavigationMatrixFixture(context, scenario) { historyRenders: [], navigateFinishes: 0, navigateStarts: 0, + shortActiveMutations: [], }; document.addEventListener("yt-navigate-start", () => { probe.navigateStarts += 1; @@ -575,13 +1738,34 @@ async function installStandardNavigationMatrixFixture(context, scenario) { document.addEventListener("yt-navigate-finish", () => { probe.navigateFinishes += 1; }); + const shortActiveObserver = new MutationObserver((mutations) => { + for (const mutation of mutations) { + const renderer = mutation.target; + if (!renderer.matches?.("ytd-reel-video-renderer")) continue; + probe.shortActiveMutations.push({ + active: renderer.hasAttribute("is-active"), + attribute: mutation.attributeName, + hidden: renderer.hidden, + oldValue: mutation.oldValue, + videoId: renderer.getAttribute("video-id"), + }); + } + }); + shortActiveObserver.observe(fixturePage, { + attributeFilter: ["hidden", "is-active"], + attributeOldValue: true, + attributes: true, + subtree: true, + }); const delayedLinkId = { "short-direct-watch-delayed": "short-to-watch", "watch-direct-short-delayed": "watch-to-short", }[matrixScenario.id]; if (delayedLinkId) { - document.getElementById(delayedLinkId)?.setAttribute("data-fixture-control-delay", "600"); + document + .getElementById(delayedLinkId) + ?.setAttribute("data-fixture-control-delay", String(matrixScenario.timing.controlDelayMs)); } if (matrixScenario.id === "watch-history-back-forward-replace") { @@ -599,13 +1783,18 @@ async function installStandardNavigationMatrixFixture(context, scenario) { replacementTopRow.querySelectorAll(".ryd-tooltip").forEach((element) => element.remove()); const controls = replacementTopRow.querySelector("[data-fixture-control-video-id]"); controls.setAttribute("data-fixture-control-video-id", videoId); + globalThis.__navigationFixture.setNativeLikeCount(controls, videoId); for (const role of ["like", "dislike"]) { - const control = controls.querySelector(`[data-ryd-role="${role}"]`); + const control = controls.querySelector(`[data-fixture-role="${role}"]`); control.classList.remove("style-default-active"); control.classList.add("style-text"); control.querySelector("button")?.setAttribute("aria-pressed", "false"); } - controls.querySelector('[data-ryd-role="dislike"] #text').textContent = ""; + controls + .querySelectorAll( + '[data-fixture-role="dislike"] .ytSpecButtonShapeNextButtonTextContent, [data-fixture-role="dislike"] #text', + ) + .forEach((element) => element.remove()); currentTopRow.replaceWith(replacementTopRow); watchPage.setAttribute("data-fixture-video-id", videoId); watchFlexy.setAttribute("video-id", videoId); @@ -625,6 +1814,7 @@ async function installStandardNavigationMatrixFixture(context, scenario) { historyRenders: [...probe.historyRenders], navigateFinishes: probe.navigateFinishes, navigateStarts: probe.navigateStarts, + shortActiveMutations: [...probe.shortActiveMutations], transitionPending: fixturePage.dataset.fixtureTransitionPending ?? null, }; }, @@ -639,6 +1829,32 @@ async function installNavigationMatrixFixture(context, scenario) { if (!NAVIGATION_MATRIX.some((candidate) => candidate.id === scenario.id)) { throw new Error(`No navigation matrix fixture is registered for ${scenario.id}.`); } + if ( + scenario.id === "short-next-short-reuse-renderer-start-no-finish" || + scenario.id === "short-next-short-reuse-renderer-repeated-start-no-finish" || + scenario.id === "short-next-short-exact-href-with-description-crosslink" + ) { + await installSameRendererShortStartOnlyFixture(context, scenario); + return; + } + if (scenario.id === "short-next-short-reuse-renderer-replace-action-root-exact-href-persistent-data-null") { + await installPersistentDataNullShortFixture(context, scenario); + return; + } + if ( + scenario.id === "short-next-short-persistent-data-null-nonrendered-native-stays-inert" || + scenario.id === "short-next-short-persistent-data-null-native-dislike" + ) { + await installPersistentDataNullVariantShortFixture(context, scenario); + return; + } + if ( + scenario.id === "short-next-short-replace-root-start-no-finish-staged-hydration" || + scenario.id === "short-next-short-replace-root-repeated-start-no-finish-staged-hydration" + ) { + await installReplacedRootShortStartOnlyFixture(context, scenario); + return; + } if (scenario.id === "watch-sidebar-watch-retain-prune") { await installSidebarRetainPruneFixture(context, scenario); return; @@ -659,7 +1875,8 @@ async function installNavigationMatrixFixture(context, scenario) { } function currentWatchLocators(page, runtime, videoId) { - const controls = page.locator(`[data-fixture-control-video-id="${videoId}"]`); + const watchRoot = page.locator(`ytd-watch-flexy[video-id="${videoId}"], ytd-watch-grid[video-id="${videoId}"]`); + const controls = watchRoot.locator(`[data-fixture-control-video-id="${videoId}"]`); const reactionRegion = controls.locator("xpath=.."); const wrapper = reactionRegion.locator(`:scope > ${runtime.selectors.wrapper}`); const container = wrapper.locator(runtime.selectors.container); @@ -669,15 +1886,83 @@ function currentWatchLocators(page, runtime, videoId) { controls, reactionRegion, tooltip: wrapper.locator(runtime.selectors.tooltip), + watchRoot, wrapper, }; } +function currentShortRendererLocator(page, videoId, shortIdentity = "active-video-id") { + if (shortIdentity === "exact-href-without-active-or-video-id") { + return page.locator( + `ytd-reel-video-renderer:not([hidden]):has(a[href^="/shorts/${videoId}"])` + `:not([is-active]):not([video-id])`, + ); + } + return page.locator(`ytd-reel-video-renderer[video-id="${videoId}"][is-active]`); +} + +function currentShortDislikeSelector(runtime, shortsDislikeControl = "synthetic") { + return shortsDislikeControl === "native" + ? 'dislike-button-view-model[data-fixture-role="dislike"]' + : runtime.selectors.shortsDislike; +} + +function getDestinationDislikePostconditionTarget(page, runtime, scenario) { + if (scenario.postcondition !== SINGLE_DESTINATION_DISLIKE_POSTCONDITION) { + throw new Error(`Unsupported navigation postcondition for ${scenario.id}: ${scenario.postcondition}`); + } + + const { destination } = scenario; + const reactionRoot = + destination.kind === "shorts" + ? currentShortRendererLocator(page, destination.videoId, destination.shortIdentity) + : currentWatchLocators(page, runtime, destination.videoId).controls; + const control = + destination.kind === "shorts" + ? reactionRoot.locator(currentShortDislikeSelector(runtime, destination.shortsDislikeControl)) + : reactionRoot.locator('[data-fixture-role="dislike"]'); + const likeControl = reactionRoot.locator('[data-fixture-role="like"]'); + + return { + button: control.locator("button"), + control, + count: control.locator(destination.kind === "shorts" ? "#text" : runtime.selectors.watchDislikeCount), + expectedCount: expectedDislikeText(destination.counts, 1), + expectedFinalDislikeCount: expectedDislikeText(destination.counts), + expectedInitialDislikeCount: expectedDislikeText(destination.counts), + expectedFinalLikeCount: destination.counts.likes + 1, + likeButton: likeControl.locator("button"), + likeControl, + likeCount: likeControl.locator("#text, [role='text']"), + }; +} + async function expectOwnedWatchBar(page, runtime, videoId, counts) { const locators = currentWatchLocators(page, runtime, videoId); + const currentCount = locators.controls + .locator('[data-fixture-role="dislike"]') + .locator(runtime.selectors.watchDislikeCount); await expect(locators.controls).toHaveCount(1); - await expect(locators.controls.locator('[data-ryd-role="dislike"] #text')).toHaveText(String(counts.dislikes)); + await expect( + locators.controls.locator( + ":scope > yt-smartimation > [data-fixture-smartimation-content-shell] > [data-fixture-smartimation-content] > like-button-view-model", + ), + ).toHaveCount(1); + await expect( + locators.controls.locator( + ":scope > yt-smartimation > [data-fixture-smartimation-content-shell] > [data-fixture-smartimation-content] > dislike-button-view-model", + ), + ).toHaveCount(1); + expect( + await page.evaluate(() => globalThis.__navigationFixtureBaseline?.renderedWatchNativeDislikeTextCount), + "the fixture must supply an icon-only native Watch Dislike control", + ).toBe(0); + await expect(currentCount).toHaveCount(1); + await expect(currentCount).not.toHaveAttribute("data-fixture-provided-native-text", /.*/); + await expect(currentCount).toHaveText(expectedDislikeText(counts)); await expect(locators.wrapper).toHaveCount(1); + if (runtime.selectors.wrapperVideoAttribute) { + await expect(locators.wrapper).toHaveAttribute(runtime.selectors.wrapperVideoAttribute, videoId); + } await expect(locators.wrapper).toBeVisible(); await expect(locators.container).toBeVisible(); await expect(locators.bar).toBeVisible(); @@ -698,8 +1983,8 @@ async function expectOwnedWatchBar(page, runtime, videoId, counts) { width: bounds.width, }; }; - const like = reactionRegion.querySelector('[data-ryd-role="like"] button'); - const dislike = reactionRegion.querySelector('[data-ryd-role="dislike"] button'); + const like = reactionRegion.querySelector('[data-fixture-role="like"] button'); + const dislike = reactionRegion.querySelector('[data-fixture-role="dislike"] button'); const wrapper = reactionRegion.querySelector(`:scope > ${selectors.wrapper}`); const container = wrapper.querySelector(selectors.container); const bar = container.querySelector(selectors.bar); @@ -719,28 +2004,56 @@ async function expectOwnedWatchBar(page, runtime, videoId, counts) { return locators; } -async function expectOwnedShortControl(page, runtime, videoId, counts) { +async function expectOwnedShortControl( + page, + runtime, + videoId, + counts, + shortIdentity = "active-video-id", + shortsDislikeControl = "synthetic", +) { await expect(page).toHaveURL((url) => url.pathname === `/shorts/${videoId}`); - const activeRenderer = page.locator(`ytd-reel-video-renderer[video-id="${videoId}"][is-active]`); - const dislike = activeRenderer.locator(runtime.selectors.shortsDislike); - await expect(activeRenderer).toHaveCount(1); - await expect(activeRenderer).toBeVisible(); + const currentRenderer = currentShortRendererLocator(page, videoId, shortIdentity); + const dislikeSelector = currentShortDislikeSelector(runtime, shortsDislikeControl); + const dislike = currentRenderer.locator(dislikeSelector); + await expect(currentRenderer).toHaveCount(1); + await expect(currentRenderer).toBeVisible(); await expect(dislike).toHaveCount(1); await expect(dislike).toBeVisible(); await expect(dislike.locator("#text")).toHaveText(String(counts.dislikes)); - if (runtime.selectors.shortsVideoAttribute) { + if (shortsDislikeControl === "synthetic" && runtime.selectors.shortsVideoAttribute) { await expect(dislike).toHaveAttribute(runtime.selectors.shortsVideoAttribute, videoId); } - await expect(page.locator(`${runtime.selectors.shortsDislike}:visible`)).toHaveCount(1); + await expect(page.locator(`${dislikeSelector}:visible`)).toHaveCount(1); + await expect(currentRenderer.locator("reel-action-bar-view-model button")).toHaveCount(6); + await expect(currentRenderer.locator("reel-action-bar-view-model button:visible")).toHaveCount(6); await expect(page.locator(runtime.selectors.wrapper)).toHaveCount(0); await expect(page.locator(runtime.selectors.container)).toHaveCount(0); await expect(page.locator(runtime.selectors.bar)).toHaveCount(0); - return { activeRenderer, dislike }; + return { currentRenderer, dislike }; } -async function readOwnedSurfaceInvariant(page, runtime, videoId, counts, kind) { +async function readOwnedSurfaceInvariant( + page, + runtime, + videoId, + counts, + kind, + shortIdentity = "active-video-id", + shortsDislikeControl = "synthetic", +) { + const shortsDislikeSelector = currentShortDislikeSelector(runtime, shortsDislikeControl); return page.evaluate( - ({ counts: expectedCounts, expectedTooltip, kind: expectedKind, selectors, videoId: expectedVideoId }) => { + ({ + counts: expectedCounts, + expectedCount, + expectedTooltip, + kind: expectedKind, + selectors, + shortsDislikeSelector: expectedShortsDislikeSelector, + shortIdentity: expectedShortIdentity, + videoId: expectedVideoId, + }) => { const visible = (element) => { if (!element?.isConnected || element.closest("[hidden], [aria-hidden='true']")) return false; const style = getComputedStyle(element); @@ -760,22 +2073,41 @@ async function readOwnedSurfaceInvariant(page, runtime, videoId, counts, kind) { const activeRenderers = Array.from( document.querySelectorAll(`ytd-reel-video-renderer[video-id="${expectedVideoId}"][is-active]`), ); - const currentControls = activeRenderers.flatMap((renderer) => - Array.from(renderer.querySelectorAll(selectors.shortsDislike)), + const exactHrefRenderers = Array.from(document.querySelectorAll("ytd-reel-video-renderer")).filter( + (renderer) => + visible(renderer) && + Array.from(renderer.querySelectorAll('a[href*="/shorts/"]')).some((link) => { + try { + return new URL(link.getAttribute("href"), location.origin).pathname === `/shorts/${expectedVideoId}`; + } catch { + return false; + } + }), ); - const currentActionButtons = activeRenderers.flatMap((renderer) => - Array.from(renderer.querySelectorAll("reel-action-bar-view-model button")).filter(visible), + const currentRenderers = + expectedShortIdentity === "exact-href-without-active-or-video-id" ? exactHrefRenderers : activeRenderers; + const currentControls = currentRenderers.flatMap((renderer) => + Array.from(renderer.querySelectorAll(expectedShortsDislikeSelector)), ); - const visibleControls = Array.from(document.querySelectorAll(selectors.shortsDislike)).filter(visible); + const actionButtons = currentRenderers.flatMap((renderer) => + Array.from(renderer.querySelectorAll("reel-action-bar-view-model button")), + ); + const currentActionButtons = actionButtons.filter(visible); + const visibleControls = Array.from(document.querySelectorAll(expectedShortsDislikeSelector)).filter(visible); return { ...common, activeRenderers: activeRenderers.length, count: currentControls[0]?.querySelector("#text")?.textContent?.trim() ?? null, currentActionButtons: currentActionButtons.length, + currentRendererHasActive: currentRenderers[0]?.hasAttribute("is-active") ?? null, + currentRendererVideoId: currentRenderers[0]?.getAttribute("video-id") ?? null, + currentRenderers: currentRenderers.length, + totalActionButtons: actionButtons.length, currentControls: currentControls.length, currentVisible: currentControls.length === 1 && visible(currentControls[0]), - expectedCount: String(expectedCounts.dislikes), + expectedCount, expectedPathname: `/shorts/${expectedVideoId}`, + expectedShortIdentity, kind: expectedKind, visibleControls: visibleControls.length, }; @@ -791,9 +2123,13 @@ async function readOwnedSurfaceInvariant(page, runtime, videoId, counts, kind) { const barBounds = bar?.getBoundingClientRect(); return { ...common, - count: currentControls[0]?.querySelector('[data-ryd-role="dislike"] #text')?.textContent?.trim() ?? null, + count: + currentControls[0] + ?.querySelector('[data-fixture-role="dislike"]') + ?.querySelector(selectors.watchDislikeCount) + ?.textContent?.trim() ?? null, currentControls: currentControls.length, - expectedCount: String(expectedCounts.dislikes), + expectedCount, expectedPathname: "/watch", expectedRatio: expectedCounts.likes / (expectedCounts.likes + expectedCounts.dislikes), expectedTooltip, @@ -802,24 +2138,44 @@ async function readOwnedSurfaceInvariant(page, runtime, videoId, counts, kind) { ownerBarVisible: visible(bar), ownerContainerVisible: visible(container), ownerTooltip: wrapper?.querySelector(selectors.tooltip)?.textContent?.replace(/\s+/g, " ").trim() ?? null, + ownerVideoId: selectors.wrapperVideoAttribute + ? wrapper?.getAttribute(selectors.wrapperVideoAttribute) ?? null + : expectedVideoId, ownerWrapperVisible: visible(wrapper), ratio: containerBounds?.width > 0 && barBounds ? barBounds.width / containerBounds.width : null, videoId: currentUrl.searchParams.get("v"), }; }, - { counts, expectedTooltip: runtime.tooltipText(counts), kind, selectors: runtime.selectors, videoId }, + { + counts, + expectedCount: expectedDislikeText(counts), + expectedTooltip: runtime.tooltipText(counts), + kind, + selectors: runtime.selectors, + shortsDislikeSelector, + shortIdentity, + videoId, + }, ); } function ownedSurfaceInvariantIsValid(sample) { if (sample.pathname !== sample.expectedPathname || sample.count !== sample.expectedCount) return false; if (sample.kind === "shorts") { + const identityMatches = + sample.expectedShortIdentity === "exact-href-without-active-or-video-id" + ? sample.activeRenderers === 0 && + sample.currentRenderers === 1 && + sample.currentRendererHasActive === false && + sample.currentRendererVideoId === null + : sample.activeRenderers === 1 && sample.currentRenderers === 1; return ( - sample.activeRenderers === 1 && + identityMatches && sample.currentControls === 1 && - sample.currentActionButtons >= 4 && + sample.currentActionButtons === 6 && sample.currentVisible && sample.visibleControls === 1 && + sample.totalActionButtons === 6 && sample.wrappers === 0 && sample.containers === 0 && sample.bars === 0 @@ -835,6 +2191,7 @@ function ownedSurfaceInvariantIsValid(sample) { sample.ownerWrapperVisible && sample.ownerContainerVisible && sample.ownerBarVisible && + sample.ownerVideoId === sample.expectedVideoId && sample.ownerTooltip?.includes(sample.expectedTooltip) && Math.abs(sample.ratio - sample.expectedRatio) <= 0.015 ); @@ -845,7 +2202,16 @@ async function waitForOwnedSurfaceStability(page, runtime, surface, timing = {}) intervalMs: 25, isValid: ownedSurfaceInvariantIsValid, label: `${runtime.name} ${surface.kind} ${surface.videoId} ownership`, - read: () => readOwnedSurfaceInvariant(page, runtime, surface.videoId, surface.counts, surface.kind), + read: () => + readOwnedSurfaceInvariant( + page, + runtime, + surface.videoId, + surface.counts, + surface.kind, + surface.shortIdentity, + surface.shortsDislikeControl, + ), stableForMs: timing.stableForMs ?? 250, timeoutMs: timing.timeoutMs ?? 2_000, }); @@ -866,12 +2232,21 @@ async function assertOwnedSurfaceContinuously(page, runtime, surface, durationMs intervalMs: 25, isValid: ownedSurfaceInvariantIsValid, label: `${runtime.name} settled ${surface.kind} ${surface.videoId}`, - read: () => readOwnedSurfaceInvariant(page, runtime, surface.videoId, surface.counts, surface.kind), + read: () => + readOwnedSurfaceInvariant( + page, + runtime, + surface.videoId, + surface.counts, + surface.kind, + surface.shortIdentity, + surface.shortsDislikeControl, + ), }); } function expectCountRequestVideoIds(backend, expectedVideoIds) { - expect(backend.requestsFor("GET", "/votes").map((request) => request.query.videoId)).toEqual(expectedVideoIds); + assertExactSuccessfulVotesTraffic(backend.requests, expectedVideoIds, "The shared navigation contract"); } async function readStandardProbe(page) { @@ -931,7 +2306,19 @@ async function runWatchSidebarRetainPruneScenario({ backend, page, runtime, scen sidebarConnected: true, timeline: ["navigate-start", "route-and-shell", "navigate-finish"], }); - await expect(page.locator("#fixture-matrix-retained-trees").locator(runtime.selectors.wrapper)).toHaveCount(1); + const retainedOutgoing = page.locator("#fixture-matrix-retained-trees"); + await expect(retainedOutgoing).toBeHidden(); + await expect( + page.locator('[data-fixture-role="dislike"]').locator(`:is(${runtime.selectors.watchDislikeCount}):visible`), + ).toHaveCount(0); + await expect(page.locator(`${runtime.selectors.wrapper}:visible`)).toHaveCount(0); + if (runtime.clearsOutgoingWatchPresentationOnNavigateStart) { + await expect( + retainedOutgoing.locator('[data-fixture-role="dislike"]').locator(runtime.selectors.watchDislikeCount), + ).toHaveText(""); + await expect(retainedOutgoing.locator(runtime.selectors.wrapper)).toHaveCount(0); + await expect(page.locator(runtime.selectors.wrapper)).toHaveCount(0); + } expect(backend.requestsFor("GET", "/votes").map((request) => request.query.videoId)).toEqual([origin.videoId]); await page.evaluate(() => globalThis.__navigationMatrixFixture.hydrateDestination()); @@ -950,9 +2337,19 @@ async function runWatchSidebarRetainPruneScenario({ backend, page, runtime, scen }); const gatedDestination = currentWatchLocators(page, runtime, destination.videoId); await expect(gatedDestination.controls).toHaveCount(1); - await expect(gatedDestination.controls.locator('[data-ryd-role="dislike"] #text')).toHaveText(""); + await expect(gatedDestination.controls).toBeVisible(); + await expect( + gatedDestination.controls.locator('[data-fixture-role="dislike"]').locator(runtime.selectors.watchDislikeCount), + ).toHaveText(""); await expect(gatedDestination.wrapper).toHaveCount(0); - await expect(page.locator("#fixture-matrix-retained-trees").locator(runtime.selectors.wrapper)).toHaveCount(1); + await expect(page.locator(`${runtime.selectors.wrapper}:visible`)).toHaveCount(0); + if (runtime.clearsOutgoingWatchPresentationOnNavigateStart) { + await expect( + retainedOutgoing.locator('[data-fixture-role="dislike"]').locator(runtime.selectors.watchDislikeCount), + ).toHaveText(""); + await expect(retainedOutgoing.locator(runtime.selectors.wrapper)).toHaveCount(0); + await expect(page.locator(runtime.selectors.wrapper)).toHaveCount(0); + } } finally { if (!destinationCountGate.released) { destinationCountGate.release({ body: { ...destination.counts, rating: 4.5 } }); @@ -1002,7 +2399,13 @@ async function runWatchSidebarRetainPruneScenario({ backend, page, runtime, scen expect(backend.requestsFor("POST", "/interact/confirmVote")).toHaveLength(0); } -async function runWatchSameRootHiddenFirstScenario({ backend, page, runtime, scenario }) { +async function runWatchSameRootHiddenFirstScenario({ + backend, + beforeNonCurrentDuplicateDetach, + page, + runtime, + scenario, +}) { const { destination, origin } = scenario; const initialDocumentIdentity = await page.evaluate( () => globalThis.__navigationMatrixSameRootFixture.snapshot().documentIdentity, @@ -1067,6 +2470,8 @@ async function runWatchSameRootHiddenFirstScenario({ backend, page, runtime, sce sidebarConnected: true, }); + await beforeNonCurrentDuplicateDetach?.(); + await page.evaluate(() => globalThis.__navigationMatrixSameRootFixture.detachOutgoing()); await assertOwnedSurfaceContinuously(page, runtime, destination, 550); await expect(currentRoot.locator("ytd-menu-renderer.ytd-watch-metadata > div")).toHaveCount(1); @@ -1151,6 +2556,53 @@ async function runWatchAutoplayScenario({ backend, page, runtime, scenario }) { expectCountRequestVideoIds(backend, [origin.videoId, destination.videoId]); } +async function runWatchStartWithoutFinishScenario({ backend, page, runtime, scenario }) { + const { destination, origin } = scenario; + const initialProbe = await readStandardProbe(page); + await expectOwnedWatchBar(page, runtime, origin.videoId, origin.counts); + expectCountRequestVideoIds(backend, [origin.videoId]); + + await page.evaluate( + (videoId) => globalThis.__navigationFixture.navigateWatchAfterStartWithoutFinish(videoId), + destination.videoId, + ); + + await expect(page).toHaveURL((url) => url.pathname === "/watch" && url.searchParams.get("v") === destination.videoId); + expect(await readStandardProbe(page)).toMatchObject({ + currentKind: "watch", + currentVideoId: destination.videoId, + documentIdentity: initialProbe.documentIdentity, + historyRenders: [], + navigateFinishes: 0, + navigateStarts: 1, + transitionPending: "watch-start-without-finish", + }); + const outgoingControls = page.locator(`[data-fixture-control-video-id="${origin.videoId}"]`); + await expect(outgoingControls).toHaveCount(1); + await expect(outgoingControls).toBeVisible(); + await page.waitForTimeout(650); + expectCountRequestVideoIds(backend, [origin.videoId]); + + await page.evaluate(() => globalThis.__navigationFixture.finishWatchAfterStartWithoutFinish()); + await expect(outgoingControls).toHaveCount(0); + + await waitForOwnedSurfaceWithinBudget(page, runtime, destination); + await expectOwnedWatchBar(page, runtime, destination.videoId, destination.counts); + expectCountRequestVideoIds(backend, [origin.videoId, destination.videoId]); + expect(await readStandardProbe(page)).toMatchObject({ + currentKind: "watch", + currentVideoId: destination.videoId, + documentIdentity: initialProbe.documentIdentity, + historyRenders: [], + navigateFinishes: 0, + navigateStarts: 1, + transitionPending: null, + }); + + await assertOwnedSurfaceContinuously(page, runtime, destination); + expectCountRequestVideoIds(backend, [origin.videoId, destination.videoId]); +} + async function runCrossSurfaceScenario({ backend, page, runtime, scenario }) { const { destination, origin } = scenario; const initialProbe = await readStandardProbe(page); @@ -1182,7 +2634,7 @@ async function runCrossSurfaceScenario({ backend, page, runtime, scenario }) { }); expectCountRequestVideoIds(backend, [origin.videoId]); - await waitForOwnedSurfaceWithinBudget(page, runtime, destination); + await waitForOwnedSurfaceWithinBudget(page, runtime, destination, scenario.timing?.maxFirstValidMs); if (destination.kind === "watch") { await expectOwnedWatchBar(page, runtime, destination.videoId, destination.counts); } else { @@ -1219,12 +2671,16 @@ async function runWatchActionContainerReplacementScenario({ backend, page, runti replacement.querySelectorAll(selectors.wrapper).forEach((element) => element.remove()); const controls = replacement.querySelector("[data-fixture-control-video-id]"); for (const role of ["like", "dislike"]) { - const control = controls.querySelector(`[data-ryd-role="${role}"]`); + const control = controls.querySelector(`[data-fixture-role="${role}"]`); control.classList.remove("style-default-active"); control.classList.add("style-text"); control.querySelector("button")?.setAttribute("aria-pressed", "false"); } - controls.querySelector('[data-ryd-role="dislike"] #text').textContent = ""; + controls + .querySelectorAll( + '[data-fixture-role="dislike"] .ytSpecButtonShapeNextButtonTextContent, [data-fixture-role="dislike"] #text', + ) + .forEach((element) => element.remove()); const stats = { addedWrappers: 0 }; const observer = new MutationObserver((mutations) => { @@ -1275,6 +2731,11 @@ async function runWatchActionContainerReplacementScenario({ backend, page, runti async function runWatchSameNodeRouteCompletionScenario({ backend, page, runtime, scenario }) { const { destination, origin } = scenario; + expect(expectedDislikeText(origin.counts)).toBe(expectedDislikeText(destination.counts)); + expect(origin.counts.dislikes).not.toBe(destination.counts.dislikes); + expect(origin.counts.likes / (origin.counts.likes + origin.counts.dislikes)).not.toBe( + destination.counts.likes / (destination.counts.likes + destination.counts.dislikes), + ); const initialTopology = await page.evaluate(() => globalThis.__navigationMatrixSameNodeFixture.snapshot()); await expectOwnedWatchBar(page, runtime, origin.videoId, origin.counts); expectCountRequestVideoIds(backend, [origin.videoId]); @@ -1282,22 +2743,40 @@ async function runWatchSameNodeRouteCompletionScenario({ backend, page, runtime, const destinationCountGate = backend.defer("GET", "/votes"); await page.locator("#fixture-matrix-same-node-watch").click(); await expect(page).toHaveURL((url) => url.pathname === "/watch" && url.searchParams.get("v") === destination.videoId); + expect(await page.evaluate(() => globalThis.__navigationMatrixSameNodeFixture.snapshot())).toMatchObject({ + countAfterNavigateStart: "", + countAfterRouteAndRoot: "", + timeline: ["navigate-start", "route-and-root-only", "navigate-finish"], + }); const destinationRequest = await destinationCountGate.seen; expect(destinationRequest.query.videoId).toBe(destination.videoId); const pendingDestination = currentWatchLocators(page, runtime, destination.videoId); await expect(pendingDestination.controls).toHaveCount(1); - await expect(pendingDestination.controls.locator('[data-ryd-role="dislike"] #text')).toHaveText(""); + await expect( + pendingDestination.controls.locator('[data-fixture-role="dislike"]').locator(runtime.selectors.watchDislikeCount), + ).toHaveText(""); await expect(pendingDestination.wrapper).toHaveCount(0); - await expect(page.locator(`${runtime.selectors.wrapper}[data-ryd-video-id="${origin.videoId}"]`)).toHaveCount(0); + if (runtime.selectors.wrapperVideoAttribute) { + await expect( + page.locator(`${runtime.selectors.wrapper}[${runtime.selectors.wrapperVideoAttribute}="${origin.videoId}"]`), + ).toHaveCount(0); + } destinationCountGate.release({ body: { ...destination.counts, rating: 4.5 } }); await waitForOwnedSurfaceWithinBudget(page, runtime, destination); const destinationLocators = await expectOwnedWatchBar(page, runtime, destination.videoId, destination.counts); - await expect(destinationLocators.wrapper).toHaveAttribute("data-ryd-video-id", destination.videoId); + if (runtime.selectors.wrapperVideoAttribute) { + await expect(destinationLocators.wrapper).toHaveAttribute( + runtime.selectors.wrapperVideoAttribute, + destination.videoId, + ); + } expectCountRequestVideoIds(backend, [origin.videoId, destination.videoId]); expect(await page.evaluate(() => globalThis.__navigationMatrixSameNodeFixture.snapshot())).toEqual({ buttonsReused: true, + countAfterNavigateStart: "", + countAfterRouteAndRoot: "", controlsReused: true, dislikeReused: true, documentIdentity: initialTopology.documentIdentity, @@ -1333,6 +2812,10 @@ async function runWatchRateBarCorruptionScenario({ backend, page, runtime, scena wrapper.style.overflow = "hidden"; } else if (corruptionKind === "missing-fill") { fill.remove(); + } else if (corruptionKind === "missing-video-owner") { + wrapper.removeAttribute(selectors.wrapperVideoAttribute); + } else if (corruptionKind === "stale-video-owner") { + wrapper.setAttribute(selectors.wrapperVideoAttribute, "stalevid001"); } else if (corruptionKind === "stripped-wrapper-class") { wrapper.classList.remove("ryd-tooltip"); } else { @@ -1387,10 +2870,602 @@ async function runShortNextScenario({ backend, page, runtime, scenario }) { expectCountRequestVideoIds(backend, [origin.videoId, destination.videoId]); } +async function runShortSameRendererStartOnlyScenario({ backend, page, runtime, scenario }) { + const { destination, origin } = scenario; + const repeatedStart = scenario.transition.navigateStarts === "before-and-after-route"; + const expectedTimeline = repeatedStart + ? ["navigate-start-a", "synthetic-removed-before-route", "route-and-exact-href-only", "navigate-start-b"] + : ["navigate-start", "route-and-exact-href-only"]; + const expectedImmediateAfterRoute = { + actionBarReused: true, + href: `/shorts/${destination.videoId}`, + isActive: false, + likeReused: true, + rendererReused: true, + rendererVideoId: null, + ...(repeatedStart + ? { + nativeDislikeReused: false, + syntheticCount: 0, + syntheticReused: false, + } + : { + nativeDislikeReused: true, + syntheticCount: 1, + syntheticReused: true, + }), + }; + await expectOwnedShortControl(page, runtime, origin.videoId, origin.counts); + expectCountRequestVideoIds(backend, [origin.videoId]); + + const initial = await page.evaluate(() => globalThis.__navigationMatrixSameRendererShortFixture.snapshot()); + await page.locator("#short-next").click(); + await expect(page).toHaveURL((url) => url.pathname === `/shorts/${destination.videoId}`); + + const expectedShortHrefs = destination.unrelatedDescriptionShortVideoId + ? [`/shorts/${destination.videoId}`, `/shorts/${destination.unrelatedDescriptionShortVideoId}`] + : [`/shorts/${destination.videoId}`]; + + expect(await page.evaluate(() => globalThis.__navigationMatrixSameRendererShortFixture.snapshot())).toMatchObject({ + actionBarReused: true, + documentIdentity: initial.documentIdentity, + href: `/shorts/${destination.videoId}`, + immediatelyAfterRoute: expectedImmediateAfterRoute, + isActive: false, + likeReused: true, + navigateFinishes: 0, + rendererReused: true, + rendererVideoId: null, + shortHrefs: expectedShortHrefs, + timeline: expectedTimeline, + visibleRendererCount: 1, + }); + const immediateAfterRoute = await page.evaluate( + () => globalThis.__navigationMatrixSameRendererShortFixture.snapshot().immediatelyAfterRoute, + ); + if (repeatedStart) { + expect(immediateAfterRoute).toMatchObject({ + nativeDislikeReused: false, + syntheticCount: 0, + syntheticDisabled: null, + syntheticReused: false, + syntheticText: null, + }); + } else if (runtime.name === "extension") { + expect(immediateAfterRoute).toMatchObject({ + nativeDislikeReused: true, + syntheticCount: 1, + syntheticDisabled: true, + syntheticReused: true, + syntheticText: "", + }); + } + + await waitForOwnedSurfaceWithinBudget(page, runtime, destination); + await expectOwnedShortControl(page, runtime, destination.videoId, destination.counts, destination.shortIdentity); + expectCountRequestVideoIds(backend, [origin.videoId, destination.videoId]); + expect(await page.evaluate(() => globalThis.__navigationMatrixSameRendererShortFixture.snapshot())).toMatchObject({ + actionBarReused: true, + href: `/shorts/${destination.videoId}`, + isActive: false, + likeReused: true, + nativeDislikeReused: !repeatedStart, + navigateFinishes: 0, + rendererReused: true, + rendererVideoId: null, + shortHrefs: expectedShortHrefs, + syntheticCount: 1, + syntheticReused: !repeatedStart, + timeline: expectedTimeline, + visibleRendererCount: 1, + }); + + await assertOwnedSurfaceContinuously(page, runtime, destination); + expectCountRequestVideoIds(backend, [origin.videoId, destination.videoId]); +} + +async function runShortPersistentDataNullScenario({ backend, page, runtime, scenario }) { + const { destination, origin } = scenario; + const expectedTimeline = ["navigate-start", "route-and-fresh-five-native-persistent-data-null-action-root"]; + const readFixture = () => page.evaluate(() => globalThis.__navigationMatrixPersistentDataNullShortFixture.snapshot()); + const expectNoInteractions = () => + expect( + backend.requests.filter( + (request) => + request.method === "POST" && ["/interact/vote", "/interact/confirmVote"].includes(request.pathname), + ), + ).toEqual([]); + + await expectOwnedShortControl(page, runtime, origin.videoId, origin.counts); + expectCountRequestVideoIds(backend, [origin.videoId]); + expectNoInteractions(); + const initial = await readFixture(); + + await page.locator("#short-next").click(); + await expect(page).toHaveURL((url) => url.pathname === `/shorts/${destination.videoId}`); + expect(await readFixture()).toMatchObject({ + actionBarConnected: true, + actionBarDataReady: false, + actionBarDataWriteCount: 0, + actionBarReplaced: true, + actionNames: ["like", "comments", "share", "remix", "pivot"], + currentActionButtonCount: 5, + currentNativeChildCount: 5, + currentSyntheticCount: 0, + currentSyntheticVideoIds: [], + documentIdentity: initial.documentIdentity, + exactHrefRendererCount: 1, + hitTestedActionButtonCount: 5, + href: `/shorts/${destination.videoId}`, + isActive: false, + nativeChildrenStable: true, + nativeDislikeCount: 0, + navigateFinishes: 0, + navigateStarts: 1, + originActionBarConnected: false, + originSyntheticConnected: false, + rendererReused: true, + rendererVideoId: null, + syntheticEnabled: false, + timeline: expectedTimeline, + visibleActionButtonCount: 5, + visibleRendererCount: 1, + }); + + await page.waitForTimeout(scenario.timing.unsafeWindowMs); + const unsafeWindow = await readFixture(); + expect(unsafeWindow).toMatchObject({ + actionBarConnected: true, + actionBarDataReadCount: expect.any(Number), + actionBarDataReady: false, + actionBarDataWriteCount: 0, + actionBarReplaced: true, + actionNames: ["like", "comments", "share", "remix", "pivot"], + currentActionButtonCount: 5, + currentNativeChildCount: 5, + currentSyntheticCount: 0, + exactHrefRendererCount: 1, + hitTestedActionButtonCount: 5, + href: `/shorts/${destination.videoId}`, + isActive: false, + nativeChildrenStable: true, + nativeDislikeCount: 0, + rendererVideoId: null, + visibleActionButtonCount: 5, + visibleRendererCount: 1, + }); + expect(unsafeWindow.actionBarDataReadCount).toBeGreaterThan(0); + expectCountRequestVideoIds(backend, [origin.videoId]); + expectNoInteractions(); + + await waitForOwnedSurfaceWithinBudget(page, runtime, destination, scenario.timing.maxFirstValidMs); + await expectOwnedShortControl(page, runtime, destination.videoId, destination.counts, destination.shortIdentity); + const recovered = await readFixture(); + expect(recovered).toMatchObject({ + actionBarConnected: true, + actionBarDataReadCount: expect.any(Number), + actionBarDataReady: false, + actionBarDataWriteCount: 0, + actionBarReplaced: true, + actionNames: ["like", "dislike", "comments", "share", "remix", "pivot"], + currentActionButtonCount: 6, + currentNativeChildCount: 5, + currentSyntheticCount: 1, + currentSyntheticVideoIds: [destination.videoId], + documentIdentity: initial.documentIdentity, + exactHrefRendererCount: 1, + hitTestedActionButtonCount: 6, + href: `/shorts/${destination.videoId}`, + isActive: false, + nativeChildrenStable: true, + nativeDislikeCount: 0, + navigateFinishes: 0, + navigateStarts: 1, + originActionBarConnected: false, + originSyntheticConnected: false, + rendererReused: true, + rendererVideoId: null, + syntheticEnabled: true, + timeline: expectedTimeline, + visibleActionButtonCount: 6, + visibleRendererCount: 1, + }); + expect(recovered.actionBarDataReadCount).toBeGreaterThan(unsafeWindow.actionBarDataReadCount); + expectCountRequestVideoIds(backend, [origin.videoId, destination.videoId]); + expectNoInteractions(); + + await assertOwnedSurfaceContinuously(page, runtime, destination); + expectCountRequestVideoIds(backend, [origin.videoId, destination.videoId]); + expectNoInteractions(); + expect(await readFixture()).toMatchObject({ + actionBarDataReady: false, + actionBarDataWriteCount: 0, + actionNames: ["like", "dislike", "comments", "share", "remix", "pivot"], + currentNativeChildCount: 5, + currentSyntheticCount: 1, + currentSyntheticVideoIds: [destination.videoId], + hitTestedActionButtonCount: 6, + nativeChildrenStable: true, + visibleActionButtonCount: 6, + }); +} + +async function runShortPersistentDataNullInertScenario({ backend, page, runtime, scenario }) { + const { destination, origin } = scenario; + const expectedTimeline = [ + "navigate-start", + "hide-one-native-activation-target", + "route-and-fresh-persistent-data-null-action-root", + ]; + const readFixture = () => + page.evaluate(() => globalThis.__navigationMatrixPersistentDataNullVariantFixture.snapshot()); + const requestState = () => ({ + interactionPaths: backend.requests + .filter( + (request) => + request.method === "POST" && ["/interact/vote", "/interact/confirmVote"].includes(request.pathname), + ) + .map((request) => request.pathname), + videoIds: backend.requests + .filter((request) => request.method === "GET" && request.pathname === "/votes") + .map((request) => request.query?.videoId), + }); + const geometryMatches = (actual, expected) => + actual !== null && + expected !== null && + ["bottom", "height", "left", "right", "top", "width"].every( + (property) => Math.abs(actual[property] - expected[property]) <= 0.5, + ); + + await expectOwnedShortControl(page, runtime, origin.videoId, origin.counts); + expectCountRequestVideoIds(backend, [origin.videoId]); + expect(requestState().interactionPaths).toEqual([]); + const initial = await readFixture(); + + await page.locator("#short-next").click(); + await expect(page).toHaveURL((url) => url.pathname === `/shorts/${destination.videoId}`); + const transitioned = await readFixture(); + expect(transitioned).toMatchObject({ + actionBarConnected: true, + actionBarDataReady: false, + actionBarDataWriteCount: 0, + actionBarReplaced: true, + actionNames: ["like", "comments", "share", "remix", "pivot"], + blockedActivationConnected: true, + currentActionButtonCount: 5, + currentNativeChildCount: 5, + currentSyntheticCount: 0, + documentIdentity: initial.documentIdentity, + exactHrefRendererCount: 1, + hitTestedActionButtonCount: 4, + href: `/shorts/${destination.videoId}`, + isActive: false, + nativeChildrenStable: true, + nativeDislikeCount: 0, + navigateFinishes: 0, + navigateStarts: 1, + nonRenderedActionButtonCount: 1, + originActionBarConnected: false, + originSyntheticConnected: false, + rendererReused: true, + rendererVideoId: null, + timeline: expectedTimeline, + visibleActionButtonCount: 4, + visibleRendererCount: 1, + }); + expect(transitioned.actionBarBounds?.height).toBeGreaterThan(0); + expect(transitioned.actionBarBounds?.width).toBeGreaterThan(0); + + await assertInvariantContinuously({ + durationMs: scenario.timing.inertForMs, + intervalMs: 25, + isValid: ({ fixture, requests }) => + fixture.actionBarConnected && + fixture.actionBarDataReady === false && + fixture.actionBarDataWriteCount === 0 && + fixture.currentActionButtonCount === 5 && + fixture.currentNativeChildCount === 5 && + fixture.currentSyntheticCount === 0 && + fixture.exactHrefRendererCount === 1 && + fixture.hitTestedActionButtonCount === 4 && + fixture.nativeChildrenStable && + fixture.nativeDislikeCount === 0 && + fixture.nonRenderedActionButtonCount === 1 && + fixture.visibleActionButtonCount === 4 && + fixture.visibleRendererCount === 1 && + geometryMatches(fixture.actionBarBounds, transitioned.actionBarBounds) && + requests.interactionPaths.length === 0 && + requests.videoIds.length === 1 && + requests.videoIds[0] === origin.videoId, + label: `${runtime.name} incomplete persistent data-null Shorts surface remains inert`, + read: async () => ({ fixture: await readFixture(), requests: requestState() }), + }); + + expectCountRequestVideoIds(backend, [origin.videoId]); + expect(requestState().interactionPaths).toEqual([]); + expect(await readFixture()).toMatchObject({ + actionBarDataReady: false, + actionBarDataWriteCount: 0, + currentSyntheticCount: 0, + hitTestedActionButtonCount: 4, + nativeChildrenStable: true, + nativeDislikeCount: 0, + nonRenderedActionButtonCount: 1, + visibleActionButtonCount: 4, + }); +} + +async function runShortPersistentDataNullNativeDislikeScenario({ backend, page, runtime, scenario }) { + const { destination, origin } = scenario; + const expectedTimeline = [ + "navigate-start", + "append-native-dislike", + "route-and-fresh-persistent-data-null-action-root", + ]; + const readFixture = () => + page.evaluate(() => globalThis.__navigationMatrixPersistentDataNullVariantFixture.snapshot()); + const expectNoInteractions = () => + expect( + backend.requests.filter( + (request) => + request.method === "POST" && ["/interact/vote", "/interact/confirmVote"].includes(request.pathname), + ), + ).toEqual([]); + + await expectOwnedShortControl(page, runtime, origin.videoId, origin.counts); + expectCountRequestVideoIds(backend, [origin.videoId]); + expectNoInteractions(); + const initial = await readFixture(); + + await page.locator("#short-next").click(); + await expect(page).toHaveURL((url) => url.pathname === `/shorts/${destination.videoId}`); + expect(await readFixture()).toMatchObject({ + actionBarConnected: true, + actionBarDataReady: false, + actionBarDataWriteCount: 0, + actionBarReplaced: true, + actionNames: ["like", "dislike", "comments", "share", "remix", "pivot"], + currentActionButtonCount: 6, + currentNativeChildCount: 6, + currentSyntheticCount: 0, + documentIdentity: initial.documentIdentity, + exactHrefRendererCount: 1, + hitTestedActionButtonCount: 6, + href: `/shorts/${destination.videoId}`, + isActive: false, + nativeChildrenStable: true, + nativeDislikeCount: 1, + nativeDislikeText: "", + navigateFinishes: 0, + navigateStarts: 1, + nonRenderedActionButtonCount: 0, + originActionBarConnected: false, + originSyntheticConnected: false, + rendererReused: true, + rendererVideoId: null, + timeline: expectedTimeline, + visibleActionButtonCount: 6, + visibleRendererCount: 1, + }); + expectCountRequestVideoIds(backend, [origin.videoId]); + expectNoInteractions(); + + await page.waitForTimeout(scenario.timing.unsafeWindowMs); + expect(await readFixture()).toMatchObject({ + actionBarDataReady: false, + actionBarDataWriteCount: 0, + actionNames: ["like", "dislike", "comments", "share", "remix", "pivot"], + currentActionButtonCount: 6, + currentNativeChildCount: 6, + currentSyntheticCount: 0, + hitTestedActionButtonCount: 6, + nativeChildrenStable: true, + nativeDislikeCount: 1, + nativeDislikeText: "", + nonRenderedActionButtonCount: 0, + visibleActionButtonCount: 6, + }); + expectCountRequestVideoIds(backend, [origin.videoId]); + expectNoInteractions(); + + const readiness = await waitForOwnedSurfaceWithinBudget(page, runtime, destination, scenario.timing.maxFirstValidMs); + expect(readiness.firstValidMs).toBeLessThan(scenario.timing.maxFirstValidMs); + await expectOwnedShortControl( + page, + runtime, + destination.videoId, + destination.counts, + destination.shortIdentity, + destination.shortsDislikeControl, + ); + const initialized = await readFixture(); + expect(initialized).toMatchObject({ + actionBarConnected: true, + actionBarDataReady: false, + actionBarDataWriteCount: 0, + actionBarReplaced: true, + actionNames: ["like", "dislike", "comments", "share", "remix", "pivot"], + blockedActivationConnected: false, + currentActionButtonCount: 6, + currentNativeChildCount: 6, + currentSyntheticCount: 0, + documentIdentity: initial.documentIdentity, + exactHrefRendererCount: 1, + hitTestedActionButtonCount: 6, + href: `/shorts/${destination.videoId}`, + isActive: false, + nativeChildrenStable: true, + nativeDislikeCount: 1, + nativeDislikeText: String(destination.counts.dislikes), + navigateFinishes: 0, + navigateStarts: 1, + nonRenderedActionButtonCount: 0, + originActionBarConnected: false, + originSyntheticConnected: false, + rendererReused: true, + rendererVideoId: null, + timeline: expectedTimeline, + visibleActionButtonCount: 6, + visibleRendererCount: 1, + }); + expect(initialized.actionBarBounds?.height).toBeGreaterThan(0); + expect(initialized.actionBarBounds?.width).toBeGreaterThan(0); + expect(initialized.actionBarDataReadCount).toBeGreaterThan(0); + expectCountRequestVideoIds(backend, [origin.videoId, destination.videoId]); + expectNoInteractions(); + + await assertOwnedSurfaceContinuously(page, runtime, destination); + expectCountRequestVideoIds(backend, [origin.videoId, destination.videoId]); + expectNoInteractions(); + expect(await readFixture()).toMatchObject({ + actionBarDataReady: false, + actionBarDataWriteCount: 0, + currentNativeChildCount: 6, + currentSyntheticCount: 0, + nativeChildrenStable: true, + nativeDislikeCount: 1, + nativeDislikeText: String(destination.counts.dislikes), + }); +} + +async function runShortReplacedRootStartOnlyScenario({ backend, page, runtime, scenario }) { + const { destination, origin } = scenario; + const repeatedStart = scenario.transition.navigateStarts === "before-and-after-route"; + const expectedRouteTimeline = repeatedStart + ? ["navigate-start-a", "route-and-root-without-action-bar", "navigate-start-b"] + : ["navigate-start", "route-and-root-without-action-bar"]; + + await expectOwnedShortControl(page, runtime, origin.videoId, origin.counts); + expectCountRequestVideoIds(backend, [origin.videoId]); + const initial = await page.evaluate(() => globalThis.__navigationMatrixReplacedRootShortFixture.snapshot()); + + await page.locator("#short-next").click(); + await expect(page).toHaveURL((url) => url.pathname === `/shorts/${destination.videoId}`); + expect(await page.evaluate(() => globalThis.__navigationMatrixReplacedRootShortFixture.snapshot())).toEqual({ + actionBarConnected: false, + actionBarDataReadCount: 0, + actionBarDataReady: false, + actionBarFirstDataReadDelayMs: null, + currentActionButtonCount: 0, + currentNativeChildCount: 0, + currentSyntheticCount: 0, + currentSyntheticVideoIds: [], + destinationRendererConnected: true, + destinationRendererVisible: true, + destinationRootConnected: true, + documentIdentity: initial.documentIdentity, + navigateFinishes: 0, + navigateStarts: repeatedStart ? 2 : 1, + originRootConnected: false, + originSyntheticConnected: false, + rendererVideoId: destination.videoId, + timeline: expectedRouteTimeline, + }); + + // Cross the periodic lifecycle boundary while the selected destination has + // no action root at all. This is the exact state that previously deadlocked + // the extension after its observer stayed attached to the detached A root. + await page.waitForTimeout(650); + expectCountRequestVideoIds(backend, [origin.videoId]); + await expect(page.locator("[data-ryd-synthetic-shorts-dislike]")).toHaveCount(0); + + await page.evaluate(() => globalThis.__navigationMatrixReplacedRootShortFixture.appendEmptyActionBar()); + await expect + .poll( + () => + page.evaluate(() => globalThis.__navigationMatrixReplacedRootShortFixture.snapshot().actionBarDataReadCount), + { + intervals: [25], + message: `${runtime.name} must observe the replacement Shorts root before the next 500ms watchdog tick`, + timeout: 225, + }, + ) + .toBeGreaterThan(0); + expect(await page.evaluate(() => globalThis.__navigationMatrixReplacedRootShortFixture.snapshot())).toMatchObject({ + actionBarConnected: true, + actionBarDataReadCount: expect.any(Number), + actionBarDataReady: false, + actionBarFirstDataReadDelayMs: expect.any(Number), + currentActionButtonCount: 0, + currentNativeChildCount: 0, + currentSyntheticCount: 0, + timeline: [...expectedRouteTimeline, "append-empty-data-null-action-bar"], + }); + expect( + await page.evaluate( + () => globalThis.__navigationMatrixReplacedRootShortFixture.snapshot().actionBarFirstDataReadDelayMs, + ), + ).toBeLessThanOrEqual(175); + expectCountRequestVideoIds(backend, [origin.videoId]); + + await page.evaluate(() => globalThis.__navigationMatrixReplacedRootShortFixture.appendNativeChildren()); + await page.waitForTimeout(350); + expect(await page.evaluate(() => globalThis.__navigationMatrixReplacedRootShortFixture.snapshot())).toMatchObject({ + actionBarConnected: true, + actionBarDataReadCount: expect.any(Number), + actionBarDataReady: false, + actionBarFirstDataReadDelayMs: expect.any(Number), + currentActionButtonCount: 5, + currentNativeChildCount: 5, + currentSyntheticCount: 0, + timeline: [ + ...expectedRouteTimeline, + "append-empty-data-null-action-bar", + "append-complete-native-inventory-data-null", + ], + }); + expectCountRequestVideoIds(backend, [origin.videoId]); + + await page.evaluate(() => globalThis.__navigationMatrixReplacedRootShortFixture.hydrateActionBar()); + await waitForOwnedSurfaceWithinBudget(page, runtime, destination); + await expectOwnedShortControl(page, runtime, destination.videoId, destination.counts); + expectCountRequestVideoIds(backend, [origin.videoId, destination.videoId]); + expect(await page.evaluate(() => globalThis.__navigationMatrixReplacedRootShortFixture.snapshot())).toEqual({ + actionBarConnected: true, + actionBarDataReadCount: expect.any(Number), + actionBarDataReady: true, + actionBarFirstDataReadDelayMs: expect.any(Number), + currentActionButtonCount: 6, + currentNativeChildCount: 5, + currentSyntheticCount: 1, + currentSyntheticVideoIds: [destination.videoId], + destinationRendererConnected: true, + destinationRendererVisible: true, + destinationRootConnected: true, + documentIdentity: initial.documentIdentity, + navigateFinishes: 0, + navigateStarts: repeatedStart ? 2 : 1, + originRootConnected: false, + originSyntheticConnected: false, + rendererVideoId: destination.videoId, + timeline: [ + ...expectedRouteTimeline, + "append-empty-data-null-action-bar", + "append-complete-native-inventory-data-null", + "hydrate-action-bar-data", + ], + }); + + await assertOwnedSurfaceContinuously(page, runtime, destination); + expectCountRequestVideoIds(backend, [origin.videoId, destination.videoId]); +} + const SCENARIO_RUNNERS = { "short-next-short-active-reel": runShortNextScenario, + "short-next-short-active-reel-compact": runShortNextScenario, + "short-next-short-active-reel-medium": runShortNextScenario, + "short-next-short-exact-href-with-description-crosslink": runShortSameRendererStartOnlyScenario, + "short-next-short-persistent-data-null-native-dislike": runShortPersistentDataNullNativeDislikeScenario, + "short-next-short-persistent-data-null-nonrendered-native-stays-inert": runShortPersistentDataNullInertScenario, + "short-next-short-replace-root-repeated-start-no-finish-staged-hydration": runShortReplacedRootStartOnlyScenario, + "short-next-short-replace-root-start-no-finish-staged-hydration": runShortReplacedRootStartOnlyScenario, + "short-next-short-reuse-renderer-replace-action-root-exact-href-persistent-data-null": + runShortPersistentDataNullScenario, + "short-next-short-reuse-renderer-repeated-start-no-finish": runShortSameRendererStartOnlyScenario, + "short-next-short-reuse-renderer-start-no-finish": runShortSameRendererStartOnlyScenario, "short-direct-watch-delayed": runCrossSurfaceScenario, "watch-autoplay-watch-replace-no-finish": runWatchAutoplayScenario, + "watch-navigate-start-watch-replace-no-finish": runWatchStartWithoutFinishScenario, "watch-current-action-container-replace": runWatchActionContainerReplacementScenario, "watch-current-rate-bar-connected-corruption": runWatchRateBarCorruptionScenario, "watch-direct-short-delayed": runCrossSurfaceScenario, @@ -1401,6 +3476,7 @@ const SCENARIO_RUNNERS = { "watch-sidebar-watch-same-root-offscreen-first": runWatchSameRootHiddenFirstScenario, "watch-sidebar-watch-same-node-route-complete": runWatchSameNodeRouteCompletionScenario, }; +const NAVIGATION_SCENARIO_RUNNER_IDS = Object.freeze(Object.keys(SCENARIO_RUNNERS)); async function runNavigationMatrixScenario(options) { const runner = SCENARIO_RUNNERS[options.scenario.id]; @@ -1409,9 +3485,14 @@ async function runNavigationMatrixScenario(options) { } module.exports = { + EXTENSION_MATRIX_RUNTIME, NAVIGATION_MATRIX, + NAVIGATION_SCENARIO_RUNNER_IDS, + NO_DESTINATION_DISLIKE_POSTCONDITION, + SINGLE_DESTINATION_DISLIKE_POSTCONDITION, USERSCRIPT_MATRIX_RUNTIME, WATCH_SIDEBAR_MATRIX, + getDestinationDislikePostconditionTarget, installNavigationMatrixFixture, runNavigationMatrixScenario, }; diff --git a/Extensions/UserScript/e2e/userscript-delegated-activation.e2e.js b/Extensions/UserScript/e2e/userscript-delegated-activation.e2e.js new file mode 100644 index 0000000..63ace6f --- /dev/null +++ b/Extensions/UserScript/e2e/userscript-delegated-activation.e2e.js @@ -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([]); +}); diff --git a/Extensions/UserScript/e2e/userscript-navigation-lifecycle.e2e.js b/Extensions/UserScript/e2e/userscript-navigation-lifecycle.e2e.js index f923d7a..a8816b4 100644 --- a/Extensions/UserScript/e2e/userscript-navigation-lifecycle.e2e.js +++ b/Extensions/UserScript/e2e/userscript-navigation-lifecycle.e2e.js @@ -49,8 +49,8 @@ async function expectWatchInitialized(page, videoId, expectedDislikes = COUNTS[v const expectedCount = String(expectedDislikes); await expect(page).toHaveURL((url) => url.pathname === "/watch" && url.searchParams.get("v") === videoId); await expect(page.locator(`ytd-watch-flexy[video-id="${videoId}"]`)).toHaveCount(1); - await expect(page.locator('[data-fixture-page-kind="watch"] [data-ryd-role="buttons"]')).toHaveCount(1); - await expect(page.locator('[data-fixture-page-kind="watch"] [data-ryd-role="dislike"] #text')).toHaveText( + await expect(page.locator('[data-fixture-page-kind="watch"] [data-fixture-role="buttons"]')).toHaveCount(1); + await expect(page.locator('[data-fixture-page-kind="watch"] [data-fixture-role="dislike"] #text')).toHaveText( expectedCount, ); await expect(page.locator("#return-youtube-dislike-bar-container")).toHaveCount(1); @@ -66,7 +66,7 @@ async function expectShortInitialized(page, videoId, expectedDislikes = COUNTS[v await expect(page.locator("ytd-shorts")).toHaveCount(1); await expect(activeRenderer).toHaveCount(1); await expect( - page.locator('ytd-reel-video-renderer[is-active] reel-action-bar-view-model[data-ryd-role="buttons"]'), + page.locator('ytd-reel-video-renderer[is-active] reel-action-bar-view-model[data-fixture-role="buttons"]'), ).toHaveCount(1); const syntheticDislike = activeRenderer.locator("[data-ryd-synthetic-shorts-dislike]"); await expect(syntheticDislike).toHaveCount(1); @@ -85,9 +85,9 @@ async function expectMobileShortInitialized(page, videoId, expectedDislikes = CO await expect(activeShort).toHaveCount(1); await expect(activeShort).toBeVisible(); await expect(page.locator("[data-fixture-mobile-short][is-active]:visible")).toHaveCount(1); - await expect(activeShort.locator('ytm-like-button-renderer[data-ryd-role="buttons"]')).toHaveCount(1); - await expect(activeShort.locator('[data-ryd-role="dislike"] #text')).toHaveText(String(expectedDislikes)); - await expect(activeShort.locator('[data-ryd-role="dislike"] button')).toBeEnabled(); + await expect(activeShort.locator('ytm-like-button-renderer[data-fixture-role="buttons"]')).toHaveCount(1); + await expect(activeShort.locator('[data-fixture-role="dislike"] #text')).toHaveText(String(expectedDislikes)); + await expect(activeShort.locator('[data-fixture-role="dislike"] button')).toBeEnabled(); await expect(page.locator("[data-ryd-synthetic-shorts-dislike]")).toHaveCount(0); await expect(page.locator("#return-youtube-dislike-bar-container")).toHaveCount(0); } @@ -95,7 +95,9 @@ async function expectMobileShortInitialized(page, videoId, expectedDislikes = CO async function expectOneActivation(page, backend, videoId) { const voteCount = backend.requestsFor("POST", "/interact/vote").length; const confirmationCount = backend.requestsFor("POST", "/interact/confirmVote").length; - await page.locator('[data-ryd-role="dislike"]:visible button').click(); + await page + .locator('[data-fixture-role="dislike"]:visible button, [data-ryd-synthetic-shorts-dislike]:visible button') + .click(); await expect.poll(() => backend.requestsFor("POST", "/interact/confirmVote").length).toBe(confirmationCount + 1); await page.waitForTimeout(600); @@ -125,7 +127,7 @@ test("cold channel reload then immediate Short link initializes delayed Shorts c await page.locator("#channel-short").click(); await expect(page).toHaveURL((url) => url.pathname === `/shorts/${VIDEO_A}`); - const decoyDislikeCount = page.locator('[data-fixture-decoy-controls] [data-ryd-role="dislike"] #text'); + const decoyDislikeCount = page.locator('[data-fixture-decoy-controls] [data-fixture-role="dislike"] #text'); await page.waitForTimeout(250); await expect(decoyDislikeCount).toHaveText(""); await expect(page.locator("[data-ryd-synthetic-shorts-dislike]")).toHaveCount(0); @@ -320,9 +322,9 @@ test("watch to Short link switches page kind, identity, and controls", async ({ await page.locator("#watch-to-short").click(); await expect(page).toHaveURL((url) => url.pathname === `/shorts/${VIDEO_B}`); - await expect(page.locator('[data-fixture-page-kind="watch"] [data-ryd-role="dislike"] #text')).toHaveText("11"); + await expect(page.locator('[data-fixture-page-kind="watch"] [data-fixture-role="dislike"] #text')).toHaveText("11"); await page.waitForTimeout(50); - await expect(page.locator('[data-fixture-page-kind="watch"] [data-ryd-role="dislike"] #text')).toHaveText("11"); + await expect(page.locator('[data-fixture-page-kind="watch"] [data-fixture-role="dislike"] #text')).toHaveText("11"); await expect(page.locator("[data-ryd-synthetic-shorts-dislike]")).toHaveCount(0); await expectShortInitialized(page, VIDEO_B); @@ -415,8 +417,8 @@ test("delayed mobile Short navigation never binds the outgoing renderer to the t await expect(outgoingRenderer.locator(`a[href="/shorts/${VIDEO_B}"]`)).toHaveCount(1); await expect(page.locator(`ytm-reel-video-renderer[video-id="${VIDEO_B}"]`)).toHaveCount(0); await page.waitForTimeout(600); - await expect(outgoingRenderer.locator('[data-ryd-role="dislike"] #text')).toHaveText("11"); - await outgoingRenderer.locator('[data-ryd-role="dislike"] button').click(); + await expect(outgoingRenderer.locator('[data-fixture-role="dislike"] #text')).toHaveText("11"); + await outgoingRenderer.locator('[data-fixture-role="dislike"] button').click(); await page.waitForTimeout(100); expect( harness.backend.requestsFor("GET", "/votes").filter((request) => request.query.videoId === VIDEO_B), @@ -561,7 +563,7 @@ test("watch SPA navigation refreshes one aligned ratio bar with the target video await expect(wrapper).toBeVisible(); await expect(container).toBeVisible(); await expect(bar).toBeVisible(); - await expect(currentControls.locator('[data-ryd-role="dislike"] #text')).toHaveText("22"); + await expect(currentControls.locator('[data-fixture-role="dislike"] #text')).toHaveText("22"); await expect(tooltip).toContainText("200 / 22"); await expect(tooltip).toBeHidden(); @@ -578,8 +580,8 @@ test("watch SPA navigation refreshes one aligned ratio bar with the target video }; }; const controls = reactionRegion.querySelector("[data-fixture-control-video-id]"); - const like = controls.querySelector('[data-ryd-role="like"] button'); - const dislike = controls.querySelector('[data-ryd-role="dislike"] button'); + const like = controls.querySelector('[data-fixture-role="like"] button'); + const dislike = controls.querySelector('[data-fixture-role="dislike"] button'); const ratioWrapper = reactionRegion.querySelector(":scope > .ryd-tooltip"); const ratioContainer = ratioWrapper.querySelector("#return-youtube-dislike-bar-container"); const ratioFill = ratioContainer.querySelector("#return-youtube-dislike-bar"); @@ -651,7 +653,7 @@ test("a pruned current watch ratio bar is restored once with the current video's await expect(wrapper).toBeVisible(); await expect(container).toBeVisible(); await expect(bar).toBeVisible(); - await expect(currentControls.locator('[data-ryd-role="dislike"] #text')).toHaveText("22"); + await expect(currentControls.locator('[data-fixture-role="dislike"] #text')).toHaveText("22"); await expect(tooltip).toContainText("200 / 22"); await expect(tooltip).toBeHidden(); expect(await wrapper.evaluate((restoredWrapper) => restoredWrapper !== globalThis.__fixturePrunedWatchBar)).toBe( @@ -787,8 +789,8 @@ test("delayed watch navigation never binds outgoing controls to the target video await expect(outgoingControls.locator('[data-fixture-irrelevant-watch-count-mutation="true"]')).toHaveCount(1); await expect(page.locator('[data-fixture-irrelevant-watch-tooltip-mutation="true"]')).toHaveCount(1); await page.waitForTimeout(600); - await expect(outgoingControls.locator('[data-ryd-role="dislike"] #text')).toHaveText("11"); - await outgoingControls.locator('[data-ryd-role="dislike"] button').click(); + await expect(outgoingControls.locator('[data-fixture-role="dislike"] #text')).toHaveText("11"); + await outgoingControls.locator('[data-fixture-role="dislike"] button').click(); await page.waitForTimeout(100); expect( harness.backend.requestsFor("GET", "/votes").filter((request) => request.query.videoId === VIDEO_B), @@ -796,7 +798,7 @@ test("delayed watch navigation never binds outgoing controls to the target video expect(harness.backend.requestsFor("POST", "/interact/vote")).toHaveLength(0); expect(harness.backend.requestsFor("POST", "/interact/confirmVote")).toHaveLength(0); - const outgoingDislike = outgoingControls.locator('[data-ryd-role="dislike"]'); + const outgoingDislike = outgoingControls.locator('[data-fixture-role="dislike"]'); await page.evaluate(() => window.__navigationFixture.replaceDelayedWatchControl("like")); await expect(outgoingControls.locator('[data-fixture-watch-replacement="like"]')).toHaveCount(1); await expect(outgoingDislike).toBeVisible(); @@ -826,8 +828,8 @@ test("same-node watch reuse requires independent native refresh on both activati const harness = await launchNavigationHarness({ context, page }, { pageKind: "watch", videoId: VIDEO_A }); await expectWatchInitialized(page, VIDEO_A); const reusedControls = page.locator(`[data-fixture-control-video-id="${VIDEO_A}"]`); - const reusedLikeButton = reusedControls.locator('[data-ryd-role="like"] button'); - const reusedDislikeButton = reusedControls.locator('[data-ryd-role="dislike"] button'); + const reusedLikeButton = reusedControls.locator('[data-fixture-role="like"] button'); + const reusedDislikeButton = reusedControls.locator('[data-fixture-role="dislike"] button'); await reusedLikeButton.evaluate((button) => button.setAttribute("data-fixture-same-node", "like")); await reusedDislikeButton.evaluate((button) => button.setAttribute("data-fixture-same-node", "dislike")); @@ -836,7 +838,7 @@ test("same-node watch reuse requires independent native refresh on both activati await expect(page).toHaveURL((url) => url.pathname === "/watch" && url.searchParams.get("v") === VIDEO_B); await page.waitForTimeout(600); - await expect(reusedControls.locator('[data-ryd-role="dislike"] #text')).toHaveText("11"); + await expect(reusedControls.locator('[data-fixture-role="dislike"] #text')).toHaveText("11"); await reusedDislikeButton.click(); await page.waitForTimeout(100); expect( @@ -875,8 +877,8 @@ test("pre-navigation native drift cannot satisfy same-node watch refresh for the const harness = await launchNavigationHarness({ context, page }, { pageKind: "watch", videoId: VIDEO_A }); await expectWatchInitialized(page, VIDEO_A); const reusedControls = page.locator(`[data-fixture-control-video-id="${VIDEO_A}"]`); - const reusedLikeButton = reusedControls.locator('[data-ryd-role="like"] button'); - const reusedDislikeButton = reusedControls.locator('[data-ryd-role="dislike"] button'); + const reusedLikeButton = reusedControls.locator('[data-fixture-role="like"] button'); + const reusedDislikeButton = reusedControls.locator('[data-fixture-role="dislike"] button'); await reusedLikeButton.evaluate((button) => button.setAttribute("data-fixture-pre-navigation-node", "like")); await reusedDislikeButton.evaluate((button) => button.setAttribute("data-fixture-pre-navigation-node", "dislike")); @@ -896,9 +898,10 @@ test("pre-navigation native drift cannot satisfy same-node watch refresh for the await expect(page).toHaveURL((url) => url.pathname === "/watch" && url.searchParams.get("v") === VIDEO_B); await page.waitForTimeout(600); - await expect(reusedControls.locator('[data-ryd-role="dislike"] #text')).toHaveText("11"); + await expect(reusedControls.locator('[data-fixture-role="dislike"] #text')).toHaveText("11"); await expect(page.locator("#ryd-dislike-tooltip")).toContainText("100 / 11"); await reusedDislikeButton.click(); + await expect(reusedDislikeButton).toHaveAttribute("aria-pressed", "true"); await page.waitForTimeout(100); expect( harness.backend.requestsFor("GET", "/votes").filter((request) => request.query.videoId === VIDEO_B), @@ -915,6 +918,7 @@ test("pre-navigation native drift cannot satisfy same-node watch refresh for the await page.evaluate((videoId) => window.__navigationFixture.refreshReusedWatchControl("dislike", videoId), VIDEO_B); await expect(reusedDislikeButton).toHaveAttribute("aria-label", `dislike refreshed for ${VIDEO_B}`); + await expect(reusedDislikeButton).toHaveAttribute("aria-pressed", "false"); await expect(reusedDislikeButton).toHaveAttribute("data-fixture-pre-navigation-node", "dislike"); await expectWatchInitialized(page, VIDEO_B); await expectOneActivation(page, harness.backend, VIDEO_B); @@ -933,8 +937,8 @@ test("navigation-start snapshots same-node watch refreshes that occur before nav const harness = await launchNavigationHarness({ context, page }, { pageKind: "watch", videoId: VIDEO_A }); await expectWatchInitialized(page, VIDEO_A); const reusedControls = page.locator(`[data-fixture-control-video-id="${VIDEO_A}"]`); - const reusedLikeButton = reusedControls.locator('[data-ryd-role="like"] button'); - const reusedDislikeButton = reusedControls.locator('[data-ryd-role="dislike"] button'); + const reusedLikeButton = reusedControls.locator('[data-fixture-role="like"] button'); + const reusedDislikeButton = reusedControls.locator('[data-fixture-role="dislike"] button'); await reusedLikeButton.evaluate((button) => button.setAttribute("data-fixture-pre-finish-node", "like")); await reusedDislikeButton.evaluate((button) => button.setAttribute("data-fixture-pre-finish-node", "dislike")); @@ -982,8 +986,8 @@ test("navigation-start combines a replaced Like target with a reused Dislike ref const harness = await launchNavigationHarness({ context, page }, { pageKind: "watch", videoId: VIDEO_A }); await expectWatchInitialized(page, VIDEO_A); const controls = page.locator(`[data-fixture-control-video-id="${VIDEO_A}"]`); - const originalLikeButton = controls.locator('[data-ryd-role="like"] button'); - const reusedDislikeButton = controls.locator('[data-ryd-role="dislike"] button'); + const originalLikeButton = controls.locator('[data-fixture-role="like"] button'); + const reusedDislikeButton = controls.locator('[data-fixture-role="dislike"] button'); await originalLikeButton.evaluate((button) => { window.__fixtureOriginalMixedLikeTarget = button; }); @@ -1037,7 +1041,7 @@ test("mobile channel to Short ignores stale channel controls until the target mo await page.locator("#channel-short").click(); await expect(page).toHaveURL((url) => url.pathname === `/shorts/${VIDEO_A}`); - const decoyDislikeCount = page.locator('[data-fixture-decoy-controls] [data-ryd-role="dislike"] #text'); + const decoyDislikeCount = page.locator('[data-fixture-decoy-controls] [data-fixture-role="dislike"] #text'); await page.waitForTimeout(250); await expect(decoyDislikeCount).toHaveText(""); expect(harness.backend.requestsFor("GET", "/votes")).toHaveLength(0); diff --git a/Extensions/UserScript/e2e/userscript-navigation-matrix.e2e.js b/Extensions/UserScript/e2e/userscript-navigation-matrix.e2e.js index 5a79fa7..1d9c794 100644 --- a/Extensions/UserScript/e2e/userscript-navigation-matrix.e2e.js +++ b/Extensions/UserScript/e2e/userscript-navigation-matrix.e2e.js @@ -6,62 +6,72 @@ const { installGmEnvironment, installHermeticRoutes, openNavigationFixture, + readGmValue, } = require("./harness"); +const { USERSCRIPT_MATRIX_RUNTIME, installNavigationMatrixFixture } = require("./navigation-matrix"); const { - NAVIGATION_MATRIX, - USERSCRIPT_MATRIX_RUNTIME, - installNavigationMatrixFixture, - runNavigationMatrixScenario, -} = require("./navigation-matrix"); + createNavigationRuntimeContractAdapter, + registerNavigationRuntimeContractScenarios, + runNavigationRuntimeContract, +} = require("../../e2e/navigation-runtime-contract"); const EXISTING_CREDENTIALS = { registrationConfirmed: true, - userId: "ExistingUserscriptCredential000000000001", + userId: "ExistingUserscriptCredential00000001", }; - -for (const scenario of NAVIGATION_MATRIX) { - test(`userscript navigation matrix: ${scenario.id}`, async ({ context, page }) => { - await page.setViewportSize(scenario.viewport); - const consoleErrors = []; - const pageErrors = []; - page.on("console", (message) => { - if (message.type() === "error") consoleErrors.push(message.text()); - }); - page.on("pageerror", (error) => pageErrors.push(error.message)); - - await context.addInitScript(() => { - globalThis.__unhandledRejections = []; - addEventListener("unhandledrejection", (event) => { - const reason = event.reason; - globalThis.__unhandledRejections.push(reason instanceof Error ? reason.message : String(reason)); +registerNavigationRuntimeContractScenarios({ + runtimeName: "userscript", + register: ({ scenario, title }) => { + test(title, async ({ context, page }) => { + await page.setViewportSize(scenario.viewport); + const consoleErrors = []; + const pageErrors = []; + page.on("console", (message) => { + if (message.type() === "error") consoleErrors.push(message.text()); }); - }); + page.on("pageerror", (error) => pageErrors.push(error.message)); - const backend = createFakeBackend({ - countsByVideo: { - [scenario.destination.videoId]: scenario.destination.counts, - [scenario.origin.videoId]: scenario.origin.counts, - }, - }); - await installGmEnvironment(context, { [CREDENTIAL_KEY]: EXISTING_CREDENTIALS }); - await installNavigationMatrixFixture(context, scenario); - await installHermeticRoutes(context, backend); - await openNavigationFixture(page, { - pageKind: scenario.origin.kind, - videoId: scenario.origin.videoId, - }); - await injectGeneratedUserscript(page); + await context.addInitScript(() => { + globalThis.__unhandledRejections = []; + addEventListener("unhandledrejection", (event) => { + const reason = event.reason; + globalThis.__unhandledRejections.push(reason instanceof Error ? reason.message : String(reason)); + }); + }); - await runNavigationMatrixScenario({ - backend, - page, - runtime: USERSCRIPT_MATRIX_RUNTIME, - scenario, - }); + const backend = createFakeBackend({ + countsByVideo: { + [scenario.destination.videoId]: scenario.destination.counts, + [scenario.origin.videoId]: scenario.origin.counts, + }, + }); + await installGmEnvironment(context, { [CREDENTIAL_KEY]: EXISTING_CREDENTIALS }); + await installNavigationMatrixFixture(context, scenario); + await installHermeticRoutes(context, backend); + await openNavigationFixture(page, { + pageKind: scenario.origin.kind, + videoId: scenario.origin.videoId, + }); + await injectGeneratedUserscript(page); - expect(backend.blockedRequests).toEqual([]); - expect(consoleErrors).toEqual([]); - expect(pageErrors).toEqual([]); - expect(await page.evaluate(() => globalThis.__unhandledRejections)).toEqual([]); - }); -} + const adapter = createNavigationRuntimeContractAdapter({ + backend, + expectedCredentials: EXISTING_CREDENTIALS, + matrixRuntime: USERSCRIPT_MATRIX_RUNTIME, + page, + readCredentials: () => readGmValue(page, CREDENTIAL_KEY), + readInteractionRecords: () => backend.requests, + runtimeName: "userscript", + }); + await runNavigationRuntimeContract({ + adapter, + scenario, + }); + + expect(backend.blockedRequests).toEqual([]); + expect(consoleErrors).toEqual([]); + expect(pageErrors).toEqual([]); + expect(await page.evaluate(() => globalThis.__unhandledRejections)).toEqual([]); + }); + }, +}); diff --git a/Extensions/UserScript/e2e/userscript-render-oracle.e2e.js b/Extensions/UserScript/e2e/userscript-render-oracle.e2e.js new file mode 100644 index 0000000..a461276 --- /dev/null +++ b/Extensions/UserScript/e2e/userscript-render-oracle.e2e.js @@ -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); + } +}); diff --git a/Extensions/UserScript/e2e/userscript-runtime-resilience.e2e.js b/Extensions/UserScript/e2e/userscript-runtime-resilience.e2e.js new file mode 100644 index 0000000..a91491b --- /dev/null +++ b/Extensions/UserScript/e2e/userscript-runtime-resilience.e2e.js @@ -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", + }), + }); + }); + }, +}); diff --git a/Extensions/UserScript/e2e/userscript-shorts-placeholder-pool.e2e.js b/Extensions/UserScript/e2e/userscript-shorts-placeholder-pool.e2e.js new file mode 100644 index 0000000..87e4d4b --- /dev/null +++ b/Extensions/UserScript/e2e/userscript-shorts-placeholder-pool.e2e.js @@ -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([]); +}); diff --git a/Extensions/UserScript/e2e/userscript-state-visual-contract.e2e.js b/Extensions/UserScript/e2e/userscript-state-visual-contract.e2e.js index 4913bee..f2f7746 100644 --- a/Extensions/UserScript/e2e/userscript-state-visual-contract.e2e.js +++ b/Extensions/UserScript/e2e/userscript-state-visual-contract.e2e.js @@ -1,3 +1,5 @@ +const fs = require("node:fs"); +const path = require("node:path"); const { test, expect } = require("@playwright/test"); const { CREDENTIAL_KEY, @@ -8,67 +10,38 @@ const { installHermeticRoutes, openWatchFixture, } = require("./harness"); +const { + USERSCRIPT_WATCH_VISUAL_PROFILE, + attachVisualFailureScreenshot, + expectNoStructuralLayoutShift, + expectWatchVisualState, + readWatchVisualContract, + waitForBarRatio, +} = require("../../e2e/watch-visual-contract"); +const { getWatchNeutralBaselineId, registerWatchVisualContract } = require("../../e2e/watch-visual-scenarios"); +const { annotateVisualEvidence, captureOptionalVisualEvidence } = require("../../e2e/visual-evidence"); +const REPOSITORY_ROOT = path.resolve(__dirname, "../../.."); +const VISUAL_REVIEW_DIRECTORY = path.join(REPOSITORY_ROOT, "test-results", "visual-review", "userscript"); const EXISTING_CREDENTIALS = { userId: "ExistingUserscriptCredential000000000001", registrationConfirmed: true, }; const BASE_COUNTS = { likes: 300, dislikes: 100 }; -const VIEWPORTS = [ - { name: "wide", width: 1280, height: 720 }, - { name: "narrow", width: 768, height: 720 }, - { name: "mobile-sized", width: 390, height: 844 }, -]; -const TRANSITIONS = [ - { - action: "like", - initialState: "neutral", - nextState: "liked", - value: 1, - likesDelta: 1, - dislikesDelta: 0, - }, - { - action: "dislike", - initialState: "neutral", - nextState: "disliked", - value: -1, - likesDelta: 0, - dislikesDelta: 1, - }, - { - action: "like", - initialState: "liked", - nextState: "neutral", - value: 0, - likesDelta: -1, - dislikesDelta: 0, - }, - { - action: "dislike", - initialState: "liked", - nextState: "disliked", - value: -1, - likesDelta: -1, - dislikesDelta: 1, - }, - { - action: "like", - initialState: "disliked", - nextState: "liked", - value: 1, - likesDelta: 1, - dislikesDelta: -1, - }, - { - action: "dislike", - initialState: "disliked", - nextState: "neutral", - value: 0, - likesDelta: 0, - dislikesDelta: -1, - }, -]; + +async function captureVisualReview(page, name) { + const outputPath = path.join(VISUAL_REVIEW_DIRECTORY, `${name}.png`); + return captureOptionalVisualEvidence({ + capture: async (screenshotPath) => { + fs.mkdirSync(VISUAL_REVIEW_DIRECTORY, { recursive: true }); + await page.locator("#top-row").screenshot({ + animations: "disabled", + path: screenshotPath, + }); + }, + outputPath, + }); +} function monitorRuntime(page) { const consoleErrors = []; @@ -92,335 +65,52 @@ async function launchVisualFixture({ context, page }, initialState) { await injectGeneratedUserscript(page); await expect(page.locator('[data-ryd-role="dislike"] #text')).toHaveText(String(BASE_COUNTS.dislikes)); - await expect(page.locator("#return-youtube-dislike-bar-container")).toBeVisible(); + await expect(page.locator(USERSCRIPT_WATCH_VISUAL_PROFILE.container)).toBeVisible(); return { backend, ...runtime }; } -async function readVisualContract(page) { - return page.evaluate(() => { - const required = (selector) => { - const element = document.querySelector(selector); - if (!element) throw new Error(`Missing visual-contract element: ${selector}`); - return element; - }; - const rect = (element) => { - const bounds = element.getBoundingClientRect(); - return { - bottom: bounds.bottom, - height: bounds.height, - left: bounds.left, - right: bounds.right, - top: bounds.top, - width: bounds.width, +registerWatchVisualContract({ + runtime: "userscript", + test, + async runScenario({ fixtures: { context, page }, scenario, testInfo }) { + const { transition, viewport } = scenario; + await page.setViewportSize({ width: viewport.width, height: viewport.height }); + try { + const harness = await launchVisualFixture({ context, page }, transition.initialState); + const before = await readWatchVisualContract(page, USERSCRIPT_WATCH_VISUAL_PROFILE); + expectWatchVisualState(before, transition.initialState, BASE_COUNTS, viewport, USERSCRIPT_WATCH_VISUAL_PROFILE); + const neutralBaselineId = getWatchNeutralBaselineId(scenario); + if (neutralBaselineId) { + annotateVisualEvidence(testInfo, await captureVisualReview(page, neutralBaselineId)); + } + + await page.locator(`[data-ryd-role="${transition.action}"] button`).click(); + await expect.poll(() => harness.backend.requestsFor("POST", "/interact/confirmVote").length).toBe(1); + + const nextCounts = { + likes: BASE_COUNTS.likes + transition.likesDelta, + dislikes: BASE_COUNTS.dislikes + transition.dislikesDelta, }; - }; - const box = (element) => { - const style = getComputedStyle(element); - return { - margin: [style.marginTop, style.marginRight, style.marginBottom, style.marginLeft], - padding: [style.paddingTop, style.paddingRight, style.paddingBottom, style.paddingLeft], - }; - }; - const control = (role) => { - const outer = required(`[data-ryd-role="${role}"]`); - const button = required(`[data-ryd-role="${role}"] button`); - const icon = required(`[data-ryd-role="${role}"] [data-fixture-icon]`); - const text = required(`[data-ryd-role="${role}"] #text`); - const buttonStyle = getComputedStyle(button); - return { - ariaPressed: button.getAttribute("aria-pressed"), - backgroundColor: buttonStyle.backgroundColor, - button: rect(button), - buttonBox: box(button), - classes: [...outer.classList].sort(), - color: buttonStyle.color, - icon: rect(icon), - iconBox: box(icon), - outer: rect(outer), - outerBox: box(outer), - text: rect(text), - textBox: box(text), - }; - }; + await waitForBarRatio(page, nextCounts, USERSCRIPT_WATCH_VISUAL_PROFILE); + const after = await readWatchVisualContract(page, USERSCRIPT_WATCH_VISUAL_PROFILE); + expectWatchVisualState(after, transition.nextState, nextCounts, viewport, USERSCRIPT_WATCH_VISUAL_PROFILE); + expectNoStructuralLayoutShift(before, after); - const surface = required("#top-level-buttons-computed"); - const reactionGroup = required('[data-ryd-role="buttons"]'); - const topRow = required("#top-row"); - const wrapper = required(".ryd-tooltip"); - const barContainer = required("#return-youtube-dislike-bar-container"); - const bar = required("#return-youtube-dislike-bar"); - const tooltip = required("#ryd-dislike-tooltip"); - const reactionGroupStyle = getComputedStyle(reactionGroup); - const surfaceStyle = getComputedStyle(surface); - const topRowStyle = getComputedStyle(topRow); - const barStyle = getComputedStyle(bar); - const barContainerStyle = getComputedStyle(barContainer); - const tooltipStyle = getComputedStyle(tooltip); - const wrapperStyle = getComputedStyle(wrapper); - - return { - bar: rect(bar), - barAppearance: { - backgroundColor: barStyle.backgroundColor, - borderRadius: barStyle.borderRadius, - }, - barContainer: rect(barContainer), - barContainerAppearance: { - backgroundColor: barContainerStyle.backgroundColor, - borderRadius: barContainerStyle.borderRadius, - }, - buttons: rect(surface), - buttonsBox: box(surface), - buttonsGap: reactionGroupStyle.gap, - buttonsPosition: surfaceStyle.position, - dislike: control("dislike"), - like: control("like"), - ownership: { - barOwnedByButtons: surface.contains(barContainer), - tooltipDescribedBy: wrapper.getAttribute("aria-describedby"), - tooltipOwnedByButtons: surface.contains(tooltip), - tooltipRole: tooltip.getAttribute("role"), - wrapperOwnedByButtons: wrapper.parentElement === surface, - }, - reactionGroup: rect(reactionGroup), - reactionGroupBox: box(reactionGroup), - topRow: rect(topRow), - topRowBox: box(topRow), - topRowStyle: { - borderBottomWidth: topRowStyle.borderBottomWidth, - paddingBottom: topRowStyle.paddingBottom, - }, - tooltipText: tooltip.textContent.replace(/\s+/g, " ").trim(), - tooltipAppearance: { - fontSize: tooltipStyle.fontSize, - lineHeight: tooltipStyle.lineHeight, - opacity: tooltipStyle.opacity, - padding: box(tooltip).padding, - position: tooltipStyle.position, - visibility: tooltipStyle.visibility, - }, - unique: { - bars: document.querySelectorAll("#return-youtube-dislike-bar").length, - containers: document.querySelectorAll("#return-youtube-dislike-bar-container").length, - tooltips: document.querySelectorAll("#ryd-dislike-tooltip").length, - wrappers: document.querySelectorAll(".ryd-tooltip").length, - }, - viewport: { height: innerHeight, width: innerWidth }, - wrapper: rect(wrapper), - wrapperAppearance: { - box: box(wrapper), - position: wrapperStyle.position, - }, - }; - }); -} - -function expectClose(actual, expected, precision = 5) { - expect(actual).toBeCloseTo(expected, precision); -} - -function expectComputedColor(actual, expectedChannel) { - const srgb = actual.match(/^color\(srgb\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)/); - const channels = srgb - ? srgb.slice(1).map((channel) => Number(channel) * 255) - : actual - .match(/[\d.]+/g) - ?.slice(0, 3) - .map(Number); - expect(channels).toHaveLength(3); - channels.forEach((channel) => expect(channel).toBeCloseTo(expectedChannel, 0)); -} - -function expectControlGeometry(control) { - expectClose(control.outer.width, 96); - expectClose(control.outer.height, 36); - expectClose(control.button.width, 96); - expectClose(control.button.height, 36); - expect(control.outerBox).toEqual({ - margin: ["0px", "0px", "0px", "0px"], - padding: ["0px", "0px", "0px", "0px"], - }); - expect(control.buttonBox).toEqual({ - margin: ["0px", "0px", "0px", "0px"], - padding: ["0px", "12px", "0px", "12px"], - }); - expect(control.iconBox).toEqual({ - margin: ["0px", "0px", "0px", "0px"], - padding: ["0px", "0px", "0px", "0px"], - }); - expect(control.textBox).toEqual({ - margin: ["0px", "0px", "0px", "0px"], - padding: ["0px", "0px", "0px", "0px"], - }); - expectClose(control.icon.width, 20); - expectClose(control.icon.height, 20); - expectClose(control.icon.top + control.icon.height / 2, control.button.top + control.button.height / 2); - expectClose(control.text.top + control.text.height / 2, control.button.top + control.button.height / 2); - expectClose(control.text.left - control.icon.right, 6); -} - -function expectPressedState(control, pressed) { - expect(control.ariaPressed).toBe(String(pressed)); - expect(control.classes).toEqual([pressed ? "style-default-active" : "style-text"]); - expect(control.backgroundColor).toBe(pressed ? "rgb(241, 241, 241)" : "rgb(39, 39, 39)"); - expect(control.color).toBe(pressed ? "rgb(15, 15, 15)" : "rgb(241, 241, 241)"); -} - -function expectVisualState(snapshot, state, counts, viewport) { - expect(snapshot.viewport).toEqual({ height: viewport.height, width: viewport.width }); - expect(snapshot.unique).toEqual({ bars: 1, containers: 1, tooltips: 1, wrappers: 1 }); - expect(snapshot.ownership).toEqual({ - barOwnedByButtons: true, - tooltipDescribedBy: "ryd-dislike-tooltip", - tooltipOwnedByButtons: true, - tooltipRole: "tooltip", - wrapperOwnedByButtons: true, - }); - expect(snapshot.tooltipText).toBe(`${counts.likes} / ${counts.dislikes}`); - expect(snapshot.buttonsGap).toBe("0px"); - expect(snapshot.buttonsPosition).toBe("relative"); - expect(snapshot.buttonsBox).toEqual({ - margin: ["0px", "0px", "0px", "0px"], - padding: ["0px", "0px", "0px", "0px"], - }); - expect(snapshot.reactionGroupBox).toEqual(snapshot.buttonsBox); - expect(snapshot.topRowBox).toEqual({ - margin: ["0px", "0px", "0px", "0px"], - padding: ["0px", "0px", "10px", "0px"], - }); - expect(snapshot.topRowStyle).toEqual({ borderBottomWidth: "1px", paddingBottom: "10px" }); - expect(snapshot.wrapperAppearance).toEqual({ - box: { - margin: ["0px", "0px", "0px", "0px"], - padding: ["0px", "0px", "0px", "0px"], - }, - position: "absolute", - }); - expect(snapshot.barContainerAppearance.borderRadius).toBe("2px"); - expectComputedColor(snapshot.barContainerAppearance.backgroundColor, 139); - expect(snapshot.barAppearance).toEqual({ - backgroundColor: "rgb(241, 241, 241)", - borderRadius: "2px", - }); - expect(snapshot.tooltipAppearance).toEqual({ - fontSize: "12px", - lineHeight: "16px", - opacity: "0", - padding: ["6px", "8px", "6px", "8px"], - position: "absolute", - visibility: "hidden", - }); - - expectControlGeometry(snapshot.like); - expectControlGeometry(snapshot.dislike); - expectPressedState(snapshot.like, state === "liked"); - expectPressedState(snapshot.dislike, state === "disliked"); - - expectClose(snapshot.like.outer.right, snapshot.dislike.outer.left); - expectClose(snapshot.wrapper.left, snapshot.like.outer.left); - expectClose(snapshot.wrapper.right, snapshot.dislike.outer.right); - expectClose(snapshot.wrapper.width, snapshot.like.outer.width + snapshot.dislike.outer.width); - expectClose(snapshot.wrapper.height, 2); - expectClose(snapshot.wrapper.top - Math.max(snapshot.like.outer.bottom, snapshot.dislike.outer.bottom), 8); - expectClose(snapshot.barContainer.width, snapshot.wrapper.width); - expectClose(snapshot.barContainer.height, 2); - expectClose(snapshot.bar.height, 2); - expectClose(snapshot.bar.width / snapshot.barContainer.width, counts.likes / (counts.likes + counts.dislikes), 3); - expect(snapshot.topRow.left).toBeGreaterThanOrEqual(0); - expect(snapshot.topRow.right).toBeLessThanOrEqual(snapshot.viewport.width); - expect(snapshot.wrapper.left).toBeGreaterThanOrEqual(snapshot.topRow.left); - expect(snapshot.wrapper.right).toBeLessThanOrEqual(snapshot.topRow.right); -} - -function expectNoStructuralLayoutShift(before, after) { - const paths = [ - ["topRow"], - ["buttons"], - ["reactionGroup"], - ["wrapper"], - ["barContainer"], - ["like", "outer"], - ["like", "button"], - ["dislike", "outer"], - ["dislike", "button"], - ]; - const dimensions = ["top", "right", "bottom", "left", "width", "height"]; - for (const path of paths) { - const beforeRect = path.reduce((value, key) => value[key], before); - const afterRect = path.reduce((value, key) => value[key], after); - for (const dimension of dimensions) { - expect( - Math.abs(afterRect[dimension] - beforeRect[dimension]), - `${path.join(".")}.${dimension}`, - ).toBeLessThanOrEqual(0.25); - } - } -} - -async function waitForBarRatio(page, counts) { - const expectedRatio = counts.likes / (counts.likes + counts.dislikes); - await expect - .poll(() => - page.locator("#return-youtube-dislike-bar").evaluate((bar) => { - const container = bar.parentElement; - return bar.getBoundingClientRect().width / container.getBoundingClientRect().width; - }), - ) - .toBeCloseTo(expectedRatio, 3); -} - -async function attachFailureScreenshot(testInfo, page, name) { - let body; - try { - const scope = page.locator("#top-row"); - body = (await scope.count()) > 0 ? await scope.screenshot({ animations: "disabled" }) : await page.screenshot(); - } catch { - body = await page.screenshot().catch(() => null); - } - if (body) { - await testInfo.attach(`${name}-visual-contract-failure`, { body, contentType: "image/png" }); - } -} - -for (const viewport of VIEWPORTS) { - test.describe(`${viewport.name} watch visual contract`, () => { - for (const transition of TRANSITIONS) { - const name = `${transition.initialState} + ${transition.action} -> ${transition.nextState}`; - test(name, async ({ context, page }, testInfo) => { - await page.setViewportSize({ width: viewport.width, height: viewport.height }); - try { - const harness = await launchVisualFixture({ context, page }, transition.initialState); - const before = await readVisualContract(page); - expectVisualState(before, transition.initialState, BASE_COUNTS, viewport); - - await page.locator(`[data-ryd-role="${transition.action}"] button`).click(); - await expect.poll(() => harness.backend.requestsFor("POST", "/interact/confirmVote").length).toBe(1); - - const nextCounts = { - likes: BASE_COUNTS.likes + transition.likesDelta, - dislikes: BASE_COUNTS.dislikes + transition.dislikesDelta, - }; - await waitForBarRatio(page, nextCounts); - const after = await readVisualContract(page); - expectVisualState(after, transition.nextState, nextCounts, viewport); - expectNoStructuralLayoutShift(before, after); - - expect(harness.backend.requestsFor("GET", "/votes")).toHaveLength(1); - expect(harness.backend.requestsFor("POST", "/interact/vote")).toHaveLength(1); - expect(harness.backend.requestsFor("POST", "/interact/vote")[0].body).toMatchObject({ - userId: EXISTING_CREDENTIALS.userId, - value: transition.value, - videoId: VIDEO_A, - }); - expect(harness.backend.requestsFor("POST", "/interact/confirmVote")).toHaveLength(1); - expect(harness.backend.blockedRequests).toEqual([]); - expect(harness.consoleErrors).toEqual([]); - expect(harness.pageErrors).toEqual([]); - } catch (error) { - await attachFailureScreenshot(testInfo, page, `${viewport.name}-${name}`); - throw error; - } + expect(harness.backend.requestsFor("GET", "/votes")).toHaveLength(1); + expect(harness.backend.requestsFor("POST", "/interact/vote")).toHaveLength(1); + expect(harness.backend.requestsFor("POST", "/interact/vote")[0].body).toMatchObject({ + userId: EXISTING_CREDENTIALS.userId, + value: transition.value, + videoId: VIDEO_A, }); + expect(harness.backend.requestsFor("POST", "/interact/confirmVote")).toHaveLength(1); + expect(harness.backend.blockedRequests).toEqual([]); + expect(harness.consoleErrors).toEqual([]); + expect(harness.pageErrors).toEqual([]); + annotateVisualEvidence(testInfo, await captureVisualReview(page, scenario.id)); + } catch (error) { + await attachVisualFailureScreenshot(testInfo, page, scenario.id); + throw error; } - }); -} + }, +}); diff --git a/Extensions/UserScript/live/live-artifact-readiness.spec.js b/Extensions/UserScript/live/live-artifact-readiness.spec.js new file mode 100644 index 0000000..efb2c5b --- /dev/null +++ b/Extensions/UserScript/live/live-artifact-readiness.spec.js @@ -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(); +}); diff --git a/Extensions/UserScript/live/live-authenticated-context.spec.js b/Extensions/UserScript/live/live-authenticated-context.spec.js new file mode 100644 index 0000000..df956d4 --- /dev/null +++ b/Extensions/UserScript/live/live-authenticated-context.spec.js @@ -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 = ` + Unrelated video description link + + View your channel + + `; + 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 = ` + + Private Name private@example.test@Expected + + `; + 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/); + }); +}); diff --git a/Extensions/UserScript/live/live-cdp-endpoint.js b/Extensions/UserScript/live/live-cdp-endpoint.js new file mode 100644 index 0000000..6beba9a --- /dev/null +++ b/Extensions/UserScript/live/live-cdp-endpoint.js @@ -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, +}; diff --git a/Extensions/UserScript/live/live-cdp-endpoint.spec.js b/Extensions/UserScript/live/live-cdp-endpoint.spec.js new file mode 100644 index 0000000..5b87e7f --- /dev/null +++ b/Extensions/UserScript/live/live-cdp-endpoint.spec.js @@ -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/); + }, + ); +}); diff --git a/Extensions/UserScript/live/live-diagnostics.spec.js b/Extensions/UserScript/live/live-diagnostics.spec.js index 62d5739..cbb3861 100644 --- a/Extensions/UserScript/live/live-diagnostics.spec.js +++ b/Extensions/UserScript/live/live-diagnostics.spec.js @@ -4,10 +4,14 @@ const path = require("node:path"); const { + LiveFatalSignalGuard, + LiveReadOnlyGate, LiveRunDiagnostics, UNHANDLED_REJECTION_PREFIX, diagnosticApiUrl, + environmentalConsoleWarningId, readLivePageState, + runIndependentLoggedStages, runLoggedStage, } = require("../e2e/live/live-diagnostics"); @@ -34,6 +38,29 @@ function visibleRect() { 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", () => { test("captures runtime and Shorts ownership state without dumping page HTML", () => { 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); }); + 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", () => { expect( diagnosticApiUrl( diff --git a/Extensions/UserScript/live/live-extension-reloader.spec.js b/Extensions/UserScript/live/live-extension-reloader.spec.js new file mode 100644 index 0000000..deb2a63 --- /dev/null +++ b/Extensions/UserScript/live/live-extension-reloader.spec.js @@ -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(); + }); +}); diff --git a/Extensions/UserScript/live/live-full-run-contract.spec.js b/Extensions/UserScript/live/live-full-run-contract.spec.js new file mode 100644 index 0000000..867712a --- /dev/null +++ b/Extensions/UserScript/live/live-full-run-contract.spec.js @@ -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 && /, + ); +}); diff --git a/Extensions/UserScript/live/live-interactive-readonly.spec.js b/Extensions/UserScript/live/live-interactive-readonly.spec.js new file mode 100644 index 0000000..fb81325 --- /dev/null +++ b/Extensions/UserScript/live/live-interactive-readonly.spec.js @@ -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/); + }); +}); diff --git a/Extensions/UserScript/live/live-interactive-runner.spec.js b/Extensions/UserScript/live/live-interactive-runner.spec.js new file mode 100644 index 0000000..06afba9 --- /dev/null +++ b/Extensions/UserScript/live/live-interactive-runner.spec.js @@ -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 }); + } + }); +}); diff --git a/Extensions/UserScript/live/live-navigation-constants.js b/Extensions/UserScript/live/live-navigation-constants.js new file mode 100644 index 0000000..dd77840 --- /dev/null +++ b/Extensions/UserScript/live/live-navigation-constants.js @@ -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, +}; diff --git a/Extensions/UserScript/live/live-options.js b/Extensions/UserScript/live/live-options.js index 83325ce..193b0fe 100644 --- a/Extensions/UserScript/live/live-options.js +++ b/Extensions/UserScript/live/live-options.js @@ -3,11 +3,21 @@ const fs = require("node:fs"); const path = require("node:path"); const extensionPackage = require("../../../package.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_APPROVALS_DIRECTORY = path.resolve(__dirname, "../../../test-results/live-youtube-vote-approvals"); -const DEFAULT_LIVE_NAV_CHANNEL_URL = "https://www.youtube.com/@SmashTrash"; -const DEFAULT_LIVE_NAV_SHORT = "iKQhN7omLM4"; +const DEFAULT_LIVE_CDP_CONNECT_TIMEOUT_MILLISECONDS = 120_000; +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 MAX_LIVE_SIDEBAR_HOPS = 10; const VIDEO_ID_PATTERN = /^[A-Za-z0-9_-]{11}$/; @@ -32,25 +42,16 @@ function requireVideoId(environment, name) { return value; } -function optionalVideoId(environment, name) { - const value = environment[name]?.trim(); - if (!value) return null; - if (!VIDEO_ID_PATTERN.test(value)) { - throw new Error(`${name} must be an 11-character YouTube video ID when provided.`); - } - return value; -} - -function optionalBoundedInteger(environment, name, defaultValue, maximum) { +function optionalBoundedInteger(environment, name, defaultValue, maximum, minimum = 1) { const rawValue = environment[name]?.trim(); if (!rawValue) return defaultValue; if (!/^\d+$/.test(rawValue)) { - throw new Error(`${name} must be a whole number from 1 to ${maximum}.`); + throw new Error(`${name} must be a whole number from ${minimum} to ${maximum}.`); } const value = Number(rawValue); - if (!Number.isSafeInteger(value) || value < 1 || value > maximum) { - throw new Error(`${name} must be a whole number from 1 to ${maximum}.`); + if (!Number.isSafeInteger(value) || value < minimum || value > maximum) { + throw new Error(`${name} must be a whole number from ${minimum} to ${maximum}.`); } return value; } @@ -149,7 +150,14 @@ function parseChannelUrl(value) { 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]; let marker; try { @@ -163,13 +171,14 @@ function readExpectedBuildId(runtime, { markerPaths = LIVE_BUILD_MARKER_PATHS, r if (!LIVE_BUILD_ID_PATTERN.test(marker?.buildId)) { throw new Error(`The ${runtime} live-build marker at ${markerPath} is malformed.`); } + assertArtifactCurrent(runtime, marker.buildId); return marker.buildId; } function readLiveOptions( environment = process.env, nowMilliseconds = Date.now(), - { readBuildId = readExpectedBuildId } = {}, + { readBuildId = readExpectedBuildId, resolveEndpoint = resolveCdpEndpoint } = {}, ) { if (environment.RYD_LIVE_YOUTUBE !== "1") return null; @@ -187,6 +196,9 @@ function readLiveOptions( const watchA = requireVideoId(environment, "RYD_LIVE_WATCH_A"); const watchB = requireVideoId(environment, "RYD_LIVE_WATCH_B"); 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."); const expectedVersion = @@ -194,7 +206,14 @@ function readLiveOptions( (runtime === "userscript" ? userscriptVersion : extensionPackage.version); 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), expectedChannel: requireChannelHandle(environment), 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", ), - 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), productionApiApproved: true, + reactionShort, runtime, sidebar: { hopCount: optionalBoundedInteger( @@ -227,8 +254,12 @@ module.exports = { DEFAULT_LIVE_NAV_CHANNEL_URL, DEFAULT_LIVE_NAV_SHORT, DEFAULT_LIVE_SIDEBAR_HOPS, + DEFAULT_LIVE_SHORTS_NEXT_HOPS, + DEFAULT_LIVE_CDP_CONNECT_TIMEOUT_MILLISECONDS, LIVE_VOTE_APPROVALS_DIRECTORY, LIVE_VOTE_APPROVAL_WINDOW_SECONDS, + MAX_LIVE_SHORTS_NEXT_HOPS, + MIN_LIVE_SHORTS_NEXT_HOPS, consumeLiveVoteApproval, hasFreshVoteApproval, liveVoteApproval, diff --git a/Extensions/UserScript/live/live-options.spec.js b/Extensions/UserScript/live/live-options.spec.js index 70de12c..1a89c03 100644 --- a/Extensions/UserScript/live/live-options.spec.js +++ b/Extensions/UserScript/live/live-options.spec.js @@ -5,6 +5,8 @@ const { DEFAULT_LIVE_NAV_CHANNEL_URL, DEFAULT_LIVE_NAV_SHORT, DEFAULT_LIVE_SIDEBAR_HOPS, + DEFAULT_LIVE_SHORTS_NEXT_HOPS, + DEFAULT_LIVE_CDP_CONNECT_TIMEOUT_MILLISECONDS, LIVE_VOTE_APPROVALS_DIRECTORY, LIVE_VOTE_APPROVAL_WINDOW_SECONDS, consumeLiveVoteApproval, @@ -24,6 +26,7 @@ const VALID_ENVIRONMENT = { RYD_LIVE_WATCH_A: "abcdefghijk", RYD_LIVE_WATCH_B: "zyxwvutsrqp", 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_EXPECTED_CHANNEL: "@ryd-test", }; @@ -31,20 +34,24 @@ const VALID_ENVIRONMENT = { function readLiveOptions(environment, nowMilliseconds = NOW) { return readLiveOptionsFromEnvironment(environment, nowMilliseconds, { readBuildId: () => EXPECTED_BUILD_ID, + resolveEndpoint: (value) => value, }); } describe("live YouTube options", () => { test("reads the exact generated build ID from the selected runtime marker", () => { const readFileSync = jest.fn(() => JSON.stringify({ buildId: EXPECTED_BUILD_ID })); + const assertArtifactCurrent = jest.fn(); expect( readExpectedBuildId("userscript", { + assertArtifactCurrent, markerPaths: { userscript: "owned-live-build.json" }, readFileSync, }), ).toBe(EXPECTED_BUILD_ID); expect(readFileSync).toHaveBeenCalledWith("owned-live-build.json", "utf8"); + expect(assertArtifactCurrent).toHaveBeenCalledWith("userscript", EXPECTED_BUILD_ID); }); test.each([ @@ -60,12 +67,29 @@ describe("live YouTube options", () => { ])("rejects a %s generated live-build marker", (_label, readFileSync, expectedMessage) => { expect(() => readExpectedBuildId("extension", { + assertArtifactCurrent: jest.fn(), markerPaths: { extension: "owned-live-build.json" }, readFileSync, }), ).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", () => { const playwrightOutputDirectory = path.resolve(__dirname, "../../../test-results/live-youtube"); expect(LIVE_VOTE_APPROVALS_DIRECTORY).not.toBe(playwrightOutputDirectory); @@ -76,6 +100,11 @@ describe("live YouTube options", () => { 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", () => { expect(() => readLiveOptions({ ...VALID_ENVIRONMENT, RYD_LIVE_PRODUCTION_API: undefined })).toThrow( "RYD_LIVE_PRODUCTION_API=1", @@ -116,12 +145,15 @@ describe("live YouTube options", () => { test.each(["userscript", "extension"])("accepts the %s runtime", (runtime) => { expect(readLiveOptions({ ...VALID_ENVIRONMENT, RYD_LIVE_RUNTIME: runtime })).toMatchObject({ cdpEndpoint: "chrome", + cdpConnectTimeoutMilliseconds: DEFAULT_LIVE_CDP_CONNECT_TIMEOUT_MILLISECONDS, expectedBuildId: EXPECTED_BUILD_ID, navigation: { channelUrl: DEFAULT_LIVE_NAV_CHANNEL_URL, short: DEFAULT_LIVE_NAV_SHORT, - watch: null, + shortsNextHops: DEFAULT_LIVE_SHORTS_NEXT_HOPS, + watch: "navwatch001", }, + reactionShort: "shortsabcde", runtime, sidebar: { hopCount: DEFAULT_LIVE_SIDEBAR_HOPS }, short: "shortsabcde", @@ -135,19 +167,68 @@ describe("live YouTube options", () => { const result = readLiveOptionsFromEnvironment( { ...VALID_ENVIRONMENT, RYD_LIVE_EXPECTED_BUILD_ID: "f".repeat(32) }, NOW, - { readBuildId }, + { readBuildId, resolveEndpoint: (value) => value }, ); expect(readBuildId).toHaveBeenCalledWith("userscript"); 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", () => { expect(readLiveOptions({ ...VALID_ENVIRONMENT, RYD_LIVE_SIDEBAR_HOPS: "5" })).toMatchObject({ 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) => { expect(() => readLiveOptions({ ...VALID_ENVIRONMENT, RYD_LIVE_SIDEBAR_HOPS: hopCount })).toThrow( /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([ "http://www.youtube.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"); }); diff --git a/Extensions/UserScript/live/live-readonly-guard.spec.js b/Extensions/UserScript/live/live-readonly-guard.spec.js index 96d0b3b..5def637 100644 --- a/Extensions/UserScript/live/live-readonly-guard.spec.js +++ b/Extensions/UserScript/live/live-readonly-guard.spec.js @@ -6,6 +6,7 @@ class FakeBrowserContext { this.routes = []; this.routeCalls = 0; this.unrouteCalls = 0; + this.unrouteImplementation = null; } on(event, handler) { @@ -25,6 +26,7 @@ class FakeBrowserContext { async unroute(matcher, handler) { this.unrouteCalls += 1; this.routes = this.routes.filter((route) => route.matcher !== matcher || route.handler !== handler); + if (this.unrouteImplementation) await this.unrouteImplementation(); } async dispatch(request) { @@ -45,12 +47,12 @@ class FakeBrowserContext { } } -function createDriver(context) { +function createDriver(context, options = {}) { const page = { setDefaultNavigationTimeout: jest.fn(), setDefaultTimeout: jest.fn(), }; - return new LiveYoutubeDriver(page, context); + return new LiveYoutubeDriver(page, context, options); } function request(method, url) { @@ -116,4 +118,59 @@ describe("live read-only production-interaction guard", () => { expect(context.unrouteCalls).toBe(1); 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); + }); }); diff --git a/Extensions/UserScript/live/live-runtime-traffic-ledger.spec.js b/Extensions/UserScript/live/live-runtime-traffic-ledger.spec.js new file mode 100644 index 0000000..4deef4b --- /dev/null +++ b/Extensions/UserScript/live/live-runtime-traffic-ledger.spec.js @@ -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(); + }); +}); diff --git a/Extensions/UserScript/live/live-scenarios.spec.js b/Extensions/UserScript/live/live-scenarios.spec.js index 2f01432..5234c9b 100644 --- a/Extensions/UserScript/live/live-scenarios.spec.js +++ b/Extensions/UserScript/live/live-scenarios.spec.js @@ -1,11 +1,26 @@ const path = require("node:path"); +const { + DISLIKED_STATE, + LIKED_STATE, + NEUTRAL_STATE, + applyVoteTransitionCounts, + resolveVoteTransition, +} = require("../../common/vote-transition-core"); const { RESPONSIVE_VIEWPORTS, + WATCH_ACTION_TOPOLOGY_VIEWPORTS, runChannelShortsNavigationScenario, + runChannelWatchNavigationScenario, + runPostNavigationVoteScenario, runProductionReactionMatrixScenario, runReactionCycle, + runReloadScenario, runResponsiveVisualScenario, runSidebarStressScenario, + runShortsRenderScenario, + runSpaNavigationScenario, + runWatchActionTopologyScenario, + runWatchRenderScenario, } = require("../e2e/live/live-scenarios"); const EXPECTED_ACTIONS = { @@ -14,6 +29,26 @@ const EXPECTED_ACTIONS = { disliked: ["like", "dislike", "dislike", "like", "like", "dislike"], }; +const SHARED_TEST_STATES = { + disliked: DISLIKED_STATE, + liked: LIKED_STATE, + neutral: NEUTRAL_STATE, +}; + +function expectedReactionCounts(initialState, initialCounts) { + let counts = { ...initialCounts }; + let state = SHARED_TEST_STATES[initialState]; + return [ + { ...counts }, + ...EXPECTED_ACTIONS[initialState].map((action) => { + const transition = resolveVoteTransition(state, action); + counts = applyVoteTransitionCounts(counts.likes, counts.dislikes, transition); + state = transition.nextState; + return { ...counts }; + }), + ]; +} + function nextState(state, action) { if (action === "like") return state === "liked" ? "neutral" : "liked"; return state === "disliked" ? "neutral" : "disliked"; @@ -38,14 +73,27 @@ function createReactionHarness( stateWaitFailTransition = null, } = {}, ) { + const initialCounts = { dislikes: 123, likes: 456 }; const events = []; let currentState = initialState; let currentVideo = null; let handshakeNumber = 0; let stateWaitNumber = 0; const failedHandshakeNumbers = new Set(failHandshakes); + const countsForState = () => ({ + dislikes: initialCounts.dislikes + Number(currentState === "disliked") - Number(initialState === "disliked"), + likes: initialCounts.likes + Number(currentState === "liked") - Number(initialState === "liked"), + }); const driver = { + assertDislikeCountChangesObservable: jest.fn(async (changes) => { + expect(changes.every(({ after, before }) => after !== before)).toBe(true); + return changes; + }), + assertRenderedDislikeCount: jest.fn(async (renderedCount, dislikes) => { + expect(renderedCount).toBe(String(dislikes)); + return { normalizedCount: renderedCount }; + }), assertCurrentVideo: jest.fn((videoId) => expect(currentVideo).toBe(videoId)), assertRuntime: jest.fn(), assertSignedIn: jest.fn(), @@ -60,12 +108,14 @@ function createReactionHarness( }), openShort: jest.fn(async (videoId) => { currentVideo = videoId; + return { body: { ...initialCounts, id: videoId }, videoId }; }), openWatch: jest.fn(async (videoId) => { currentVideo = videoId; + return { body: { ...initialCounts, id: videoId }, videoId }; }), readReactionState: jest.fn(async () => currentState), - waitForDislikeText: jest.fn(async () => "123"), + waitForDislikeText: jest.fn(async () => String(countsForState().dislikes)), waitForReactionState: jest.fn(async (expected) => { stateWaitNumber += 1; if (stateWaitNumber === stateWaitFailTransition) { @@ -99,51 +149,836 @@ function createReactionHarness( } const OPTIONS = { + capabilities: { shortsVisualModel: "strict-synthetic" }, expectedBuildId: "0123456789abcdef0123456789abcdef", expectedChannel: "@ryd-test", expectedVersion: "3.2.0", runtime: "userscript", }; -describe("live Shorts navigation stabilization", () => { - test("pauses the next Short only after its current control has rendered", async () => { - const events = []; +function currentShortsControl(videoId, count = "123") { + return { + activeActionBarSyntheticControls: 1, + count, + currentReelSyntheticControls: 1, + synthetic: true, + syntheticVideoId: videoId, + videoId, + visibleDocumentSyntheticControls: 1, + visibleStaleSyntheticControls: 0, + visibleSyntheticControls: 1, + }; +} + +function presentNativeShortsControls(videoId, visibleNativeControls = 5) { + return { + currentVideoId: videoId, + labels: ["Like", "Comments", "Share", "Remix", "Sound"].slice(0, visibleNativeControls), + matchingRenderedReels: 1, + matchingVisibleNativeControls: visibleNativeControls, + observedForMs: 50, + renderedReels: 1, + status: "present", + visibleNativeControls, + }; +} + +function blankNativeShortsControls(videoId, observedForMs = 20_000) { + return { + currentVideoId: videoId, + labels: [], + matchingRenderedReels: 1, + matchingVisibleNativeControls: 0, + observedForMs, + reason: "no-visible-native-shorts-actions", + renderedReels: 1, + status: "blank", + visibleNativeControls: 0, + }; +} + +function successfulShortsNavigation(videoId, dislikes = 123) { + return { + body: { dislikes, id: videoId, likes: 456 }, + nativeControls: presentNativeShortsControls(videoId), + request: { + method: "GET", + requestId: 1, + source: "page", + url: `https://returnyoutubedislikeapi.com/votes?videoId=${videoId}`, + videoId, + workerUrl: null, + }, + status: 200, + videoId, + }; +} + +function blankShortsNavigation(videoId, observedForMs = 20_000) { + return { + body: null, + nativeControls: blankNativeShortsControls(videoId, observedForMs), + request: null, + status: null, + videoId, + }; +} + +describe("strict shared Watch navigation results", () => { + test.each(["userscript", "extension"])( + "binds every basic %s Watch route to its exact API count and current-result oracle", + async (runtime) => { + const response = (videoId, dislikes) => ({ body: { dislikes, id: videoId, likes: 1_000 }, videoId }); + const driver = { + assertCurrentWatchResult: jest.fn(async (videoId, requestedRuntime, counts) => ({ + count: String(counts.dislikes), + runtime: requestedRuntime, + videoId, + })), + assertRuntime: jest.fn(), + assertSignedIn: jest.fn(), + navigateFromColdChannelToWatch: jest.fn(async () => response("watchvideob", 200)), + navigateWithinPlaylist: jest.fn(async () => response("watchvideob", 200)), + openPlaylist: jest.fn(async () => response("watchvideoa", 100)), + reload: jest.fn(async () => response("watchvideoa", 101)), + withNoProductionInteractions: jest.fn(async (action) => action()), + }; + const options = { + ...OPTIONS, + navigation: { channelUrl: "https://www.youtube.com/@SmashTrash", watch: "watchvideob" }, + playlistUrl: "https://www.youtube.com/watch?v=watchvideoa&list=playlist", + runtime, + watchA: "watchvideoa", + watchB: "absentvid01", + }; + + await expect(runWatchRenderScenario(driver, options)).resolves.toMatchObject({ count: "100" }); + await expect(runReloadScenario(driver, options)).resolves.toMatchObject({ + initial: { count: "100" }, + reloaded: { count: "101" }, + }); + await expect(runSpaNavigationScenario(driver, options)).resolves.toMatchObject({ + destinationVideoId: "watchvideob", + watchACount: "100", + watchBCount: "200", + }); + await expect(runChannelWatchNavigationScenario(driver, options)).resolves.toMatchObject({ count: "200" }); + + expect(driver.assertCurrentWatchResult.mock.calls).toEqual([ + ["watchvideoa", runtime, { dislikes: 100, id: "watchvideoa", likes: 1_000 }], + ["watchvideoa", runtime, { dislikes: 100, id: "watchvideoa", likes: 1_000 }], + ["watchvideoa", runtime, { dislikes: 101, id: "watchvideoa", likes: 1_000 }], + ["watchvideoa", runtime, { dislikes: 100, id: "watchvideoa", likes: 1_000 }], + ["watchvideob", runtime, { dislikes: 200, id: "watchvideob", likes: 1_000 }], + ["watchvideob", runtime, { dislikes: 200, id: "watchvideob", likes: 1_000 }], + ]); + expect(driver.navigateWithinPlaylist).toHaveBeenCalledWith({ excludedVideoIds: [options.watchA] }); + }, + ); + + test("accepts equal formatted counts only after binding the dynamic playlist destination by video ID", async () => { + const destinationVideoId = "dynamicvid1"; + const navigation = (videoId) => ({ body: { dislikes: 100, id: videoId, likes: 1_000 }, videoId }); const driver = { - assertCurrentShortsControl: jest.fn(async (videoId) => { - events.push(`control:${videoId}`); - return { count: videoId === "abcdefghijk" ? "100" : "200", synthetic: true, videoId }; - }), + assertCurrentWatchResult: jest.fn(async (videoId) => ({ count: "100", videoId })), assertRuntime: jest.fn(), assertSignedIn: jest.fn(), - navigateFromColdChannelToShort: jest.fn(async () => events.push("channel-navigation")), - navigateToNextShort: jest.fn(async () => { - events.push("next-navigation"); - return "lmnopqrstuv"; - }), - pausePlayback: jest.fn(async () => events.push("pause")), + navigateWithinPlaylist: jest.fn(async () => navigation(destinationVideoId)), + openPlaylist: jest.fn(async () => navigation("watchvideoa")), withNoProductionInteractions: jest.fn(async (action) => action()), }; const options = { ...OPTIONS, - navigation: { - channelUrl: "https://www.youtube.com/@SmashTrash", - short: "abcdefghijk", - }, + playlistUrl: "https://www.youtube.com/watch?v=watchvideoa&list=playlist", + runtime: "extension", + watchA: "watchvideoa", + watchB: "absentvid01", }; - await expect(runChannelShortsNavigationScenario(driver, options)).resolves.toEqual({ - initial: { count: "100", synthetic: true, videoId: "abcdefghijk" }, - next: { count: "200", synthetic: true, videoId: "lmnopqrstuv" }, + await expect(runSpaNavigationScenario(driver, options)).resolves.toMatchObject({ + destinationVideoId, + watchACount: "100", + watchB: { videoId: destinationVideoId }, + watchBCount: "100", }); + expect(driver.assertCurrentWatchResult).toHaveBeenLastCalledWith( + destinationVideoId, + options.runtime, + expect.objectContaining({ id: destinationVideoId }), + ); + }); +}); - expect(events).toEqual([ - "channel-navigation", - "control:abcdefghijk", - "next-navigation", - "control:lmnopqrstuv", - "pause", +function createPostNavigationVoteHarness(initialState, { failHandshake = null, navigationInteractions = 0 } = {}) { + const events = []; + let currentVideo = null; + let currentState = initialState; + let handshakeNumber = 0; + let interactionCount = 0; + + const driver = { + assertCurrentVideo: jest.fn((videoId) => expect(currentVideo).toBe(videoId)), + assertRuntime: jest.fn(), + assertSignedIn: jest.fn(), + clickAction: jest.fn(async (videoId, action) => { + expect(currentVideo).toBe(videoId); + currentState = nextState(currentState, action); + interactionCount += 2; + events.push(`click:${action}:${currentState}`); + }), + navigateWithinPlaylist: jest.fn(async (videoId) => { + currentVideo = videoId; + interactionCount += navigationInteractions; + events.push(`navigate:${videoId}`); + }), + openPlaylist: jest.fn(async (_playlistUrl, videoId) => { + currentVideo = videoId; + events.push(`open:${videoId}`); + }), + openWatch: jest.fn(async (videoId) => { + currentVideo = videoId; + events.push(`cleanup-open:${videoId}`); + }), + readReactionState: jest.fn(async () => currentState), + waitForDislikeText: jest.fn(async () => (currentVideo === "watchvideoa" ? "100" : "200")), + waitForReactionState: jest.fn(async (expected) => expect(currentState).toBe(expected)), + }; + const recorder = { + mark: jest.fn(() => interactionCount), + stop: jest.fn(), + voteUserId: jest.fn(() => "shared-user-id"), + waitForHandshake: jest.fn(async (value, mark) => { + handshakeNumber += 1; + expect(mark).toBe(interactionCount - 2); + expect(value).toBe(valueForState(currentState)); + events.push(`handshake:${value}`); + if (handshakeNumber === failHandshake) throw new Error("simulated handshake failure"); + return "shared-user-id"; + }), + }; + + return { + driver, + events, + getState: () => currentState, + recorder, + }; +} + +describe("live Shorts navigation stabilization", () => { + test.each(["userscript", "extension"])( + "requires ten successful, distinct, response-backed visual transitions for %s", + async (runtime) => { + const events = []; + const nextVideoIds = Array.from({ length: 10 }, (_, index) => `shortnext${String(index + 1).padStart(2, "0")}`); + let nextIndex = 0; + const driver = { + assertCurrentShortsControl: jest.fn(async (videoId) => { + events.push(`control:${videoId}`); + return currentShortsControl(videoId, String(100 + nextIndex)); + }), + assertRuntime: jest.fn(async () => events.push("runtime")), + assertSignedIn: jest.fn(async () => events.push("signed-in")), + captureSyntheticShortsVisual: jest.fn(async (videoId, screenshotPath) => { + events.push(`visual:${videoId}`); + return { screenshotPath, videoId }; + }), + navigateFromColdChannelToShort: jest.fn(async () => { + events.push("channel-navigation"); + return successfulShortsNavigation("abcdefghijk", 200); + }), + navigateToNextShort: jest.fn(async (previousVideoId) => { + const videoId = nextVideoIds[nextIndex]; + expect(previousVideoId).toBe(nextIndex === 0 ? "abcdefghijk" : nextVideoIds[nextIndex - 1]); + nextIndex += 1; + events.push(`next:${videoId}`); + return successfulShortsNavigation(videoId, 200 + nextIndex); + }), + pausePlayback: jest.fn(async () => events.push("pause")), + reportBlankShortsSample: jest.fn(), + soakCurrentShortsControl: jest.fn(async (videoId, requestedRuntime, dislikes) => { + events.push(`soak:${videoId}:${requestedRuntime}:${dislikes}`); + return { sampleCount: 6, videoId }; + }), + withNoProductionInteractions: jest.fn(async (action) => action()), + }; + const options = { + ...OPTIONS, + runtime, + navigation: { + channelUrl: "https://www.youtube.com/@SmashTrash", + short: "abcdefghijk", + shortsNextHops: 10, + }, + }; + + const result = await runChannelShortsNavigationScenario(driver, options, { + makeDirectory: jest.fn(), + outputDirectory: "shorts-evidence", + }); + + expect(result.visitedVideoIds).toEqual(["abcdefghijk", ...nextVideoIds]); + expect(result.hops).toHaveLength(10); + expect(result.attemptedNextHops).toBe(10); + expect(result.maximumNextAttempts).toBe(20); + expect(result.skipped).toEqual([]); + expect(result.successfulNextSamples).toBe(10); + expect(result.validSampleCount).toBe(11); + expect(result.hops.map(({ status, videoId }) => ({ status, videoId }))).toEqual( + nextVideoIds.map((videoId) => ({ status: 200, videoId })), + ); + expect(driver.navigateToNextShort).toHaveBeenCalledTimes(10); + expect(driver.assertCurrentShortsControl).toHaveBeenCalledTimes(11); + expect(driver.captureSyntheticShortsVisual).toHaveBeenCalledTimes(11); + expect(driver.soakCurrentShortsControl).toHaveBeenCalledTimes(11); + expect(driver.reportBlankShortsSample).not.toHaveBeenCalled(); + expect(driver.assertCurrentShortsControl.mock.calls.slice(1)).toEqual( + nextVideoIds.map((videoId, index) => [videoId, runtime, { expectedDislikes: 201 + index }]), + ); + expect(driver.soakCurrentShortsControl.mock.calls).toEqual([ + ["abcdefghijk", runtime, 200], + ...nextVideoIds.map((videoId, index) => [videoId, runtime, 201 + index]), + ]); + expect(driver.captureSyntheticShortsVisual.mock.calls.map(([videoId]) => videoId)).toEqual([ + "abcdefghijk", + ...nextVideoIds, + ]); + expect(driver.captureSyntheticShortsVisual.mock.calls.at(-1)[1]).toContain( + `${runtime}-shorts-hop-10-shortnext10.png`, + ); + expect(driver.pausePlayback).toHaveBeenCalledTimes(1); + expect(events.at(-1)).toBe("pause"); + expect(driver.withNoProductionInteractions).toHaveBeenCalledTimes(1); + }, + ); + + test("rejects a Shorts stress configuration below ten before loading YouTube", async () => { + const driver = { withNoProductionInteractions: jest.fn() }; + + await expect( + runChannelShortsNavigationScenario(driver, { + ...OPTIONS, + navigation: { channelUrl: "https://www.youtube.com/@SmashTrash", short: "abcdefghijk", shortsNextHops: 9 }, + }), + ).rejects.toThrow("at least 10 successful Next samples"); + expect(driver.withNoProductionInteractions).not.toHaveBeenCalled(); + }); + + test("fails when a later Shorts Next transition revisits a previously rendered video", async () => { + const nextVideoIds = ["shortnext01", "shortnext02", "shortnext01"]; + let nextIndex = 0; + const driver = { + assertCurrentShortsControl: jest.fn(async (videoId) => currentShortsControl(videoId)), + assertRuntime: jest.fn(), + assertSignedIn: jest.fn(), + captureSyntheticShortsVisual: jest.fn(async () => ({})), + navigateFromColdChannelToShort: jest.fn(async () => successfulShortsNavigation("abcdefghijk")), + navigateToNextShort: jest.fn(async () => { + const videoId = nextVideoIds[nextIndex++]; + return successfulShortsNavigation(videoId); + }), + pausePlayback: jest.fn(), + reportBlankShortsSample: jest.fn(), + soakCurrentShortsControl: jest.fn(), + withNoProductionInteractions: jest.fn(async (action) => action()), + }; + + await expect( + runChannelShortsNavigationScenario( + driver, + { + ...OPTIONS, + navigation: { + channelUrl: "https://www.youtube.com/@SmashTrash", + short: "abcdefghijk", + shortsNextHops: 10, + }, + }, + { makeDirectory: jest.fn(), outputDirectory: "shorts-evidence" }, + ), + ).rejects.toThrow("attempt 3 revisited an earlier video shortnext01"); + expect(driver.assertCurrentShortsControl).toHaveBeenCalledTimes(3); + expect(driver.pausePlayback).not.toHaveBeenCalled(); + }); + + test.each([ + [ + { + body: { dislikes: 123 }, + nativeControls: presentNativeShortsControls("shortnext01"), + request: { method: "GET", videoId: "shortnext01" }, + status: 204, + videoId: "shortnext01", + }, + "did not receive HTTP 200", + ], + [ + { + body: { dislikes: 123 }, + nativeControls: presentNativeShortsControls("shortnext01"), + request: { method: "POST", videoId: "shortnext01" }, + status: 200, + videoId: "shortnext01", + }, + "did not issue a GET /votes request", + ], + [ + { + body: { dislikes: 123 }, + nativeControls: presentNativeShortsControls("shortnext01"), + request: { method: "GET", videoId: "stalevid001" }, + status: 200, + videoId: "shortnext01", + }, + "requested votes for stale video stalevid001 instead of shortnext01", + ], + [ + { + body: {}, + nativeControls: presentNativeShortsControls("shortnext01"), + request: { method: "GET", videoId: "shortnext01" }, + status: 200, + videoId: "shortnext01", + }, + "has no valid production dislike count", + ], + ])("rejects a Shorts hop without its exact successful /votes result", async (navigation, expectedError) => { + const driver = { + assertCurrentShortsControl: jest.fn(async (videoId) => currentShortsControl(videoId)), + assertRuntime: jest.fn(), + assertSignedIn: jest.fn(), + captureSyntheticShortsVisual: jest.fn(async () => ({})), + navigateFromColdChannelToShort: jest.fn(async () => successfulShortsNavigation("abcdefghijk")), + navigateToNextShort: jest.fn(async () => navigation), + pausePlayback: jest.fn(), + reportBlankShortsSample: jest.fn(), + soakCurrentShortsControl: jest.fn(), + withNoProductionInteractions: jest.fn(async (action) => action()), + }; + + await expect( + runChannelShortsNavigationScenario( + driver, + { + ...OPTIONS, + navigation: { + channelUrl: "https://www.youtube.com/@SmashTrash", + short: "abcdefghijk", + shortsNextHops: 10, + }, + }, + { makeDirectory: jest.fn(), outputDirectory: "shorts-evidence" }, + ), + ).rejects.toThrow(expectedError); + expect(driver.assertCurrentShortsControl).toHaveBeenCalledTimes(1); + expect(driver.pausePlayback).not.toHaveBeenCalled(); + }); + + test("skips fully blank native rails, records their IDs and durations, and still audits ten successful Next samples", async () => { + const attemptVideoIds = Array.from({ length: 11 }, (_, index) => `nexttry${String(index + 1).padStart(4, "0")}`); + let nextIndex = 0; + const driver = { + assertCurrentShortsControl: jest.fn(async (videoId) => currentShortsControl(videoId)), + assertRuntime: jest.fn(), + assertSignedIn: jest.fn(), + captureBlankShortsDiagnostics: jest.fn(async (videoId, screenshotPath) => ({ + expectedVideoId: videoId, + nativeControlsAfterEvidence: blankNativeShortsControls(videoId, 0), + reels: [], + screenshotPath, + })), + captureSyntheticShortsVisual: jest.fn(async () => ({})), + navigateFromColdChannelToShort: jest.fn(async () => blankShortsNavigation("abcdefghijk", 20_125)), + navigateToNextShort: jest.fn(async () => { + const videoId = attemptVideoIds[nextIndex]; + nextIndex += 1; + return nextIndex === 1 ? blankShortsNavigation(videoId, 20_250) : successfulShortsNavigation(videoId); + }), + pausePlayback: jest.fn(), + reportBlankShortsSample: jest.fn(), + soakCurrentShortsControl: jest.fn(), + withNoProductionInteractions: jest.fn(async (action) => action()), + }; + const writeFile = jest.fn(); + + const result = await runChannelShortsNavigationScenario( + driver, + { + ...OPTIONS, + navigation: { + channelUrl: "https://www.youtube.com/@SmashTrash", + short: "abcdefghijk", + shortsNextHops: 10, + }, + }, + { makeDirectory: jest.fn(), outputDirectory: "shorts-evidence", writeFile }, + ); + + expect(result.attemptedNextHops).toBe(11); + expect(result.successfulNextSamples).toBe(10); + expect(result.validSampleCount).toBe(10); + expect(result.skipped).toEqual([ + { + attemptNumber: 0, + durationMs: 20_125, + inventoryPath: path.join("shorts-evidence", "userscript-shorts-blank-channel-00-abcdefghijk.json"), + reason: "no-visible-native-shorts-actions", + screenshotPath: path.join("shorts-evidence", "userscript-shorts-blank-channel-00-abcdefghijk.png"), + source: "channel", + videoId: "abcdefghijk", + votesRequestObserved: false, + }, + { + attemptNumber: 1, + durationMs: 20_250, + inventoryPath: path.join("shorts-evidence", "userscript-shorts-blank-next-01-nexttry0001.json"), + reason: "no-visible-native-shorts-actions", + screenshotPath: path.join("shorts-evidence", "userscript-shorts-blank-next-01-nexttry0001.png"), + source: "next", + videoId: "nexttry0001", + votesRequestObserved: false, + }, ]); - expect(driver.pausePlayback).toHaveBeenCalledTimes(1); + expect(driver.reportBlankShortsSample.mock.calls.map(([record]) => record)).toEqual(result.skipped); + expect(driver.captureBlankShortsDiagnostics).toHaveBeenCalledTimes(2); + expect(writeFile).toHaveBeenCalledTimes(2); + for (const [inventoryPath, contents] of writeFile.mock.calls) { + const evidence = JSON.parse(contents); + expect(inventoryPath).toBe(evidence.sample.inventoryPath); + expect(evidence.diagnostics.screenshotPath).toBe(evidence.sample.screenshotPath); + expect(evidence.nativeControls.status).toBe("blank"); + } + expect(driver.assertCurrentShortsControl).toHaveBeenCalledTimes(10); + expect(driver.captureSyntheticShortsVisual).toHaveBeenCalledTimes(10); + expect(driver.soakCurrentShortsControl).toHaveBeenCalledTimes(10); + expect(result.visitedVideoIds).toEqual(["abcdefghijk", ...attemptVideoIds]); + }); + + test("reclassifies a late native rail after blank evidence and applies the normal strict runtime assertions", async () => { + const nextVideoIds = Array.from({ length: 10 }, (_, index) => `nexttry${String(index + 1).padStart(4, "0")}`); + let nextIndex = 0; + const lateNavigation = { + ...successfulShortsNavigation(nextVideoIds[0]), + nativeControls: blankNativeShortsControls(nextVideoIds[0]), + }; + const driver = { + assertCurrentShortsControl: jest.fn(async (videoId) => currentShortsControl(videoId)), + assertRuntime: jest.fn(), + assertSignedIn: jest.fn(), + captureBlankShortsDiagnostics: jest.fn(async (videoId, screenshotPath) => ({ + expectedVideoId: videoId, + nativeControlsAfterEvidence: presentNativeShortsControls(videoId, 4), + reels: [], + screenshotPath, + })), + captureSyntheticShortsVisual: jest.fn(async () => ({})), + navigateFromColdChannelToShort: jest.fn(async () => successfulShortsNavigation("abcdefghijk")), + navigateToNextShort: jest.fn(async () => { + const videoId = nextVideoIds[nextIndex]; + nextIndex += 1; + return nextIndex === 1 ? lateNavigation : successfulShortsNavigation(videoId); + }), + pausePlayback: jest.fn(), + reportBlankShortsSample: jest.fn(), + reportProgress: jest.fn(), + soakCurrentShortsControl: jest.fn(), + withNoProductionInteractions: jest.fn(async (action) => action()), + }; + + const result = await runChannelShortsNavigationScenario( + driver, + { + ...OPTIONS, + navigation: { + channelUrl: "https://www.youtube.com/@SmashTrash", + short: "abcdefghijk", + shortsNextHops: 10, + }, + }, + { makeDirectory: jest.fn(), outputDirectory: "shorts-evidence", writeFile: jest.fn() }, + ); + + expect(result.attemptedNextHops).toBe(10); + expect(result.successfulNextSamples).toBe(10); + expect(result.skipped).toEqual([]); + expect(driver.captureBlankShortsDiagnostics).toHaveBeenCalledTimes(1); + expect(driver.reportBlankShortsSample).not.toHaveBeenCalled(); + expect(driver.reportProgress).toHaveBeenCalledWith("shorts-sample.recovered-after-evidence", { + attemptNumber: 1, + source: "next", + status: "present", + videoId: nextVideoIds[0], + }); + expect(driver.assertCurrentShortsControl).toHaveBeenCalledWith(nextVideoIds[0], "userscript", { + expectedDislikes: 123, + }); + }); + + test("fails instead of skipping when post-evidence remeasurement reveals native actions without RYD", async () => { + const videoId = "nexttry0001"; + const lateNavigation = { + ...successfulShortsNavigation(videoId), + nativeControls: blankNativeShortsControls(videoId), + }; + const driver = { + assertCurrentShortsControl: jest + .fn() + .mockResolvedValueOnce(currentShortsControl("abcdefghijk")) + .mockRejectedValueOnce(new Error("runtime Dislike missing after late native rail")), + assertRuntime: jest.fn(), + assertSignedIn: jest.fn(), + captureBlankShortsDiagnostics: jest.fn(async (_videoId, screenshotPath) => ({ + nativeControlsAfterEvidence: presentNativeShortsControls(videoId, 4), + screenshotPath, + })), + captureSyntheticShortsVisual: jest.fn(async () => ({})), + navigateFromColdChannelToShort: jest.fn(async () => successfulShortsNavigation("abcdefghijk")), + navigateToNextShort: jest.fn(async () => lateNavigation), + pausePlayback: jest.fn(), + reportBlankShortsSample: jest.fn(), + reportProgress: jest.fn(), + soakCurrentShortsControl: jest.fn(), + withNoProductionInteractions: jest.fn(async (action) => action()), + }; + + await expect( + runChannelShortsNavigationScenario( + driver, + { + ...OPTIONS, + navigation: { + channelUrl: "https://www.youtube.com/@SmashTrash", + short: "abcdefghijk", + shortsNextHops: 10, + }, + }, + { makeDirectory: jest.fn(), outputDirectory: "shorts-evidence", writeFile: jest.fn() }, + ), + ).rejects.toThrow("runtime Dislike missing after late native rail"); + expect(driver.reportBlankShortsSample).not.toHaveBeenCalled(); + expect(driver.pausePlayback).not.toHaveBeenCalled(); + }); + + test("treats any native Shorts rail with a missing runtime Dislike as a hard failure instead of a skip", async () => { + const driver = { + assertCurrentShortsControl: jest + .fn() + .mockResolvedValueOnce(currentShortsControl("abcdefghijk")) + .mockRejectedValueOnce(new Error("runtime Dislike missing")), + assertRuntime: jest.fn(), + assertSignedIn: jest.fn(), + captureSyntheticShortsVisual: jest.fn(async () => ({})), + navigateFromColdChannelToShort: jest.fn(async () => successfulShortsNavigation("abcdefghijk")), + navigateToNextShort: jest.fn(async () => successfulShortsNavigation("nexttry0001")), + pausePlayback: jest.fn(), + reportBlankShortsSample: jest.fn(), + soakCurrentShortsControl: jest.fn(), + withNoProductionInteractions: jest.fn(async (action) => action()), + }; + + await expect( + runChannelShortsNavigationScenario( + driver, + { + ...OPTIONS, + navigation: { + channelUrl: "https://www.youtube.com/@SmashTrash", + short: "abcdefghijk", + shortsNextHops: 10, + }, + }, + { makeDirectory: jest.fn(), outputDirectory: "shorts-evidence" }, + ), + ).rejects.toThrow("runtime Dislike missing"); + expect(driver.navigateToNextShort).toHaveBeenCalledTimes(1); + expect(driver.reportBlankShortsSample).not.toHaveBeenCalled(); + expect(driver.pausePlayback).not.toHaveBeenCalled(); + }); + + test("stops within the bounded attempt policy when blank YouTube samples can no longer be a minority", async () => { + let attempt = 0; + const driver = { + assertCurrentShortsControl: jest.fn(), + assertRuntime: jest.fn(), + assertSignedIn: jest.fn(), + captureBlankShortsDiagnostics: jest.fn(async (videoId, screenshotPath) => ({ + expectedVideoId: videoId, + nativeControlsAfterEvidence: blankNativeShortsControls(videoId, 0), + reels: [], + screenshotPath, + })), + captureSyntheticShortsVisual: jest.fn(), + navigateFromColdChannelToShort: jest.fn(async () => blankShortsNavigation("abcdefghijk")), + navigateToNextShort: jest.fn(async () => { + attempt += 1; + return blankShortsNavigation(`nexttry${String(attempt).padStart(4, "0")}`); + }), + pausePlayback: jest.fn(), + reportBlankShortsSample: jest.fn(), + soakCurrentShortsControl: jest.fn(), + withNoProductionInteractions: jest.fn(async (action) => action()), + }; + + await expect( + runChannelShortsNavigationScenario( + driver, + { + ...OPTIONS, + navigation: { + channelUrl: "https://www.youtube.com/@SmashTrash", + short: "abcdefghijk", + shortsNextHops: 10, + }, + }, + { makeDirectory: jest.fn(), outputDirectory: "shorts-evidence", writeFile: jest.fn() }, + ), + ).rejects.toThrow( + /required 10 successful Next samples, but found 0 valid and 10 blank samples across 9 Next attempts/, + ); + expect(driver.navigateToNextShort).toHaveBeenCalledTimes(9); + expect(driver.captureBlankShortsDiagnostics).toHaveBeenCalledTimes(10); + expect(driver.reportBlankShortsSample).toHaveBeenCalledTimes(10); + expect(driver.pausePlayback).not.toHaveBeenCalled(); + }); + + test("stops at 18 attempts after seven successes and eleven blanks because ten successes are no longer reachable", async () => { + let attempt = 0; + const driver = { + assertCurrentShortsControl: jest.fn(async (videoId) => currentShortsControl(videoId)), + assertRuntime: jest.fn(), + assertSignedIn: jest.fn(), + captureBlankShortsDiagnostics: jest.fn(async (videoId, screenshotPath) => ({ + nativeControlsAfterEvidence: blankNativeShortsControls(videoId, 0), + screenshotPath, + })), + captureSyntheticShortsVisual: jest.fn(async () => ({})), + navigateFromColdChannelToShort: jest.fn(async () => successfulShortsNavigation("abcdefghijk")), + navigateToNextShort: jest.fn(async () => { + attempt += 1; + const videoId = `nexttry${String(attempt).padStart(4, "0")}`; + return attempt <= 7 ? successfulShortsNavigation(videoId) : blankShortsNavigation(videoId); + }), + pausePlayback: jest.fn(), + reportBlankShortsSample: jest.fn(), + soakCurrentShortsControl: jest.fn(), + withNoProductionInteractions: jest.fn(async (action) => action()), + }; + + await expect( + runChannelShortsNavigationScenario( + driver, + { + ...OPTIONS, + navigation: { + channelUrl: "https://www.youtube.com/@SmashTrash", + short: "abcdefghijk", + shortsNextHops: 10, + }, + }, + { makeDirectory: jest.fn(), outputDirectory: "shorts-evidence", writeFile: jest.fn() }, + ), + ).rejects.toThrow(/found 7 valid and 11 blank samples across 18 Next attempts \(limit 20\)/); + expect(driver.navigateToNextShort).toHaveBeenCalledTimes(18); + expect(driver.reportBlankShortsSample).toHaveBeenCalledTimes(11); + expect(driver.pausePlayback).not.toHaveBeenCalled(); + }); +}); + +describe("live post-navigation production vote", () => { + test.each([ + ["neutral", "disliked", [-1, 0], ["dislike", "dislike"]], + ["liked", "disliked", [-1, 1], ["dislike", "like"]], + ["disliked", "neutral", [0, -1], ["dislike", "dislike"]], + ])( + "targets the SPA destination and restores an initially %s reaction", + async (initialState, selectedState, expectedValues, expectedActions) => { + const harness = createPostNavigationVoteHarness(initialState); + const createRecorder = jest.fn((videoId) => { + expect(videoId).toBe("watchvideob"); + return harness.recorder; + }); + const consumeVoteApproval = jest.fn(async () => { + harness.driver.assertCurrentVideo("watchvideob"); + harness.events.push("approved"); + }); + const options = { + ...OPTIONS, + playlistUrl: "https://www.youtube.com/watch?v=watchvideoa&list=playlist", + watchA: "watchvideoa", + watchB: "watchvideob", + }; + + await expect( + runPostNavigationVoteScenario(harness.driver, options, createRecorder, consumeVoteApproval), + ).resolves.toEqual({ + action: "dislike", + initialState, + selectedState, + userId: "shared-user-id", + videoId: "watchvideob", + watchACount: "100", + watchBCount: "200", + }); + + expect(harness.driver.openPlaylist).toHaveBeenCalledWith(options.playlistUrl, options.watchA); + expect(harness.driver.navigateWithinPlaylist).toHaveBeenCalledWith(options.watchB); + expect(harness.driver.openWatch).not.toHaveBeenCalled(); + expect(consumeVoteApproval).toHaveBeenCalledTimes(1); + expect(harness.events.indexOf("approved")).toBeLessThan( + harness.events.findIndex((event) => event.startsWith("click:")), + ); + expect(harness.events.filter((event) => event.startsWith("click:")).map((event) => event.split(":")[1])).toEqual( + expectedActions, + ); + expect(harness.recorder.waitForHandshake.mock.calls.map(([value]) => value)).toEqual(expectedValues); + expect(harness.getState()).toBe(initialState); + expect(harness.recorder.stop).toHaveBeenCalledTimes(1); + }, + ); + + test("rejects interaction traffic emitted by navigation before requesting reaction approval", async () => { + const harness = createPostNavigationVoteHarness("neutral", { navigationInteractions: 2 }); + const consumeVoteApproval = jest.fn(); + + await expect( + runPostNavigationVoteScenario( + harness.driver, + { + ...OPTIONS, + playlistUrl: "https://www.youtube.com/watch?v=watchvideoa&list=playlist", + watchA: "watchvideoa", + watchB: "watchvideob", + }, + () => harness.recorder, + consumeVoteApproval, + ), + ).rejects.toThrow(/navigation emitted unexpected production interaction traffic/); + + expect(consumeVoteApproval).not.toHaveBeenCalled(); + expect(harness.driver.clickAction).not.toHaveBeenCalled(); + expect(harness.recorder.stop).toHaveBeenCalledTimes(1); + }); + + test("restores the destination state when its first production handshake fails", async () => { + const harness = createPostNavigationVoteHarness("neutral", { failHandshake: 1 }); + + await expect( + runPostNavigationVoteScenario( + harness.driver, + { + ...OPTIONS, + playlistUrl: "https://www.youtube.com/watch?v=watchvideoa&list=playlist", + watchA: "watchvideoa", + watchB: "watchvideob", + }, + () => harness.recorder, + jest.fn(), + ), + ).rejects.toThrow("simulated handshake failure"); + + expect(harness.getState()).toBe("neutral"); + expect(harness.recorder.waitForHandshake.mock.calls.map(([value]) => value)).toEqual([-1, 0]); + expect(harness.recorder.stop).toHaveBeenCalledTimes(1); }); }); @@ -153,23 +988,36 @@ describe("live watch sidebar stress scenario", () => { async (runtime) => { const hopVideoIds = ["hopvideo001", "hopvideo002", "hopvideo003"]; const counts = ["101", "202", "303"]; + const apiCounts = hopVideoIds.map((_videoId, index) => ({ + dislikes: (index + 1) * 1_000, + likes: (index + 1) * 10_000, + })); let currentVideoId = null; let hopIndex = 0; const driver = { assertCurrentVideo: jest.fn((videoId) => expect(currentVideoId).toBe(videoId)), + assertRenderedDislikeCount: jest.fn(async (renderedCount, dislikes, requestedRuntime) => ({ + dislikes, + normalizedCount: renderedCount, + runtime: requestedRuntime, + })), assertRuntime: jest.fn(), assertSignedIn: jest.fn(), - captureWatchRatioVisual: jest.fn(async (_runtime, screenshotPath) => ({ - count: counts[hopIndex - 1], - screenshotPath, - })), + captureWatchRatioVisual: jest.fn(async (_runtime, screenshotPath) => { + await new Promise((resolve) => setTimeout(resolve, 10)); + return { + count: counts[hopIndex - 1], + presenceLatencyMs: 1, + screenshotPath, + }; + }), navigateToRelatedWatch: jest.fn(async (excludedVideoIds) => { expect(excludedVideoIds).toEqual(["abcdefghijk", ...hopVideoIds.slice(0, hopIndex)]); const videoId = hopVideoIds[hopIndex]; - const dislikes = (hopIndex + 1) * 1_000; + const body = apiCounts[hopIndex]; hopIndex += 1; currentVideoId = videoId; - return { body: { dislikes }, videoId }; + return { body, videoId }; }), openWatch: jest.fn(async (videoId) => { currentVideoId = videoId; @@ -188,6 +1036,7 @@ describe("live watch sidebar stress scenario", () => { const result = await runSidebarStressScenario(driver, options, { makeDirectory, outputDirectory: "sidebar-evidence", + readyTimeoutMs: 1, soakDurationMs: 25, }); @@ -195,14 +1044,20 @@ describe("live watch sidebar stress scenario", () => { expect(driver.openWatch).toHaveBeenCalledWith(options.watchA); expect(driver.navigateToRelatedWatch).toHaveBeenCalledTimes(3); expect(driver.captureWatchRatioVisual.mock.calls).toEqual( - hopVideoIds.map((_videoId, index) => [ + hopVideoIds.map((videoId, index) => [ runtime, path.join("sidebar-evidence", `${runtime}-sidebar-hop-0${index + 1}.png`), - { presenceTimeoutMs: 1_000 }, + { expectedCounts: apiCounts[index], expectedVideoId: videoId, presenceTimeoutMs: 1 }, ]), ); expect(driver.soakWatchRatioVisual.mock.calls).toEqual( - hopVideoIds.map((videoId, index) => [runtime, { durationMs: 25, expectedCount: counts[index], videoId }]), + hopVideoIds.map((videoId, index) => [ + runtime, + { durationMs: 25, expectedCount: counts[index], expectedCounts: apiCounts[index], videoId }, + ]), + ); + expect(driver.assertRenderedDislikeCount.mock.calls).toEqual( + counts.map((count, index) => [count, (index + 1) * 1_000, runtime]), ); expect(driver.assertRuntime).toHaveBeenCalledTimes(4); expect(driver.assertSignedIn).toHaveBeenCalledTimes(4); @@ -211,9 +1066,15 @@ describe("live watch sidebar stress scenario", () => { hopCount: 3, hops: hopVideoIds.map((videoId, index) => ({ apiDislikes: (index + 1) * 1_000, + apiLikes: (index + 1) * 10_000, count: counts[index], - readyLatencyMs: expect.any(Number), - readyTimeoutMs: 1_000, + countAudit: { + dislikes: (index + 1) * 1_000, + normalizedCount: counts[index], + runtime, + }, + readyLatencyMs: 1, + readyTimeoutMs: 1, screenshotPath: path.join("sidebar-evidence", `${runtime}-sidebar-hop-0${index + 1}.png`), soak: { count: counts[index], @@ -230,6 +1091,178 @@ describe("live watch sidebar stress scenario", () => { ); }); +describe("live Watch action topology scenario", () => { + test.each(["userscript", "extension"])( + "uses the same cold-width, resize-roundtrip, and sidebar contract for %s", + async (runtime) => { + const originalViewport = { height: 950, width: 1500 }; + let currentViewport = originalViewport; + let currentVideo = null; + const layoutsByWidth = new Map([ + [1536, ["share"]], + [1300, ["share", "save", "thanks"]], + [820, ["share"]], + ]); + const driver = { + assertRenderedDislikeCount: jest.fn(async () => ({})), + assertRuntime: jest.fn(), + assertSignedIn: jest.fn(), + captureWatchActionTopologyVisual: jest.fn(async (_runtime, screenshotPath, expectations) => { + const topLevelOptionalSignatures = + currentVideo === "sidebar001" ? ["share", "save"] : layoutsByWidth.get(currentViewport.width); + expect(topLevelOptionalSignatures.length).toBeGreaterThanOrEqual(expectations.minimumTopLevelOptionalActions); + const inventorySignatures = + currentVideo === "sidebar001" ? ["report", "save", "share"] : ["report", "save", "share", "thanks"]; + if (expectations.expectedInventorySignatures) { + expect(inventorySignatures).toEqual(expectations.expectedInventorySignatures); + } + if (expectations.expectedTopLevelOptionalSignatures) { + expect(topLevelOptionalSignatures).toEqual(expectations.expectedTopLevelOptionalSignatures); + } + return { inventorySignatures, screenshotPath, topLevelOptionalSignatures }; + }), + navigateToRelatedWatch: jest.fn(async () => { + currentVideo = "sidebar001"; + return { body: { dislikes: 12, id: currentVideo, likes: 120 }, videoId: currentVideo }; + }), + openWatch: jest.fn(async (videoId) => { + currentVideo = videoId; + return { body: { dislikes: 123, id: videoId, likes: 456 }, videoId }; + }), + readViewportSize: jest.fn(async () => currentViewport), + setViewportSize: jest.fn(async (viewport) => { + currentViewport = viewport; + }), + waitForDislikeText: jest.fn(async () => "123"), + withNoProductionInteractions: jest.fn(async (action) => action()), + }; + const options = { ...OPTIONS, runtime, watchA: "abcdefghijk" }; + + const result = await runWatchActionTopologyScenario(driver, options, { + makeDirectory: jest.fn(), + outputDirectory: "topology-evidence", + }); + + expect(driver.openWatch).toHaveBeenCalledTimes(WATCH_ACTION_TOPOLOGY_VIEWPORTS.length + 1); + expect(driver.captureWatchActionTopologyVisual).toHaveBeenCalledTimes(WATCH_ACTION_TOPOLOGY_VIEWPORTS.length + 3); + expect(driver.captureWatchActionTopologyVisual.mock.calls[0][2]).toEqual({ + expectedCounts: { dislikes: 123, id: "abcdefghijk", likes: 456 }, + expectedInventorySignatures: null, + minimumTopLevelOptionalActions: 1, + }); + expect(driver.captureWatchActionTopologyVisual.mock.calls.at(-2)[2]).toEqual({ + expectedCounts: { dislikes: 123, id: "abcdefghijk", likes: 456 }, + expectedInventorySignatures: ["report", "save", "share", "thanks"], + expectedTopLevelOptionalSignatures: ["share", "save", "thanks"], + minimumTopLevelOptionalActions: 3, + }); + expect(driver.captureWatchActionTopologyVisual.mock.calls.at(-1)[2]).toEqual({ + expectedCounts: { dislikes: 12, id: "sidebar001", likes: 120 }, + minimumTopLevelOptionalActions: 1, + }); + expect(driver.navigateToRelatedWatch).toHaveBeenCalledWith([options.watchA]); + expect(driver.assertRenderedDislikeCount).toHaveBeenCalledTimes(WATCH_ACTION_TOPOLOGY_VIEWPORTS.length + 2); + expect(driver.setViewportSize).toHaveBeenLastCalledWith(originalViewport); + expect(result.coldLayouts).toHaveLength(WATCH_ACTION_TOPOLOGY_VIEWPORTS.length); + expect(result.resizeRoundTrip).toMatchObject({ + destinationViewport: { width: 1300 }, + sourceViewport: { width: 1536 }, + }); + expect(result.sidebarVideoId).toBe("sidebar001"); + expect(driver.withNoProductionInteractions).toHaveBeenCalledTimes(1); + }, + ); + + test("rejects viewport coverage that cannot exercise an expanding resize transition", async () => { + const originalViewport = { height: 900, width: 1500 }; + const driver = { + assertRenderedDislikeCount: jest.fn(async () => ({})), + assertRuntime: jest.fn(), + assertSignedIn: jest.fn(), + captureWatchActionTopologyVisual: jest.fn(async () => ({ + inventorySignatures: ["report", "save", "share"], + topLevelOptionalSignatures: ["share"], + })), + openWatch: jest.fn(async (videoId) => ({ body: { dislikes: 123, id: videoId, likes: 456 }, videoId })), + readViewportSize: jest.fn(async () => originalViewport), + setViewportSize: jest.fn(), + waitForDislikeText: jest.fn(async () => "123"), + withNoProductionInteractions: jest.fn(async (action) => action()), + }; + + await expect( + runWatchActionTopologyScenario(driver, { ...OPTIONS, watchA: "abcdefghijk" }, { makeDirectory: jest.fn() }), + ).rejects.toThrow(/strict superset of top-level actions/); + expect(driver.setViewportSize).toHaveBeenLastCalledWith(originalViewport); + }); +}); + +describe("specific live Shorts cold-load and reload scenario", () => { + function createShortsDriver(overrides = {}) { + const videoId = "shortsabcde"; + const navigation = { body: { dislikes: 123, id: videoId }, status: 200, videoId }; + return { + assertCurrentShortsControl: jest.fn(async () => currentShortsControl(videoId)), + assertRuntime: jest.fn(), + assertSignedIn: jest.fn(), + captureSyntheticShortsVisual: jest.fn(async (_videoId, screenshotPath) => ({ screenshotPath })), + openShort: jest.fn(async () => navigation), + reload: jest.fn(async () => navigation), + soakCurrentShortsControl: jest.fn(async () => ({ sampleCount: 2, videoId })), + withNoProductionInteractions: jest.fn(async (action) => action()), + ...overrides, + }; + } + + test("binds both a fresh direct load and reload to the exact API count, owner, geometry, and soak", async () => { + const driver = createShortsDriver(); + const options = { ...OPTIONS, short: "shortsabcde" }; + + const result = await runShortsRenderScenario(driver, options, { + makeDirectory: jest.fn(), + outputDirectory: "specific-short-evidence", + }); + + expect(driver.openShort).toHaveBeenCalledWith(options.short); + expect(driver.reload).toHaveBeenCalledWith(options.short); + expect(driver.assertCurrentShortsControl).toHaveBeenCalledTimes(2); + expect(driver.assertCurrentShortsControl).toHaveBeenNthCalledWith(1, options.short, options.runtime, { + expectedDislikes: 123, + }); + expect(driver.assertCurrentShortsControl).toHaveBeenNthCalledWith(2, options.short, options.runtime, { + expectedDislikes: 123, + }); + expect(driver.captureSyntheticShortsVisual).toHaveBeenCalledTimes(2); + expect(driver.captureSyntheticShortsVisual.mock.calls.map(([, screenshotPath]) => screenshotPath)).toEqual([ + expect.stringContaining("userscript-cold-direct-load.png"), + expect.stringContaining("userscript-reload.png"), + ]); + expect(driver.soakCurrentShortsControl).toHaveBeenCalledTimes(2); + expect(result).toMatchObject({ + direct: { navigation: { status: 200, videoId: options.short } }, + reloaded: { navigation: { status: 200, videoId: options.short } }, + videoId: options.short, + }); + expect(driver.withNoProductionInteractions).toHaveBeenCalledTimes(1); + }); + + test("rejects a stale response owner before recording visual evidence", async () => { + const driver = createShortsDriver({ + openShort: jest.fn(async () => ({ + body: { dislikes: 123, id: "stalevid001" }, + status: 200, + videoId: "stalevid001", + })), + }); + + await expect( + runShortsRenderScenario(driver, { ...OPTIONS, short: "shortsabcde" }, { makeDirectory: jest.fn() }), + ).rejects.toThrow(/targeted a stale Short/); + expect(driver.captureSyntheticShortsVisual).not.toHaveBeenCalled(); + expect(driver.reload).not.toHaveBeenCalled(); + }); +}); + describe("live reaction cycle", () => { test.each(["neutral", "liked", "disliked"])( "covers all six transitions and restores an initially %s watch video", @@ -256,7 +1289,24 @@ describe("live reaction cycle", () => { expect(harness.getState()).toBe(initialState); expect(harness.recorder.waitForHandshake).toHaveBeenCalledTimes(6); expect(beforeFirstAction).toHaveBeenCalledTimes(1); - expect(result).toEqual({ evidencePaths: [], initialState, userId: "shared-user-id" }); + const countSequence = expectedReactionCounts(initialState, { dislikes: 123, likes: 456 }); + expect(harness.driver.assertRenderedDislikeCount.mock.calls).toEqual( + countSequence.map((counts) => [String(counts.dislikes), counts.dislikes, "userscript"]), + ); + expect(harness.driver.assertDislikeCountChangesObservable).toHaveBeenCalledWith( + countSequence + .slice(1) + .map((after, index) => ({ after: after.dislikes, before: countSequence[index].dislikes })) + .filter(({ after, before }) => after !== before), + "userscript", + ); + expect(result).toEqual({ + evidencePaths: [], + finalCounts: { dislikes: 123, likes: 456 }, + initialCounts: { dislikes: 123, likes: 456 }, + initialState, + userId: "shared-user-id", + }); }, ); @@ -420,12 +1470,14 @@ describe("live reaction cycle", () => { }), ).rejects.toThrow("simulated visual failure"); - expect(captureReactionVisual.mock.calls.map(([capture]) => capture)).toEqual([ - { index: 0, state: "neutral" }, - { index: 1, state: "liked" }, - { index: 2, state: "neutral" }, - { index: 3, state: "disliked" }, - ]); + const counts = expectedReactionCounts("neutral", { dislikes: 123, likes: 456 }); + expect(captureReactionVisual.mock.calls.map(([capture]) => capture)).toEqual( + ["neutral", "liked", "neutral", "disliked"].map((state, index) => ({ + counts: counts[index], + index, + state, + })), + ); expect(harness.getState()).toBe("neutral"); expect(harness.events.at(-1)).toEqual({ action: "dislike", state: "neutral" }); expect(harness.recorder.waitForHandshake).toHaveBeenLastCalledWith(0, 3); @@ -433,18 +1485,41 @@ describe("live reaction cycle", () => { }); describe("live production reaction matrix visual evidence", () => { - test("captures and returns the initial state plus all six transitions for watch and Shorts", async () => { + test.each([ + ["a separate low-count reaction Short", "reactshort1"], + ["the read-only Short fallback", null], + ])("captures and returns all six transitions using %s", async (_targetKind, configuredReactionShort) => { + const reactionShort = configuredReactionShort ?? "shortsabcde"; + const baselineCounts = new Map([ + ["abcdefghijk", { dislikes: 0, likes: 456 }], + [reactionShort, { dislikes: 13, likes: 900 }], + ]); const states = new Map([ ["abcdefghijk", "neutral"], - ["shortsabcde", "neutral"], + [reactionShort, "neutral"], ]); + const countsFor = (videoId) => { + const baseline = baselineCounts.get(videoId); + const state = states.get(videoId); + return { + dislikes: baseline.dislikes + Number(state === "disliked"), + likes: baseline.likes + Number(state === "liked"), + }; + }; let currentVideo; const driver = { + assertDislikeCountChangesObservable: jest.fn(async (changes) => { + expect(changes.every(({ after, before }) => after !== before)).toBe(true); + }), assertCurrentVideo: jest.fn((videoId) => expect(currentVideo).toBe(videoId)), + assertRenderedDislikeCount: jest.fn(async (renderedCount, dislikes) => { + expect(renderedCount).toBe(String(dislikes)); + }), assertRuntime: jest.fn(), assertSignedIn: jest.fn(), captureReactionStateVisual: jest.fn(async (capture) => { expect(states.get(capture.videoId)).toBe(capture.expectedState); + expect(capture.expectedCounts).toEqual(countsFor(capture.videoId)); return { screenshotPath: capture.screenshotPath }; }), clickAction: jest.fn(async (videoId, action) => { @@ -452,12 +1527,14 @@ describe("live production reaction matrix visual evidence", () => { }), openShort: jest.fn(async (videoId) => { currentVideo = videoId; + return { body: { ...baselineCounts.get(videoId), id: videoId }, videoId }; }), openWatch: jest.fn(async (videoId) => { currentVideo = videoId; + return { body: { ...baselineCounts.get(videoId), id: videoId }, videoId }; }), readReactionState: jest.fn(async () => states.get(currentVideo)), - waitForDislikeText: jest.fn(async () => "123"), + waitForDislikeText: jest.fn(async () => String(countsFor(currentVideo).dislikes)), waitForReactionState: jest.fn(async (expected) => expect(states.get(currentVideo)).toBe(expected)), }; const recorders = []; @@ -477,6 +1554,7 @@ describe("live production reaction matrix visual evidence", () => { const makeDirectory = jest.fn(); const options = { ...OPTIONS, + ...(configuredReactionShort ? { reactionShort: configuredReactionShort } : {}), short: "shortsabcde", watchB: "abcdefghijk", }; @@ -494,33 +1572,60 @@ describe("live production reaction matrix visual evidence", () => { const expectedShort = stateSequence.map((state, index) => path.join(outputDirectory, `short-${index}-${state}.png`), ); + const expectedWatchCounts = expectedReactionCounts("neutral", baselineCounts.get(options.watchB)); + const expectedShortCounts = expectedReactionCounts("neutral", baselineCounts.get(reactionShort)); expect(makeDirectory).toHaveBeenCalledWith(outputDirectory); expect(consumeVoteApproval).toHaveBeenCalledTimes(1); expect(driver.captureReactionStateVisual).toHaveBeenCalledTimes(14); expect(driver.captureReactionStateVisual.mock.calls.slice(0, 7).map(([capture]) => capture)).toEqual( expectedWatch.map((screenshotPath, index) => ({ + expectedCounts: expectedWatchCounts[index], expectedState: stateSequence[index], isShort: false, runtime: "userscript", screenshotPath, + shortsVisualModel: "strict-synthetic", videoId: options.watchB, })), ); expect(driver.captureReactionStateVisual.mock.calls.slice(7).map(([capture]) => capture)).toEqual( expectedShort.map((screenshotPath, index) => ({ + expectedCounts: expectedShortCounts[index], expectedState: stateSequence[index], isShort: true, runtime: "userscript", screenshotPath, - videoId: options.short, + shortsVisualModel: "strict-synthetic", + videoId: reactionShort, })), ); expect(result).toEqual({ evidencePaths: [...expectedWatch, ...expectedShort], outputDirectory, - short: { evidencePaths: expectedShort, initialState: "neutral", userId: "shared-user-id" }, - watch: { evidencePaths: expectedWatch, initialState: "neutral", userId: "shared-user-id" }, + short: { + evidencePaths: expectedShort, + finalCounts: baselineCounts.get(reactionShort), + initialCounts: baselineCounts.get(reactionShort), + initialState: "neutral", + userId: "shared-user-id", + }, + watch: { + evidencePaths: expectedWatch, + finalCounts: baselineCounts.get(options.watchB), + initialCounts: baselineCounts.get(options.watchB), + initialState: "neutral", + userId: "shared-user-id", + }, }); + expect(driver.assertRenderedDislikeCount.mock.calls).toEqual( + [...expectedWatchCounts, ...expectedShortCounts].map((counts) => [ + String(counts.dislikes), + counts.dislikes, + "userscript", + ]), + ); + expect(driver.assertDislikeCountChangesObservable).toHaveBeenCalledTimes(2); + expect(createRecorder.mock.calls.map(([videoId]) => videoId)).toEqual([options.watchB, reactionShort]); expect(recorders).toHaveLength(2); expect(recorders.every((recorder) => recorder.stop.mock.calls.length === 1)).toBe(true); }); @@ -532,6 +1637,7 @@ describe("live responsive visual scenario", () => { let currentViewport = originalViewport; const driver = { assertCurrentShortsControl: jest.fn(), + assertRenderedDislikeCount: jest.fn(async () => ({})), assertRuntime: jest.fn(), assertSignedIn: jest.fn(), captureSyntheticShortsVisual: jest.fn(async (_videoId, screenshotPath) => ({ @@ -542,8 +1648,8 @@ describe("live responsive visual scenario", () => { screenshotPath, viewport: currentViewport, })), - openShort: jest.fn(), - openWatch: jest.fn(), + openShort: jest.fn(async (videoId) => ({ body: { dislikes: 321, id: videoId }, status: 200, videoId })), + openWatch: jest.fn(async (videoId) => ({ body: { dislikes: 123, id: videoId, likes: 456 }, videoId })), readViewportSize: jest.fn(async () => currentViewport), setViewportSize: jest.fn(async (viewport) => { currentViewport = viewport; @@ -568,6 +1674,12 @@ describe("live responsive visual scenario", () => { expect(driver.captureWatchRatioVisual.mock.calls.map(([, screenshotPath]) => screenshotPath)).toEqual( RESPONSIVE_VIEWPORTS.map(({ width }) => expect.stringContaining(`userscript-watch-ratio-${width}.png`)), ); + expect(driver.captureWatchRatioVisual.mock.calls.map(([, , settings]) => settings)).toEqual( + RESPONSIVE_VIEWPORTS.map(() => ({ + expectedCounts: { dislikes: 123, id: options.watchA, likes: 456 }, + expectedVideoId: options.watchA, + })), + ); expect(driver.captureSyntheticShortsVisual.mock.calls.map(([, screenshotPath]) => screenshotPath)).toEqual( RESPONSIVE_VIEWPORTS.map(({ width }) => expect.stringContaining(`userscript-shorts-control-${width}.png`)), ); @@ -579,14 +1691,15 @@ describe("live responsive visual scenario", () => { expect(result.shortsSkipped).toBeNull(); }); - test("captures watch and native extension Shorts evidence at all responsive widths without actions", async () => { + test("captures synthetic extension Shorts evidence when the adapter declares that capability", async () => { const originalViewport = { height: 900, width: 1440 }; let currentViewport = originalViewport; const driver = { assertCurrentShortsControl: jest.fn(), + assertRenderedDislikeCount: jest.fn(async () => ({})), assertRuntime: jest.fn(), assertSignedIn: jest.fn(), - captureNativeShortsVisual: jest.fn(async (_videoId, screenshotPath) => ({ + captureSyntheticShortsVisual: jest.fn(async (_videoId, screenshotPath) => ({ screenshotPath, viewport: currentViewport, })), @@ -594,8 +1707,8 @@ describe("live responsive visual scenario", () => { screenshotPath, viewport: currentViewport, })), - openShort: jest.fn(), - openWatch: jest.fn(), + openShort: jest.fn(async (videoId) => ({ body: { dislikes: 321, id: videoId }, status: 200, videoId })), + openWatch: jest.fn(async (videoId) => ({ body: { dislikes: 123, id: videoId, likes: 456 }, videoId })), readViewportSize: jest.fn(async () => currentViewport), setViewportSize: jest.fn(async (viewport) => { currentViewport = viewport; @@ -612,8 +1725,8 @@ describe("live responsive visual scenario", () => { expect(driver.openShort).toHaveBeenCalledWith("shortsabcde"); expect(driver.assertCurrentShortsControl).toHaveBeenCalledTimes(RESPONSIVE_VIEWPORTS.length); - expect(driver.captureNativeShortsVisual).toHaveBeenCalledTimes(RESPONSIVE_VIEWPORTS.length); - expect(driver.captureNativeShortsVisual.mock.calls.map(([, screenshotPath]) => screenshotPath)).toEqual( + expect(driver.captureSyntheticShortsVisual).toHaveBeenCalledTimes(RESPONSIVE_VIEWPORTS.length); + expect(driver.captureSyntheticShortsVisual.mock.calls.map(([, screenshotPath]) => screenshotPath)).toEqual( RESPONSIVE_VIEWPORTS.map(({ width }) => expect.stringContaining(`extension-shorts-control-${width}.png`)), ); expect(result.shorts).toHaveLength(RESPONSIVE_VIEWPORTS.length); @@ -621,13 +1734,67 @@ describe("live responsive visual scenario", () => { expect(driver.setViewportSize).toHaveBeenLastCalledWith(originalViewport); }); + test("captures native Shorts evidence by capability even when the runtime label is userscript", async () => { + const originalViewport = { height: 900, width: 1440 }; + let currentViewport = originalViewport; + const driver = { + assertCurrentShortsControl: jest.fn(), + assertRenderedDislikeCount: jest.fn(async () => ({})), + assertRuntime: jest.fn(), + assertSignedIn: jest.fn(), + captureNativeShortsVisual: jest.fn(async (_videoId, screenshotPath) => ({ + screenshotPath, + viewport: currentViewport, + })), + captureWatchRatioVisual: jest.fn(async (_runtime, screenshotPath) => ({ + screenshotPath, + viewport: currentViewport, + })), + openShort: jest.fn(async (videoId) => ({ body: { dislikes: 321, id: videoId }, status: 200, videoId })), + openWatch: jest.fn(async (videoId) => ({ body: { dislikes: 123, id: videoId, likes: 456 }, videoId })), + readViewportSize: jest.fn(async () => currentViewport), + setViewportSize: jest.fn(async (viewport) => { + currentViewport = viewport; + }), + waitForDislikeText: jest.fn(async () => "123"), + withNoProductionInteractions: jest.fn(async (action) => action()), + }; + + await runResponsiveVisualScenario( + driver, + { + ...OPTIONS, + capabilities: { shortsVisualModel: "native-pair" }, + short: "shortsabcde", + watchA: "abcdefghijk", + }, + { makeDirectory: jest.fn(), outputDirectory: "responsive-evidence" }, + ); + + expect(driver.captureNativeShortsVisual).toHaveBeenCalledTimes(RESPONSIVE_VIEWPORTS.length); + }); + + test("fails before navigation when the adapter omits its Shorts visual capability", async () => { + const driver = { withNoProductionInteractions: jest.fn() }; + + await expect( + runResponsiveVisualScenario( + driver, + { ...OPTIONS, capabilities: undefined, short: "shortsabcde", watchA: "abcdefghijk" }, + { makeDirectory: jest.fn(), outputDirectory: "responsive-evidence" }, + ), + ).rejects.toThrow("Unsupported live Shorts visual model: missing"); + expect(driver.withNoProductionInteractions).not.toHaveBeenCalled(); + }); + test("restores the original viewport when a visual assertion fails", async () => { const originalViewport = { height: 800, width: 1200 }; const driver = { + assertRenderedDislikeCount: jest.fn(async () => ({})), assertRuntime: jest.fn(), assertSignedIn: jest.fn(), captureWatchRatioVisual: jest.fn(async () => Promise.reject(new Error("visual failed"))), - openWatch: jest.fn(), + openWatch: jest.fn(async (videoId) => ({ body: { dislikes: 123, id: videoId, likes: 456 }, videoId })), readViewportSize: jest.fn(async () => originalViewport), setViewportSize: jest.fn(), waitForDislikeText: jest.fn(async () => "123"), diff --git a/Extensions/UserScript/live/live-vote-traffic-recorder.spec.js b/Extensions/UserScript/live/live-vote-traffic-recorder.spec.js index 368752f..f9e3aee 100644 --- a/Extensions/UserScript/live/live-vote-traffic-recorder.spec.js +++ b/Extensions/UserScript/live/live-vote-traffic-recorder.spec.js @@ -1,7 +1,8 @@ const { assertLogicalVoteHandshake } = require("../e2e/live/live-youtube-driver"); 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 } = {}) { 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 } = {}) { return { - body: { solution: "proof", userId, videoId }, + body: { solution: SOLUTION, userId, videoId }, pathname: "/interact/confirmVote", responseBody: confirmed, responseError: null, @@ -42,13 +43,24 @@ describe("live logical vote handshake validation", () => { }); 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/], ["value", [vote(), vote({ value: 0 }), confirmation()], /changed the requested vote value/], ])("rejects a retry with a mismatched %s", (_field, records, message) => { expect(() => assertLogicalVoteHandshake(records, VIDEO_ID, 1)).toThrow(message); }); + test("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([ ["extra confirmation", [vote(), confirmation(), 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", () => { expect(() => assertLogicalVoteHandshake([vote({ status: 500 }), confirmation()], VIDEO_ID, 1)).toThrow( /Vote request failed with HTTP 500/, diff --git a/Extensions/UserScript/live/live-youtube-driver.spec.js b/Extensions/UserScript/live/live-youtube-driver.spec.js index 8fab4f1..44a8ba1 100644 --- a/Extensions/UserScript/live/live-youtube-driver.spec.js +++ b/Extensions/UserScript/live/live-youtube-driver.spec.js @@ -3,21 +3,56 @@ */ const { + AttributedRuntimeTrafficLedger, LiveYoutubeDriver, + VoteTrafficRecorder, + assertDislikeCountChangesObservable, + assertExactVotesRequestAudit, + assertExactVotesResponseAudit, + assertWorkerRequestAttributionIsUnambiguous, + assertElementActionable, + assertElementReadyForViewportMeasurement, assertNativeShortsPairGeometry, assertReactionPressedStates, + assertRenderedDislikeCountMatchesApi, assertShortsActionStackGeometry, assertSyntheticShortsGeometry, + assertWatchActionTopologySnapshot, assertWatchRatioViewportAlignment, + assertWatchRatioSurroundings, + assertNavigationLinkActionable, + attributeRuntimeRequest, + channelTabUrl, + clickHitTestedElement, + clickHitTestedNavigationLink, + clickStabilizedExactVideoLink, clickWithSingleNavigationRetry, croppedScreenshotClip, firstVisibleRelatedWatchLink, + formattedDislikeCountCandidates, isShortCandidateEligible, isShortsIconVisualReady, + isExactVideoUrl, + parseLiveExtensionAcceptHeader, + prepareElementForViewportMeasurement, + readCurrentShortsNativeControlState, readDislikeControlText, + readElementActionability, + readNavigationLinkActionability, + readShortsReelDiagnosticState, + readShortsRendererIdentityState, + scrollElementIntoViewAndWaitForPaint, + readWatchRatioAppearance, + readWatchRatioSurroundings, + readWatchTopRowTopology, relatedWatchVideoId, } = require("../e2e/live/live-youtube-driver"); +const SELECTED_EXTENSION_ID = "abcdefghijklmnopabcdefghijklmnop"; +const COMPETING_EXTENSION_ID = "pppppppppppppppppppppppppppppppp"; +const EXPECTED_BUILD_ID = "0123456789abcdef0123456789abcdef"; +const LIVE_ACCEPT_HEADER = `application/json, application/vnd.ryd-live+json; id=${SELECTED_EXTENSION_ID}; build=${EXPECTED_BUILD_ID}`; + const VISIBLE_RECT = { bottom: 200, height: 100, @@ -61,6 +96,7 @@ function validSyntheticShortsGeometry() { label: box(100, 10, 48, 70), svg: box(112, 22, 24, 24), }, + nativeActionHosts: [box(100, 10, 48, 78), box(100, 166, 48, 78)], next: { host: box(100, 166, 48, 78), }, @@ -77,6 +113,24 @@ function validSyntheticShortsGeometry() { }; } +function widenShortsControlForLocalizedCount(control, { countWidth = 41, shellWidth = 52 } = {}) { + const center = control.button.x + control.button.width / 2; + control.host.x = center - shellWidth / 2; + control.host.width = shellWidth; + control.label.x = center - shellWidth / 2; + control.label.width = shellWidth; + control.count.x = center - countWidth / 2; + control.count.width = countWidth; + return control; +} + +function widenShortsButtonAroundCenter(control, width) { + const center = control.button.x + control.button.width / 2; + control.button.x = center - width / 2; + control.button.width = width; + return control; +} + function validNativeShortsPairGeometry() { const syntheticGeometry = validSyntheticShortsGeometry(); const enrich = (geometry, actionIndex) => ({ @@ -91,10 +145,12 @@ function validNativeShortsPairGeometry() { }; } -function renderShort({ href, rendererVideoId } = {}) { +function renderShort({ canonicalHref, descriptionHref, href, rendererVideoId } = {}) { document.body.innerHTML = ` ${href ? `` : ""} + ${canonicalHref ? `` : ""} + ${descriptionHref ? `
` : ""} `; @@ -160,6 +216,25 @@ describe("exact installed live-build identity", () => { ); }); + test.each([ + ["extension", "4.0.4"], + ["userscript", "3.2.0"], + ])( + "rejects an absent %s runtime instead of treating an unmodified YouTube page as a pass", + async (runtime, version) => { + const driver = createDriver({ + extension: null, + extensionBuild: null, + userscript: null, + userscriptBuild: null, + }); + + await expect(driver.assertRuntime(runtime, version, buildId)).rejects.toThrow( + `Expected ${runtime} version ${version} to be active`, + ); + }, + ); + test.each([undefined, "", "stale", "A".repeat(32)])( "refuses to test without a valid exact build ID: %p", async (expectedBuildId) => { @@ -203,6 +278,31 @@ describe("isShortCandidateEligible", () => { expect(isShortCandidateEligible(button, activeShortSettings("current-video"))).toBe(true); }); + test("uses the canonical player link instead of a description cross-link for reel ownership", () => { + const { button } = renderShort({ + canonicalHref: "/shorts/canonical01", + descriptionHref: "/shorts/crosslink01", + }); + + expect(isShortCandidateEligible(button, activeShortSettings("canonical01"))).toBe(true); + expect(isShortCandidateEligible(button, activeShortSettings("crosslink01"))).toBe(false); + expect(readShortsRendererIdentityState(button, { expectedShortVideoId: "crosslink01" })).toMatchObject({ + candidateVideoIds: ["canonical01"], + videoMatches: false, + }); + }); + + test("keeps conflicting renderer and canonical identities ineligible", () => { + const { button } = renderShort({ canonicalHref: "/shorts/canonical01", rendererVideoId: "attribute01" }); + + expect(isShortCandidateEligible(button, activeShortSettings("canonical01"))).toBe(false); + expect(isShortCandidateEligible(button, activeShortSettings("attribute01"))).toBe(false); + expect(readShortsRendererIdentityState(button, { expectedShortVideoId: "attribute01" })).toMatchObject({ + candidateVideoIds: ["attribute01", "canonical01"], + videoMatches: false, + }); + }); + test("rejects a matching descendant link when the renderer video-id identifies another Short", () => { const { button } = renderShort({ href: "/shorts/current-video", rendererVideoId: "outgoing-video" }); @@ -231,6 +331,885 @@ describe("isShortCandidateEligible", () => { expect(isShortCandidateEligible(button, activeShortSettings("current-video"))).toBe(false); }); + + test.each([ + ["zero-opacity", (reel) => (reel.style.opacity = "0")], + ["aria-hidden", (reel) => reel.setAttribute("aria-hidden", "true")], + ])("rejects a geometrically visible control inside a %s stale renderer", (_label, hideRenderer) => { + const { button, reel } = renderShort({ rendererVideoId: "current-video" }); + hideRenderer(reel); + + expect(isShortCandidateEligible(button, activeShortSettings("current-video"))).toBe(false); + }); +}); + +describe("readCurrentShortsNativeControlState", () => { + function renderRail({ + nativeControlCount = 0, + outsideActionCount = 0, + playerChromeLabels = [], + rendererVideoId = null, + synthetic = false, + } = {}) { + const nativeControls = Array.from({ length: nativeControlCount }, (_, index) => { + const host = index === 0 ? "like-button-view-model" : "button-view-model"; + return `<${host}>`; + }).join(""); + const outsideActions = Array.from({ length: outsideActionCount }, (_, index) => { + const host = index === 0 ? "like-button-view-model" : "button-view-model"; + return `<${host}>`; + }).join(""); + const playerChrome = playerChromeLabels + .map((label) => ``) + .join(""); + document.body.innerHTML = ` + + + +
${playerChrome}
+ + ${nativeControls} + ${ + synthetic + ? '
' + : "" + } +
+ ${outsideActions} + + `; + for (const element of document.querySelectorAll( + "ytd-reel-video-renderer, reel-action-bar-view-model, video, button", + )) { + element.getBoundingClientRect = jest.fn(() => VISIBLE_RECT); + } + } + + const readState = () => + readCurrentShortsNativeControlState({ + actionBarSelector: "reel-action-bar-view-model, .slim-video-action-bar-actions", + syntheticSelector: "[data-ryd-synthetic-shorts-dislike]", + videoId: "abcdefghijk", + }); + + test("classifies a current renderer with no YouTube actions as fully blank", () => { + renderRail({ playerChromeLabels: ["Pause", "Mute", "More"] }); + + expect(readState()).toMatchObject({ + completeIndependentActionBars: 0, + completeNativeActionBars: 0, + independentActionLabels: [], + matchingCompleteIndependentActionBars: 0, + matchingCompleteNativeActionBars: 0, + matchingRenderedReels: 1, + matchingVisibleIndependentActions: 0, + matchingVisibleNativeControls: 0, + renderedReels: 1, + visibleIndependentActions: 0, + visibleNativeControls: 0, + }); + }); + + test("counts native actions but excludes the runtime's synthetic Dislike", () => { + renderRail({ nativeControlCount: 5, synthetic: true }); + + expect(readState()).toMatchObject({ + completeNativeActionBars: 1, + labels: ["Native 1", "Native 2", "Native 3", "Native 4", "Native 5"], + matchingCompleteNativeActionBars: 1, + matchingRenderedReels: 1, + matchingVisibleNativeControls: 5, + visibleNativeControls: 5, + }); + }); + + test("classifies visible action buttons individually when their action-bar host has no rendered box", () => { + renderRail({ nativeControlCount: 4 }); + document.querySelector("reel-action-bar-view-model").getBoundingClientRect = jest.fn(() => box(100, 100, 0, 0)); + + expect(readState()).toMatchObject({ + completeNativeActionBars: 1, + matchingCompleteNativeActionBars: 1, + matchingVisibleNativeControls: 4, + visibleNativeControls: 4, + }); + }); + + test("does not call a page blank when another visible reel has meaningful native controls", () => { + renderRail({ nativeControlCount: 3, rendererVideoId: "different01" }); + + expect(readState()).toMatchObject({ + matchingRenderedReels: 0, + matchingVisibleNativeControls: 0, + visibleNativeControls: 3, + }); + }); + + test("uses the canonical player identity instead of a description cross-link", () => { + renderRail({ nativeControlCount: 3 }); + + const state = readCurrentShortsNativeControlState({ + actionBarSelector: "reel-action-bar-view-model, .slim-video-action-bar-actions", + syntheticSelector: "[data-ryd-synthetic-shorts-dislike]", + videoId: "unrelated01", + }); + expect(state).toMatchObject({ + matchingRenderedReels: 0, + matchingVisibleNativeControls: 0, + rendererIdentities: [["abcdefghijk"]], + visibleNativeControls: 3, + }); + }); + + test("returns the visible video box only from the exact current reel and ignores retained or detached videos", () => { + document.body.innerHTML = ` + + `; + const detached = document.createElement("video"); + document.body.append(detached); + detached.remove(); + document.querySelectorAll("ytd-reel-video-renderer").forEach((reel) => { + reel.getBoundingClientRect = jest.fn(() => ({ + bottom: 700, + height: 620, + left: 0, + right: 1024, + top: 80, + width: 1024, + x: 0, + y: 80, + })); + }); + document.querySelector("#retained").getBoundingClientRect = jest.fn(() => ({ + bottom: 768, + height: 768, + left: 0, + right: 1024, + top: 0, + width: 1024, + x: 0, + y: 0, + })); + document.querySelector("#current").getBoundingClientRect = jest.fn(() => ({ + bottom: 640, + height: 560, + left: 300, + right: 700, + top: 80, + width: 400, + x: 300, + y: 80, + })); + + expect(readState()).toMatchObject({ + currentVideoBox: { height: 560, width: 400, x: 300, y: 80 }, + matchingRenderedReels: 1, + viewport: { height: window.innerHeight, width: window.innerWidth }, + }); + }); + + test("detects visible controls outside known action-bar selectors through the independent fallback", () => { + renderRail({ outsideActionCount: 2 }); + + expect(readState()).toMatchObject({ + independentActionLabels: ["Future 1", "Future 2"], + matchingVisibleIndependentActions: 2, + matchingVisibleNativeControls: 0, + visibleIndependentActions: 2, + visibleNativeControls: 0, + }); + }); + + test("recognizes a complete structural fallback rail after the known selector changes", () => { + renderRail({ outsideActionCount: 4 }); + + expect(readState()).toMatchObject({ + completeIndependentActionBars: 1, + matchingCompleteIndependentActionBars: 1, + matchingVisibleIndependentActions: 4, + visibleIndependentActions: 4, + }); + }); + + test("captures selector-independent action details for blank-sample diagnostics", () => { + renderRail({ outsideActionCount: 1 }); + const diagnostic = readShortsReelDiagnosticState(document.querySelector("ytd-reel-video-renderer"), { + actionBarSelector: "reel-action-bar-view-model, .slim-video-action-bar-actions", + syntheticSelector: "[data-ryd-synthetic-shorts-dislike]", + }); + + expect(diagnostic).toMatchObject({ + actionBars: [expect.objectContaining({ tagName: "reel-action-bar-view-model" })], + renderedInViewport: true, + totalInteractiveCount: 1, + visibleActions: [ + expect.objectContaining({ + ariaLabel: "Future 1", + renderedInViewport: true, + synthetic: false, + tagName: "button", + }), + ], + }); + }); +}); + +describe("Shorts native-control wait classification", () => { + function nativeRailMeasurement({ currentVideoBox = null, visible = false } = {}) { + return { + completeIndependentActionBars: 0, + completeNativeActionBars: visible ? 1 : 0, + currentVideoBox, + currentVideoId: "abcdefghijk", + independentActionLabels: [], + labels: visible ? ["Like", "Comments", "Share", "Remix"] : [], + matchingCompleteIndependentActionBars: 0, + matchingCompleteNativeActionBars: visible ? 1 : 0, + matchingRenderedReels: 1, + matchingVisibleIndependentActions: 0, + matchingVisibleNativeControls: visible ? 4 : 0, + renderedReels: 1, + viewport: { height: 768, width: 1024 }, + visibleIndependentActions: 0, + visibleNativeControls: visible ? 4 : 0, + }; + } + + test("treats a reel with only Pause, Mute, and player More controls as a blank sample", async () => { + document.body.innerHTML = ` + +
+ + + +
+ +
`; + for (const element of document.querySelectorAll("ytd-reel-video-renderer, reel-action-bar-view-model, button")) { + element.getBoundingClientRect = jest.fn(() => VISIBLE_RECT); + } + const page = { + evaluate: jest.fn(async (reader, settings) => reader(settings)), + setDefaultNavigationTimeout: jest.fn(), + setDefaultTimeout: jest.fn(), + url: () => "https://www.youtube.com/shorts/abcdefghijk", + }; + const driver = new LiveYoutubeDriver(page, {}); + + await expect( + driver.waitForCurrentShortsNativeControls("abcdefghijk", { intervalMs: 1, timeoutMs: 0 }), + ).resolves.toMatchObject({ + independentActionLabels: [], + reason: "no-visible-native-shorts-actions", + status: "blank", + visibleIndependentActions: 0, + visibleNativeControls: 0, + }); + }); + + test.each([ + [ + "blank", + { matchingVisibleNativeControls: 0, visibleNativeControls: 0 }, + "blank", + "no-visible-native-shorts-actions", + ], + [ + "unowned native controls", + { matchingVisibleNativeControls: 0, visibleNativeControls: 3 }, + "present", + "visible-native-shorts-actions-not-owned-by-current-video", + ], + ])("classifies %s after the bounded observation window", async (_label, measurement, status, reason) => { + const page = { + evaluate: jest.fn(async () => ({ + completeIndependentActionBars: 0, + completeNativeActionBars: 0, + currentVideoId: "abcdefghijk", + independentActionLabels: [], + labels: [], + matchingCompleteIndependentActionBars: 0, + matchingCompleteNativeActionBars: 0, + matchingVisibleIndependentActions: 0, + matchingRenderedReels: 0, + renderedReels: 1, + visibleIndependentActions: 0, + ...measurement, + })), + setDefaultNavigationTimeout: jest.fn(), + setDefaultTimeout: jest.fn(), + url: () => "https://www.youtube.com/shorts/abcdefghijk", + }; + const reportProgress = jest.fn(); + const driver = new LiveYoutubeDriver(page, {}, { reportProgress }); + + await expect( + driver.waitForCurrentShortsNativeControls("abcdefghijk", { intervalMs: 1, timeoutMs: 0 }), + ).resolves.toMatchObject({ reason, status, ...measurement }); + expect(reportProgress).toHaveBeenCalledWith( + status === "blank" ? "shorts-native-controls.blank" : "shorts-native-controls.present-unowned", + expect.objectContaining({ reason, status }), + ); + }); + + test("returns immediately when a complete current-video native action bar appears", async () => { + const page = { + evaluate: jest.fn(async () => ({ + completeIndependentActionBars: 0, + completeNativeActionBars: 1, + currentVideoId: "abcdefghijk", + independentActionLabels: [], + labels: ["Like", "Comments", "Share", "Remix"], + matchingCompleteIndependentActionBars: 0, + matchingCompleteNativeActionBars: 1, + matchingVisibleIndependentActions: 0, + matchingRenderedReels: 1, + matchingVisibleNativeControls: 4, + renderedReels: 1, + visibleIndependentActions: 0, + visibleNativeControls: 4, + })), + setDefaultNavigationTimeout: jest.fn(), + setDefaultTimeout: jest.fn(), + url: () => "https://www.youtube.com/shorts/abcdefghijk", + }; + const driver = new LiveYoutubeDriver(page, {}); + + await expect( + driver.waitForCurrentShortsNativeControls("abcdefghijk", { timeoutMs: 20_000 }), + ).resolves.toMatchObject({ + matchingCompleteNativeActionBars: 1, + matchingVisibleNativeControls: 4, + status: "present", + }); + expect(page.evaluate).toHaveBeenCalledTimes(1); + }); + + test("reports any meaningful current native action immediately so a missing runtime control cannot be skipped", async () => { + const partial = { + completeIndependentActionBars: 0, + completeNativeActionBars: 0, + currentVideoId: "abcdefghijk", + independentActionLabels: [], + labels: ["Like", "Comments"], + matchingCompleteIndependentActionBars: 0, + matchingCompleteNativeActionBars: 0, + matchingRenderedReels: 1, + matchingVisibleIndependentActions: 0, + matchingVisibleNativeControls: 2, + renderedReels: 1, + visibleIndependentActions: 0, + visibleNativeControls: 2, + }; + const page = { + evaluate: jest + .fn() + .mockResolvedValueOnce(partial) + .mockResolvedValueOnce({ + ...partial, + completeNativeActionBars: 1, + labels: ["Like", "Comments", "Share", "Remix"], + matchingCompleteNativeActionBars: 1, + matchingVisibleNativeControls: 4, + visibleNativeControls: 4, + }), + setDefaultNavigationTimeout: jest.fn(), + setDefaultTimeout: jest.fn(), + url: () => "https://www.youtube.com/shorts/abcdefghijk", + }; + const driver = new LiveYoutubeDriver(page, {}); + + await expect( + driver.waitForCurrentShortsNativeControls("abcdefghijk", { intervalMs: 1, timeoutMs: 100 }), + ).resolves.toMatchObject({ + matchingCompleteNativeActionBars: 0, + matchingVisibleNativeControls: 2, + status: "present", + }); + expect(page.evaluate).toHaveBeenCalledTimes(1); + }); + + test("does not classify a partial visible current action bar as blank at the observation boundary", async () => { + const measurement = { + completeIndependentActionBars: 0, + completeNativeActionBars: 0, + currentVideoId: "abcdefghijk", + independentActionLabels: [], + labels: ["Like", "Comments"], + matchingCompleteIndependentActionBars: 0, + matchingCompleteNativeActionBars: 0, + matchingRenderedReels: 1, + matchingVisibleIndependentActions: 0, + matchingVisibleNativeControls: 2, + renderedReels: 1, + visibleIndependentActions: 0, + visibleNativeControls: 2, + }; + const page = { + evaluate: jest.fn(async () => measurement), + setDefaultNavigationTimeout: jest.fn(), + setDefaultTimeout: jest.fn(), + url: () => "https://www.youtube.com/shorts/abcdefghijk", + }; + const reportProgress = jest.fn(); + const driver = new LiveYoutubeDriver(page, {}, { reportProgress }); + + await expect( + driver.waitForCurrentShortsNativeControls("abcdefghijk", { intervalMs: 1, timeoutMs: 0 }), + ).resolves.toMatchObject({ + matchingCompleteNativeActionBars: 0, + matchingVisibleNativeControls: 2, + status: "present", + }); + expect(reportProgress).toHaveBeenCalledWith( + "shorts-native-controls.present", + expect.objectContaining({ matchingVisibleNativeControls: 2, status: "present" }), + ); + }); + + test("uses real pointer movement over the current video to reveal an idle native action rail", async () => { + let nativeRailRevealed = false; + const page = { + evaluate: jest.fn(async (reader) => { + expect(reader).toBe(readCurrentShortsNativeControlState); + return nativeRailMeasurement({ + currentVideoBox: box(300, 80, 400, 560), + visible: nativeRailRevealed, + }); + }), + locator: jest.fn(() => { + throw new Error("The native-control wait must not scan retained video locators."); + }), + mouse: { + move: jest.fn(async () => { + nativeRailRevealed = true; + }), + }, + setDefaultNavigationTimeout: jest.fn(), + setDefaultTimeout: jest.fn(), + url: () => "https://www.youtube.com/shorts/abcdefghijk", + }; + const driver = new LiveYoutubeDriver(page, {}); + + await expect( + driver.waitForCurrentShortsNativeControls("abcdefghijk", { intervalMs: 1, timeoutMs: 50 }), + ).resolves.toMatchObject({ + lastNativeUiWakePoint: { x: 499, y: 360 }, + matchingVisibleNativeControls: 4, + nativeUiWakeAttempts: 1, + status: "present", + }); + expect(page.mouse.move).toHaveBeenCalledWith(499, 360); + expect(page.locator).not.toHaveBeenCalled(); + expect(page.evaluate).toHaveBeenCalledTimes(2); + }); + + test("does not depend on animation-frame delivery when pointer movement reveals the rail", async () => { + let nativeRailRevealed = false; + const page = { + evaluate: jest.fn(async (reader) => { + expect(reader).toBe(readCurrentShortsNativeControlState); + return nativeRailMeasurement({ + currentVideoBox: box(500, 80, 400, 560), + visible: nativeRailRevealed, + }); + }), + mouse: { + move: jest.fn(async () => { + nativeRailRevealed = true; + }), + }, + setDefaultNavigationTimeout: jest.fn(), + setDefaultTimeout: jest.fn(), + url: () => "https://www.youtube.com/shorts/abcdefghijk", + }; + const driver = new LiveYoutubeDriver(page, {}); + + await expect( + driver.waitForCurrentShortsNativeControls("abcdefghijk", { intervalMs: 1, timeoutMs: 50 }), + ).resolves.toMatchObject({ status: "present" }); + + expect(page.mouse.move).toHaveBeenCalledWith(699, 360); + expect(page.evaluate).toHaveBeenCalledTimes(2); + }); + + test("timeout zero performs exactly one measurement without a pointer pulse or delay", async () => { + const page = { + evaluate: jest.fn(async () => nativeRailMeasurement({ currentVideoBox: box(400, 80, 400, 560) })), + mouse: { move: jest.fn(async () => undefined) }, + setDefaultNavigationTimeout: jest.fn(), + setDefaultTimeout: jest.fn(), + url: () => "https://www.youtube.com/shorts/abcdefghijk", + }; + const driver = new LiveYoutubeDriver(page, {}); + + await expect( + driver.waitForCurrentShortsNativeControls("abcdefghijk", { intervalMs: 1, timeoutMs: 0 }), + ).resolves.toMatchObject({ nativeUiWakeAttempts: 0, status: "blank" }); + + expect(page.evaluate).toHaveBeenCalledTimes(1); + expect(page.mouse.move).not.toHaveBeenCalled(); + }); + + test("confirms a truly blank rail within the bounded observation and operation-watchdog window", async () => { + const page = { + evaluate: jest.fn(async () => nativeRailMeasurement()), + setDefaultNavigationTimeout: jest.fn(), + setDefaultTimeout: jest.fn(), + url: () => "https://www.youtube.com/shorts/abcdefghijk", + }; + const driver = new LiveYoutubeDriver(page, {}); + const startedAt = Date.now(); + + const result = await driver.waitForCurrentShortsNativeControls("abcdefghijk", { + intervalMs: 5, + operationTimeoutMs: 100, + timeoutMs: 25, + }); + + expect(result).toMatchObject({ + nativeUiWakeAttempts: 0, + reason: "no-visible-native-shorts-actions", + status: "blank", + }); + expect(result.observedForMs).toBeGreaterThanOrEqual(25); + expect(Date.now() - startedAt).toBeLessThan(250); + expect(page.evaluate.mock.calls.length).toBeGreaterThanOrEqual(2); + }); + + test("accepts controls discovered by the final measurement at the observation boundary", async () => { + const page = { + evaluate: jest + .fn() + .mockResolvedValueOnce(nativeRailMeasurement()) + .mockResolvedValueOnce(nativeRailMeasurement({ visible: true })), + setDefaultNavigationTimeout: jest.fn(), + setDefaultTimeout: jest.fn(), + url: () => "https://www.youtube.com/shorts/abcdefghijk", + }; + const driver = new LiveYoutubeDriver(page, {}); + + await expect( + driver.waitForCurrentShortsNativeControls("abcdefghijk", { + intervalMs: 20, + operationTimeoutMs: 100, + timeoutMs: 20, + }), + ).resolves.toMatchObject({ matchingVisibleNativeControls: 4, status: "present" }); + expect(page.evaluate).toHaveBeenCalledTimes(2); + }); + + test("fails instead of recording blank evidence when the synchronous browser probe stalls", async () => { + const page = { + evaluate: jest.fn(() => new Promise(() => {})), + setDefaultNavigationTimeout: jest.fn(), + setDefaultTimeout: jest.fn(), + url: () => "https://www.youtube.com/shorts/abcdefghijk", + }; + const driver = new LiveYoutubeDriver(page, {}); + + await expect( + driver.waitForCurrentShortsNativeControls("abcdefghijk", { + intervalMs: 1, + operationTimeoutMs: 10, + timeoutMs: 25, + }), + ).rejects.toThrow( + "Timed out probing Shorts native controls for abcdefghijk; the 25ms observation window is invalid.", + ); + }); + + test("fails instead of overrunning the observation deadline when a pointer pulse stalls", async () => { + const page = { + evaluate: jest.fn(async () => nativeRailMeasurement({ currentVideoBox: box(300, 80, 400, 560) })), + mouse: { move: jest.fn(() => new Promise(() => {})) }, + setDefaultNavigationTimeout: jest.fn(), + setDefaultTimeout: jest.fn(), + url: () => "https://www.youtube.com/shorts/abcdefghijk", + }; + const driver = new LiveYoutubeDriver(page, {}); + + await expect( + driver.waitForCurrentShortsNativeControls("abcdefghijk", { + intervalMs: 1, + operationTimeoutMs: 10, + timeoutMs: 25, + }), + ).rejects.toThrow( + "Timed out probing Shorts native controls for abcdefghijk; the 25ms observation window is invalid.", + ); + expect(page.evaluate).toHaveBeenCalledTimes(1); + }); + + test("returns present when a complete structural fallback rail replaces the known action-bar selector", async () => { + const page = { + evaluate: jest.fn(async () => ({ + completeIndependentActionBars: 1, + completeNativeActionBars: 0, + currentVideoId: "abcdefghijk", + independentActionLabels: ["Like", "Comments", "Share", "Remix"], + labels: [], + matchingCompleteIndependentActionBars: 1, + matchingCompleteNativeActionBars: 0, + matchingRenderedReels: 1, + matchingVisibleIndependentActions: 4, + matchingVisibleNativeControls: 0, + renderedReels: 1, + visibleIndependentActions: 4, + visibleNativeControls: 0, + })), + setDefaultNavigationTimeout: jest.fn(), + setDefaultTimeout: jest.fn(), + url: () => "https://www.youtube.com/shorts/abcdefghijk", + }; + const driver = new LiveYoutubeDriver(page, {}); + + await expect( + driver.waitForCurrentShortsNativeControls("abcdefghijk", { timeoutMs: 20_000 }), + ).resolves.toMatchObject({ + matchingCompleteIndependentActionBars: 1, + matchingVisibleIndependentActions: 4, + status: "present", + }); + expect(page.evaluate).toHaveBeenCalledTimes(1); + }); +}); + +describe("blank Shorts diagnostic evidence", () => { + test("captures a viewport screenshot and a structured identity/action inventory", async () => { + const identity = { + candidateVideoIds: ["abcdefghijk"], + elementRenderedInViewport: true, + hasReel: true, + reelRenderedInViewport: true, + videoMatches: true, + }; + const dom = { + actionBars: [], + renderedInViewport: true, + totalInteractiveCount: 0, + visibleActions: [], + }; + const reel = { + evaluate: jest.fn(async (reader) => (reader === readShortsRendererIdentityState ? identity : dom)), + }; + const page = { + evaluate: jest.fn(async () => undefined), + locator: jest.fn(() => ({ count: jest.fn(async () => 1), nth: jest.fn(() => reel) })), + screenshot: jest.fn(async () => undefined), + setDefaultNavigationTimeout: jest.fn(), + setDefaultTimeout: jest.fn(), + url: () => "https://www.youtube.com/shorts/abcdefghijk", + }; + const reportProgress = jest.fn(); + const driver = new LiveYoutubeDriver(page, {}, { reportProgress }); + driver.readViewportSize = jest.fn(async () => ({ height: 768, width: 1024 })); + driver.waitForCurrentShortsNativeControls = jest.fn(async () => ({ + currentVideoId: "abcdefghijk", + observedForMs: 0, + reason: "no-visible-native-shorts-actions", + status: "blank", + visibleNativeControls: 0, + })); + + const evidence = await driver.captureBlankShortsDiagnostics("abcdefghijk", "blank.png"); + + expect(evidence).toMatchObject({ + currentUrl: "https://www.youtube.com/shorts/abcdefghijk", + expectedVideoId: "abcdefghijk", + nativeControlsAfterEvidence: expect.objectContaining({ status: "blank" }), + reels: [{ dom, identity, index: 0 }], + screenshotPath: "blank.png", + viewport: { height: 768, width: 1024 }, + }); + expect(evidence.capturedAt).toEqual(expect.any(String)); + expect(page.screenshot).toHaveBeenCalledWith({ + animations: "disabled", + caret: "hide", + path: "blank.png", + timeout: expect.any(Number), + }); + const screenshotTimeout = page.screenshot.mock.calls[0][0].timeout; + expect(screenshotTimeout).toBeGreaterThan(0); + expect(screenshotTimeout).toBeLessThanOrEqual(2_000); + expect(reportProgress).toHaveBeenCalledWith("shorts-blank-evidence.captured", { + renderedReels: 1, + screenshotPath: "blank.png", + statusAfterEvidence: "blank", + videoId: "abcdefghijk", + visibleActions: 0, + }); + }); + + test("fails quickly when a blank-evidence screenshot stalls", async () => { + const identity = { + candidateVideoIds: ["abcdefghijk"], + elementRenderedInViewport: true, + hasReel: true, + reelRenderedInViewport: true, + videoMatches: true, + }; + const dom = { + actionBars: [], + renderedInViewport: true, + totalInteractiveCount: 0, + visibleActions: [], + }; + const reel = { + evaluate: jest.fn(async (reader) => (reader === readShortsRendererIdentityState ? identity : dom)), + }; + const page = { + evaluate: jest.fn(async () => undefined), + locator: jest.fn(() => ({ count: jest.fn(async () => 1), nth: jest.fn(() => reel) })), + screenshot: jest.fn(() => new Promise(() => {})), + setDefaultNavigationTimeout: jest.fn(), + setDefaultTimeout: jest.fn(), + url: () => "https://www.youtube.com/shorts/abcdefghijk", + }; + const driver = new LiveYoutubeDriver(page, {}); + + await expect( + driver.captureBlankShortsDiagnostics("abcdefghijk", "blank.png", { operationTimeoutMs: 10 }), + ).rejects.toThrow("Timed out capturing screenshot evidence for blank Short abcdefghijk"); + expect(page.screenshot).toHaveBeenCalledTimes(1); + }); + + test("bounds the complete blank-evidence inventory when many renderer probes are individually slow", async () => { + const delayedResult = (value) => new Promise((resolve) => setTimeout(() => resolve(value), 20)); + const identity = { + candidateVideoIds: ["abcdefghijk"], + elementRenderedInViewport: true, + hasReel: true, + reelRenderedInViewport: true, + videoMatches: true, + }; + const dom = { + actionBars: [], + renderedInViewport: true, + totalInteractiveCount: 0, + visibleActions: [], + }; + const reel = { + evaluate: jest.fn((reader) => delayedResult(reader === readShortsRendererIdentityState ? identity : dom)), + }; + const page = { + evaluate: jest.fn(async () => undefined), + locator: jest.fn(() => ({ count: jest.fn(async () => 20), nth: jest.fn(() => reel) })), + screenshot: jest.fn(async () => undefined), + setDefaultNavigationTimeout: jest.fn(), + setDefaultTimeout: jest.fn(), + url: () => "https://www.youtube.com/shorts/abcdefghijk", + }; + const driver = new LiveYoutubeDriver(page, {}); + const startedAt = Date.now(); + + await expect( + driver.captureBlankShortsDiagnostics("abcdefghijk", "blank.png", { + evidenceTimeoutMs: 30, + operationTimeoutMs: 100, + }), + ).rejects.toThrow("Timed out capturing blank Shorts diagnostics for abcdefghijk within 30ms"); + expect(Date.now() - startedAt).toBeLessThan(250); + expect(reel.evaluate.mock.calls.length).toBeLessThan(40); + expect(page.screenshot).not.toHaveBeenCalled(); + }); +}); + +describe("current live reaction target selection", () => { + function actionCandidate({ eligible = true, enabled = true, visible = true } = {}) { + return { + click: jest.fn(async () => {}), + evaluate: jest.fn(async (callback) => + callback === readElementActionability + ? { + centerHitTarget: true, + centerInViewport: true, + connected: true, + enabled, + visible, + } + : { + candidateVideoIds: eligible ? ["abcdefghijk"] : ["stalevid001"], + elementRenderedInViewport: eligible, + hasReel: true, + reelRenderedInViewport: eligible, + videoMatches: eligible, + }, + ), + isEnabled: jest.fn(async () => enabled), + isVisible: jest.fn(async () => visible), + }; + } + + test("scrolls a visible below-fold control in the exact destination Watch root before clicking it", async () => { + const hiddenDuplicate = actionCandidate({ visible: false }); + const disabledDuplicate = actionCandidate({ enabled: false }); + // `eligible: false` models the viewport/Shorts-identity probe returning + // false before a scroll. Watch controls must not be rejected by that probe: + // a short browser window commonly places the whole action row below fold. + const belowFoldCurrent = actionCandidate({ eligible: false }); + const current = actionCandidate(); + const candidates = [hiddenDuplicate, disabledDuplicate, belowFoldCurrent, current]; + const candidateLocator = { + count: jest.fn(async () => candidates.length), + nth: jest.fn((index) => candidates[index]), + }; + const watchRoot = { locator: jest.fn(() => candidateLocator) }; + const page = { + locator: jest.fn(() => watchRoot), + setDefaultNavigationTimeout: jest.fn(), + setDefaultTimeout: jest.fn(), + url: jest.fn(() => "https://www.youtube.com/watch?v=abcdefghijk"), + }; + const driver = new LiveYoutubeDriver(page, {}); + + await driver.clickDislike("abcdefghijk"); + + expect(page.locator).toHaveBeenCalledWith( + 'ytd-watch-flexy[video-id="abcdefghijk"], ytd-watch-grid[video-id="abcdefghijk"]', + ); + const actionSelector = watchRoot.locator.mock.calls[0][0]; + expect(actionSelector.split(", ").every((selector) => selector.endsWith(":visible"))).toBe(true); + expect(hiddenDuplicate.click).not.toHaveBeenCalled(); + expect(disabledDuplicate.click).not.toHaveBeenCalled(); + expect(belowFoldCurrent.evaluate).toHaveBeenCalledWith(scrollElementIntoViewAndWaitForPaint, undefined, { + timeout: 20_000, + }); + expect(belowFoldCurrent.evaluate).toHaveBeenCalledWith( + readElementActionability, + { includeHref: false, scroll: false }, + { timeout: 20_000 }, + ); + expect(belowFoldCurrent.click).toHaveBeenCalledWith({ force: true, timeout: 5_000 }); + expect(current.click).not.toHaveBeenCalled(); + }); + + test("rejects a visible wrong-video Shorts duplicate before clicking the exact current reel", async () => { + const wrongVideoDuplicate = actionCandidate({ eligible: false }); + const current = actionCandidate(); + const candidates = [wrongVideoDuplicate, current]; + const candidateLocator = { + count: jest.fn(async () => candidates.length), + nth: jest.fn((index) => candidates[index]), + }; + const page = { + locator: jest.fn(() => candidateLocator), + setDefaultNavigationTimeout: jest.fn(), + setDefaultTimeout: jest.fn(), + url: jest.fn(() => "https://www.youtube.com/shorts/abcdefghijk"), + }; + const driver = new LiveYoutubeDriver(page, {}); + + await driver.clickDislike("abcdefghijk"); + + const actionSelector = page.locator.mock.calls[0][0]; + expect(actionSelector.split(", ").every((selector) => selector.endsWith(":visible"))).toBe(true); + expect(wrongVideoDuplicate.evaluate).toHaveBeenCalledWith(readShortsRendererIdentityState, { + expectedShortVideoId: "abcdefghijk", + }); + expect(wrongVideoDuplicate.click).not.toHaveBeenCalled(); + expect(current.click).toHaveBeenCalledWith({ force: true, timeout: 5_000 }); + }); }); describe("readDislikeControlText", () => { @@ -254,6 +1233,1055 @@ describe("readDislikeControlText", () => { }); }); +describe("Shorts dislike-count response binding", () => { + test.each(["extension", "userscript"])("accepts every supported %s display format for the API value", (runtime) => { + const candidates = formattedDislikeCountCandidates(1_234, "en", runtime); + + expect(candidates).toEqual(expect.arrayContaining(["1.2K", "1.2 thousand", "1,234"])); + candidates.forEach((renderedCount) => { + expect(assertRenderedDislikeCountMatchesApi(renderedCount, 1_234, "en", runtime)).toMatchObject({ + normalizedCount: renderedCount, + }); + }); + }); + + test("normalizes localized spacing without accepting an unrelated stale number", () => { + const [compact] = formattedDislikeCountCandidates(12_345, "fr", "extension"); + + expect(() => + assertRenderedDislikeCountMatchesApi(compact.replace(/ /g, "\u00a0"), 12_345, "fr", "extension"), + ).not.toThrow(); + expect(() => assertRenderedDislikeCountMatchesApi("999", 12_345, "fr", "extension")).toThrow( + /does not represent API count 12345/, + ); + }); + + test.each(["extension", "userscript"])( + "requires +/-1 reaction targets whose formatted %s counts cannot remain stale", + (runtime) => { + expect( + assertDislikeCountChangesObservable( + [ + { after: 14, before: 13 }, + { after: 13, before: 14 }, + ], + "en", + runtime, + ), + ).toHaveLength(2); + expect(() => assertDislikeCountChangesObservable([{ after: 14_141, before: 14_140 }], "en", runtime)).toThrow( + /not observable.*Choose a low-count reaction target/, + ); + }, + ); +}); + +describe("Shorts /votes request audit", () => { + const exactRequest = { + method: "GET", + url: "https://returnyoutubedislikeapi.com/votes?videoId=abcdefghijk", + videoId: "abcdefghijk", + }; + + test("accepts exactly one request for the destination Short", () => { + expect(assertExactVotesRequestAudit([exactRequest], "abcdefghijk")).toBe(exactRequest); + }); + + test("rejects a stale request even when an exact destination request also exists", () => { + expect(() => + assertExactVotesRequestAudit([{ ...exactRequest, videoId: "stalevid001" }, exactRequest], "abcdefghijk"), + ).toThrow(/emitted a stale \/votes request/); + }); + + test("rejects duplicate exact destination requests", () => { + expect(() => assertExactVotesRequestAudit([exactRequest, { ...exactRequest }], "abcdefghijk")).toThrow( + /must emit exactly one \/votes request.*observed 2/, + ); + }); + + describe("exact navigation response oracle", () => { + const exactResponse = { + ...exactRequest, + requestId: 8, + responseBody: { dislikes: 123, id: "abcdefghijk", likes: 456 }, + responseStatus: 200, + source: "page", + }; + + test("accepts one new successful attributed request with the exact response video", () => { + expect(assertExactVotesResponseAudit([exactResponse], "abcdefghijk", 7)).toBe(exactResponse); + }); + + test("accepts exactly one ordered extension Like-count refinement and returns its response as authoritative", () => { + const refinedResponse = { + ...exactResponse, + requestId: 9, + responseBody: { ...exactResponse.responseBody, dislikes: 125 }, + url: "https://returnyoutubedislikeapi.com/votes?videoId=abcdefghijk&likeCount=48525", + }; + + expect( + assertExactVotesResponseAudit([exactResponse, refinedResponse], "abcdefghijk", 7, { + allowLikeCountRefinement: true, + }), + ).toBe(refinedResponse); + }); + + test.each([ + [ + "two aggregate requests", + [exactResponse, { ...exactResponse, requestId: 9 }], + /second \/votes request.*single Like-count refinement/, + ], + [ + "two enriched requests", + [ + { ...exactResponse, url: `${exactResponse.url}&likeCount=1` }, + { ...exactResponse, requestId: 9, url: `${exactResponse.url}&likeCount=2` }, + ], + /first \/votes request.*aggregate request before refinement/, + ], + [ + "more than one refinement", + [ + exactResponse, + { ...exactResponse, requestId: 9, url: `${exactResponse.url}&likeCount=1` }, + { ...exactResponse, requestId: 10, url: `${exactResponse.url}&likeCount=2` }, + ], + /one base request followed by one Like-count refinement.*observed 3/, + ], + [ + "non-numeric refinement", + [exactResponse, { ...exactResponse, requestId: 9, url: `${exactResponse.url}&likeCount=48K` }], + /invalid Like-count refinement/, + ], + [ + "a stale refinement video", + [ + exactResponse, + { + ...exactResponse, + requestId: 9, + url: "https://returnyoutubedislikeapi.com/votes?videoId=stalevid001&likeCount=48525", + videoId: "stalevid001", + }, + ], + /stale \/votes request/, + ], + ])("rejects extension refinement traffic with %s", (_label, records, expectedError) => { + expect(() => + assertExactVotesResponseAudit(records, "abcdefghijk", 7, { allowLikeCountRefinement: true }), + ).toThrow(expectedError); + }); + + test.each([ + ["duplicate request", [exactResponse, { ...exactResponse, requestId: 9 }], /exactly one \/votes request/], + [ + "stale request", + [{ ...exactResponse, requestId: 9, videoId: "stalevid001" }, exactResponse], + /stale \/votes request/, + ], + ["old request ID", [{ ...exactResponse, requestId: 7 }], /not created after request baseline 7/], + ["wrong status", [{ ...exactResponse, responseStatus: 503 }], /returned HTTP 503/], + [ + "wrong response ID", + [{ ...exactResponse, responseBody: { ...exactResponse.responseBody, id: "stalevid001" } }], + /returned video stalevid001 instead of abcdefghijk/, + ], + [ + "invalid response count", + [{ ...exactResponse, responseBody: { ...exactResponse.responseBody, dislikes: -1 } }], + /has no valid dislike count/, + ], + ])("rejects a %s", (_label, records, expectedError) => { + expect(() => assertExactVotesResponseAudit(records, "abcdefghijk", 7)).toThrow(expectedError); + }); + }); + + test("collects the complete per-hop window and rejects stale-plus-correct traffic", async () => { + const listeners = new Map(); + const 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); + }), + }; + const page = { setDefaultNavigationTimeout: jest.fn(), setDefaultTimeout: jest.fn() }; + const driver = new LiveYoutubeDriver(page, context); + const emitRequest = (videoId) => { + const request = { + frame: () => ({ page: () => page }), + method: () => "GET", + url: () => `https://returnyoutubedislikeapi.com/votes?videoId=${videoId}`, + }; + listeners.get("request").forEach((listener) => listener(request)); + }; + + await expect( + driver.withExactVotesRequest( + async () => { + emitRequest("stalevid001"); + emitRequest("abcdefghijk"); + return { videoId: "abcdefghijk" }; + }, + { quietMs: 0 }, + ), + ).rejects.toThrow(/emitted a stale \/votes request/); + expect(context.off).toHaveBeenCalledWith("request", expect.any(Function)); + expect(listeners.get("request").size).toBe(0); + }); + + test("baselines request IDs and returns the exact attributed response record", async () => { + const listeners = new Map(); + const 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); + }), + }; + const page = { setDefaultNavigationTimeout: jest.fn(), setDefaultTimeout: jest.fn() }; + const driver = new LiveYoutubeDriver(page, context); + const request = { + frame: () => ({ page: () => page }), + method: () => "GET", + url: () => "https://returnyoutubedislikeapi.com/votes?videoId=abcdefghijk", + }; + const response = { + json: jest.fn(async () => ({ dislikes: 123, id: "abcdefghijk", likes: 456 })), + request: () => request, + status: () => 200, + }; + + await expect( + driver.withExactVotesResponse( + "abcdefghijk", + async () => { + listeners.get("request").forEach((listener) => listener(request)); + listeners.get("response").forEach((listener) => listener(response)); + return "navigation-result"; + }, + { quietMs: 0 }, + ), + ).resolves.toEqual({ + body: { dislikes: 123, id: "abcdefghijk", likes: 456 }, + request: { + method: "GET", + requestId: 1, + source: "page", + url: "https://returnyoutubedislikeapi.com/votes?videoId=abcdefghijk", + videoId: "abcdefghijk", + workerUrl: null, + }, + result: "navigation-result", + status: 200, + videoId: "abcdefghijk", + }); + expect(context.off).toHaveBeenCalledWith("request", expect.any(Function)); + expect(context.off).toHaveBeenCalledWith("response", expect.any(Function)); + expect(response.json).toHaveBeenCalledTimes(1); + }); + + test("collects an extension base request plus one richer request and returns only the final response", async () => { + const listeners = new Map(); + const 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(() => []), + }; + const page = { setDefaultNavigationTimeout: jest.fn(), setDefaultTimeout: jest.fn() }; + const driver = new LiveYoutubeDriver(page, context, { + expectedBuildId: EXPECTED_BUILD_ID, + selectedExtensionId: SELECTED_EXTENSION_ID, + }); + driver.configureRequestAttributionRuntime("extension"); + const createExchange = (url, dislikes) => { + const request = { + frame: () => ({ page: () => page }), + headers: () => ({ + accept: `application/json, application/vnd.ryd-live+json; id=${SELECTED_EXTENSION_ID}; build=${EXPECTED_BUILD_ID}`, + }), + method: () => "GET", + url: () => url, + }; + return { + request, + response: { + json: jest.fn(async () => ({ dislikes, id: "abcdefghijk", likes: 48_525 })), + request: () => request, + status: () => 200, + }, + }; + }; + const base = createExchange("https://returnyoutubedislikeapi.com/votes?videoId=abcdefghijk", 123); + const refined = createExchange( + "https://returnyoutubedislikeapi.com/votes?videoId=abcdefghijk&likeCount=48525", + 125, + ); + + await expect( + driver.withExactVotesResponse( + "abcdefghijk", + async () => { + for (const exchange of [base, refined]) { + listeners.get("request").forEach((listener) => listener(exchange.request)); + listeners.get("response").forEach((listener) => listener(exchange.response)); + } + }, + { quietMs: 0 }, + ), + ).resolves.toMatchObject({ + body: { dislikes: 125, id: "abcdefghijk", likes: 48_525 }, + request: { + requestId: 2, + url: "https://returnyoutubedislikeapi.com/votes?videoId=abcdefghijk&likeCount=48525", + }, + status: 200, + }); + expect(base.response.json).toHaveBeenCalledTimes(1); + expect(refined.response.json).toHaveBeenCalledTimes(1); + }); + + test("attributes a frame-less request only to the selected extension worker", () => { + const page = {}; + const request = { + frame: () => { + throw new Error("Service Worker requests do not have a frame"); + }, + serviceWorker: () => ({ url: () => `chrome-extension://${SELECTED_EXTENSION_ID}/ryd.background.js` }), + }; + + expect(attributeRuntimeRequest(request, page, "extension", SELECTED_EXTENSION_ID)).toEqual({ + source: "service-worker", + workerUrl: `chrome-extension://${SELECTED_EXTENSION_ID}/ryd.background.js`, + }); + expect(attributeRuntimeRequest(request, page, "userscript")).toBeNull(); + expect( + attributeRuntimeRequest( + { + ...request, + serviceWorker: () => ({ url: () => `chrome-extension://${SELECTED_EXTENSION_ID}/unrelated-worker.js` }), + }, + page, + "extension", + SELECTED_EXTENSION_ID, + ), + ).toBeNull(); + }); + + test("does not attribute an otherwise identical /votes request from a competing extension worker", () => { + const page = {}; + const request = { + frame: () => { + throw new Error("Service Worker requests do not have a frame"); + }, + serviceWorker: () => ({ url: () => `chrome-extension://${COMPETING_EXTENSION_ID}/ryd.background.js` }), + }; + + expect(attributeRuntimeRequest(request, page, "extension", SELECTED_EXTENSION_ID)).toBeNull(); + }); + + test("parses only the exact live-build Accept fingerprint", () => { + expect(parseLiveExtensionAcceptHeader(LIVE_ACCEPT_HEADER)).toEqual({ + buildId: EXPECTED_BUILD_ID, + extensionId: SELECTED_EXTENSION_ID, + }); + expect(parseLiveExtensionAcceptHeader(`${LIVE_ACCEPT_HEADER}; extra=true`)).toBeNull(); + expect(parseLiveExtensionAcceptHeader(LIVE_ACCEPT_HEADER.toUpperCase())).toBeNull(); + expect(parseLiveExtensionAcceptHeader("application/json")).toBeNull(); + }); + + test("attributes an extension page-framed GET only with the exact extension and build fingerprint", () => { + const page = {}; + const createRequest = (accept = LIVE_ACCEPT_HEADER) => ({ + frame: () => ({ page: () => page }), + headers: () => ({ Accept: accept }), + method: () => "GET", + }); + + expect( + attributeRuntimeRequest(createRequest(), page, "extension", SELECTED_EXTENSION_ID, EXPECTED_BUILD_ID), + ).toEqual({ source: "page", workerUrl: null }); + expect( + attributeRuntimeRequest( + createRequest(LIVE_ACCEPT_HEADER.replace(SELECTED_EXTENSION_ID, COMPETING_EXTENSION_ID)), + page, + "extension", + SELECTED_EXTENSION_ID, + EXPECTED_BUILD_ID, + ), + ).toBeNull(); + expect( + attributeRuntimeRequest( + createRequest(LIVE_ACCEPT_HEADER.replace(EXPECTED_BUILD_ID, "f".repeat(32))), + page, + "extension", + SELECTED_EXTENSION_ID, + EXPECTED_BUILD_ID, + ), + ).toBeNull(); + expect( + attributeRuntimeRequest( + createRequest("application/json"), + page, + "extension", + SELECTED_EXTENSION_ID, + EXPECTED_BUILD_ID, + ), + ).toBeNull(); + }); + + test("rejects a page-framed POST even when it spoofs the complete extension fingerprint", () => { + const page = {}; + const request = { + frame: () => ({ page: () => page }), + headers: () => ({ accept: LIVE_ACCEPT_HEADER }), + method: () => "POST", + }; + + expect(attributeRuntimeRequest(request, page, "extension", SELECTED_EXTENSION_ID, EXPECTED_BUILD_ID)).toBeNull(); + expect(attributeRuntimeRequest(request, page, "userscript")).toEqual({ source: "page", workerUrl: null }); + }); + + test("fails closed when extension request attribution has no selected extension ID", () => { + const page = { setDefaultNavigationTimeout: jest.fn(), setDefaultTimeout: jest.fn() }; + const driver = new LiveYoutubeDriver(page, {}); + + expect(() => driver.configureRequestAttributionRuntime("extension")).toThrow( + /requires the exact selected extension ID/, + ); + }); + + test("fails closed when extension request attribution has no exact live build ID", () => { + const page = { setDefaultNavigationTimeout: jest.fn(), setDefaultTimeout: jest.fn() }; + const context = { on: jest.fn() }; + const driver = new LiveYoutubeDriver(page, context, { selectedExtensionId: SELECTED_EXTENSION_ID }); + + expect(() => driver.configureRequestAttributionRuntime("extension")).toThrow(/requires the exact live build ID/); + }); + + test("does not attribute a request owned by another page", () => { + const page = {}; + const otherPage = {}; + const request = { + frame: () => ({ page: () => otherPage }), + serviceWorker: () => ({ url: () => `chrome-extension://${SELECTED_EXTENSION_ID}/ryd.background.js` }), + }; + + expect(attributeRuntimeRequest(request, page, "extension", SELECTED_EXTENSION_ID)).toBeNull(); + }); + + test("rejects ambiguous worker traffic when another tab has the same video open", () => { + const page = { url: () => "https://www.youtube.com/shorts/abcdefghijk" }; + const otherPage = { url: () => "https://www.youtube.com/watch?v=abcdefghijk" }; + const requests = [{ source: "service-worker", videoId: "abcdefghijk" }]; + + expect(() => + assertWorkerRequestAttributionIsUnambiguous({ pages: () => [page, otherPage] }, page, requests, "abcdefghijk"), + ).toThrow(/another tab has the same video open/); + }); + + test("captures the selected extension worker throughout the full per-hop request window", async () => { + const listeners = new Map(); + const page = { + setDefaultNavigationTimeout: jest.fn(), + setDefaultTimeout: jest.fn(), + url: () => "https://www.youtube.com/shorts/abcdefghijk", + }; + const 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: () => [page], + }; + const reportProgress = jest.fn(); + const driver = new LiveYoutubeDriver(page, context, { + expectedBuildId: EXPECTED_BUILD_ID, + reportProgress, + selectedExtensionId: SELECTED_EXTENSION_ID, + }); + driver.configureRequestAttributionRuntime("extension"); + 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", + }; + + await expect( + driver.withExactVotesRequest( + async () => { + listeners.get("request").forEach((listener) => listener(request)); + return { videoId: "abcdefghijk" }; + }, + { quietMs: 0 }, + ), + ).resolves.toEqual({ videoId: "abcdefghijk" }); + expect(reportProgress).toHaveBeenCalledWith( + "shorts-votes-request-audit.confirmed", + expect.objectContaining({ source: "service-worker", videoId: "abcdefghijk" }), + ); + }); + + test("cannot satisfy the exact /votes oracle with a competing extension worker", async () => { + const listeners = new Map(); + const page = { + setDefaultNavigationTimeout: jest.fn(), + setDefaultTimeout: jest.fn(), + url: () => "https://www.youtube.com/shorts/abcdefghijk", + }; + const 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: () => [page], + }; + const driver = new LiveYoutubeDriver(page, context, { + expectedBuildId: EXPECTED_BUILD_ID, + selectedExtensionId: SELECTED_EXTENSION_ID, + }); + driver.configureRequestAttributionRuntime("extension"); + const competingRequest = { + frame: () => { + throw new Error("Service Worker requests do not have a frame"); + }, + method: () => "GET", + serviceWorker: () => ({ url: () => `chrome-extension://${COMPETING_EXTENSION_ID}/ryd.background.js` }), + url: () => "https://returnyoutubedislikeapi.com/votes?videoId=abcdefghijk", + }; + + await expect( + driver.withExactVotesRequest( + async () => { + listeners.get("request").forEach((listener) => listener(competingRequest)); + return { videoId: "abcdefghijk" }; + }, + { quietMs: 0 }, + ), + ).rejects.toThrow(/must emit exactly one \/votes request.*observed 0/); + }); + + test("cannot satisfy the extension /votes oracle with an ordinary same-page GET", async () => { + const listeners = new Map(); + const page = { + setDefaultNavigationTimeout: jest.fn(), + setDefaultTimeout: jest.fn(), + url: () => "https://www.youtube.com/shorts/abcdefghijk", + }; + const 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: () => [page], + }; + const driver = new LiveYoutubeDriver(page, context, { + expectedBuildId: EXPECTED_BUILD_ID, + selectedExtensionId: SELECTED_EXTENSION_ID, + }); + driver.configureRequestAttributionRuntime("extension"); + const pageSpoof = { + frame: () => ({ page: () => page }), + headers: () => ({ accept: "application/json" }), + method: () => "GET", + url: () => "https://returnyoutubedislikeapi.com/votes?videoId=abcdefghijk", + }; + + await expect( + driver.withExactVotesRequest( + async () => { + listeners.get("request").forEach((listener) => listener(pageSpoof)); + return { videoId: "abcdefghijk" }; + }, + { quietMs: 0 }, + ), + ).rejects.toThrow(/must emit exactly one \/votes request.*observed 0/); + driver.stop(); + }); + + test("allows no /votes request only when the current Short stayed fully blank for the native-control window", async () => { + const listeners = new Map(); + const page = { + setDefaultNavigationTimeout: jest.fn(), + setDefaultTimeout: jest.fn(), + url: () => "https://www.youtube.com/shorts/abcdefghijk", + }; + const 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); + }), + }; + const driver = new LiveYoutubeDriver(page, context); + driver.configureRequestAttributionRuntime("userscript"); + driver.waitForCurrentShortsNativeControls = jest.fn(async () => ({ + currentVideoId: "abcdefghijk", + observedForMs: 20_000, + reason: "no-visible-native-shorts-actions", + status: "blank", + visibleNativeControls: 0, + })); + + await expect( + driver.withShortsSampleVotesResponse(async () => ({ videoId: "abcdefghijk" }), { quietMs: 0 }), + ).resolves.toMatchObject({ + body: null, + nativeControls: { + observedForMs: 20_000, + reason: "no-visible-native-shorts-actions", + status: "blank", + }, + request: null, + status: null, + videoId: "abcdefghijk", + }); + expect(context.off).toHaveBeenCalledWith("request", expect.any(Function)); + expect(context.off).toHaveBeenCalledWith("response", expect.any(Function)); + }); + + test("still rejects stale /votes traffic when YouTube rendered no native Shorts controls", async () => { + const listeners = new Map(); + const page = { + setDefaultNavigationTimeout: jest.fn(), + setDefaultTimeout: jest.fn(), + url: () => "https://www.youtube.com/shorts/abcdefghijk", + }; + const 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); + }), + }; + const request = { + frame: () => ({ page: () => page }), + method: () => "GET", + url: () => "https://returnyoutubedislikeapi.com/votes?videoId=stalevid001", + }; + const response = { + json: jest.fn(async () => ({ dislikes: 123, id: "stalevid001", likes: 456 })), + request: () => request, + status: () => 200, + }; + const driver = new LiveYoutubeDriver(page, context); + driver.configureRequestAttributionRuntime("userscript"); + driver.waitForCurrentShortsNativeControls = jest.fn(async () => ({ + currentVideoId: "abcdefghijk", + observedForMs: 20_000, + reason: "no-visible-native-shorts-actions", + status: "blank", + visibleNativeControls: 0, + })); + + await expect( + driver.withShortsSampleVotesResponse( + async () => { + listeners.get("request").forEach((listener) => listener(request)); + listeners.get("response").forEach((listener) => listener(response)); + return { videoId: "abcdefghijk" }; + }, + { quietMs: 0 }, + ), + ).rejects.toThrow("emitted a stale /votes request while targeting abcdefghijk"); + }); + + test("does not forgive a missing /votes request once any native Shorts action is visible", async () => { + const listeners = new Map(); + const page = { + setDefaultNavigationTimeout: jest.fn(), + setDefaultTimeout: jest.fn(), + url: () => "https://www.youtube.com/shorts/abcdefghijk", + }; + const 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); + }), + }; + const driver = new LiveYoutubeDriver(page, context); + driver.configureRequestAttributionRuntime("userscript"); + driver.waitForCurrentShortsNativeControls = jest.fn(async () => ({ + currentVideoId: "abcdefghijk", + observedForMs: 1, + status: "present", + visibleNativeControls: 1, + })); + + await expect( + driver.withShortsSampleVotesResponse(async () => ({ videoId: "abcdefghijk" }), { + quietMs: 0, + trafficTimeoutMs: 0, + }), + ).rejects.toThrow("Timed out waiting for new attributed production /votes traffic for Short abcdefghijk"); + }); + + test("navigateToNextShort consumes a frame-less response from the selected extension worker", async () => { + const previousVideoId = "oldvideo001"; + const nextVideoId = "nextvideo01"; + let currentUrl = `https://www.youtube.com/shorts/${previousVideoId}`; + let finishNavigation; + const listeners = new Map(); + 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=${nextVideoId}`, + }; + const response = { + json: jest.fn(async () => ({ dislikes: 321, id: nextVideoId, likes: 456 })), + request: () => request, + status: () => 200, + url: () => `https://returnyoutubedislikeapi.com/votes?videoId=${nextVideoId}`, + }; + const nextButton = { + click: jest.fn(async () => { + currentUrl = `https://www.youtube.com/shorts/${nextVideoId}`; + listeners.get("request").forEach((listener) => listener(request)); + listeners.get("response").forEach((listener) => listener(response)); + finishNavigation?.(); + }), + evaluate: jest.fn(async (callback) => + callback === readElementActionability + ? { + centerHitTarget: true, + centerInViewport: true, + connected: true, + enabled: true, + visible: true, + } + : { + elementRenderedInViewport: true, + hasReel: false, + reelRenderedInViewport: false, + videoMatches: false, + }, + ), + isVisible: jest.fn(async () => true), + }; + const page = { + evaluate: jest.fn(async () => "document-marker"), + locator: jest.fn(() => ({ + count: jest.fn(async () => 1), + nth: jest.fn(() => nextButton), + })), + setDefaultNavigationTimeout: jest.fn(), + setDefaultTimeout: jest.fn(), + url: jest.fn(() => currentUrl), + waitForURL: jest.fn( + (predicate) => + new Promise((resolve) => { + finishNavigation = () => { + if (predicate(new URL(currentUrl))) resolve(); + }; + }), + ), + }; + const 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: () => [page], + }; + const driver = new LiveYoutubeDriver(page, context, { + expectedBuildId: EXPECTED_BUILD_ID, + selectedExtensionId: SELECTED_EXTENSION_ID, + }); + driver.configureRequestAttributionRuntime("extension"); + driver.waitForCurrentShortsNativeControls = jest.fn(async () => ({ + currentVideoId: nextVideoId, + observedForMs: 10, + status: "present", + visibleNativeControls: 5, + })); + + await expect(driver.navigateToNextShort(previousVideoId, { quietMs: 0 })).resolves.toMatchObject({ + body: { dislikes: 321, id: nextVideoId, likes: 456 }, + nativeControls: { status: "present", visibleNativeControls: 5 }, + request: { + method: "GET", + source: "service-worker", + videoId: nextVideoId, + }, + status: 200, + videoId: nextVideoId, + }); + expect(response.json).toHaveBeenCalledTimes(1); + expect(nextButton.click).toHaveBeenCalledWith({ force: true, timeout: 5_000 }); + expect(context.off).toHaveBeenCalledWith("request", expect.any(Function)); + expect(context.off).toHaveBeenCalledWith("response", expect.any(Function)); + }); +}); + +describe("production interaction runtime attribution", () => { + function createRecorderHarness(runtime, selectedExtensionId = null) { + const listeners = new Map(); + const page = { url: () => "https://www.youtube.com/watch?v=abcdefghijk" }; + const 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: () => [page], + }; + const recorder = new VoteTrafficRecorder(context, "abcdefghijk", { + handshakeTimeout: 25, + page, + runtime, + selectedExtensionId, + }); + return { context, listeners, page, recorder }; + } + + function interactionRequest(pathname, body, { page = null, workerId = null } = {}) { + return { + frame: () => { + if (page) return { page: () => page }; + throw new Error("Service Worker requests do not have a frame"); + }, + method: () => "POST", + postDataJSON: () => body, + serviceWorker: () => (workerId ? { url: () => `chrome-extension://${workerId}/ryd.background.js` } : null), + url: () => `https://returnyoutubedislikeapi.com${pathname}`, + }; + } + + function emitRequest(listeners, request) { + listeners.get("request").forEach((listener) => listener(request)); + } + + test("requires an exact selected extension ID before recording a background vote", () => { + const context = { on: jest.fn() }; + + expect( + () => + new VoteTrafficRecorder(context, "abcdefghijk", { + page: {}, + runtime: "extension", + }), + ).toThrow(/requires the exact selected extension ID/); + }); + + test("ignores a complete matching handshake from a competing extension worker", async () => { + const { listeners, recorder } = createRecorderHarness("extension", SELECTED_EXTENSION_ID); + const body = { userId: "user-1", value: -1, videoId: "abcdefghijk" }; + emitRequest(listeners, interactionRequest("/interact/vote", body, { workerId: COMPETING_EXTENSION_ID })); + emitRequest(listeners, interactionRequest("/interact/confirmVote", body, { workerId: COMPETING_EXTENSION_ID })); + + expect(recorder.mark()).toBe(0); + expect(recorder.hasVote(-1, 0)).toBe(false); + await expect(recorder.waitForHandshake(-1, 0)).rejects.toThrow( + /Timed out waiting for the production vote handshake/, + ); + recorder.stop(); + }); + + test("ignores a complete page-framed handshake that spoofs the selected extension fingerprint", async () => { + const { listeners, page, recorder } = createRecorderHarness("extension", SELECTED_EXTENSION_ID); + const userId = "A".repeat(36); + const vote = interactionRequest("/interact/vote", { userId, value: -1, videoId: "abcdefghijk" }, { page }); + const confirmation = interactionRequest( + "/interact/confirmVote", + { solution: Buffer.alloc(4).toString("base64"), userId, videoId: "abcdefghijk" }, + { page }, + ); + vote.headers = confirmation.headers = () => ({ accept: LIVE_ACCEPT_HEADER }); + + emitRequest(listeners, vote); + emitRequest(listeners, confirmation); + + expect(recorder.mark()).toBe(0); + expect(recorder.hasVote(-1, 0)).toBe(false); + await expect(recorder.waitForHandshake(-1, 0)).rejects.toThrow( + /Timed out waiting for the production vote handshake/, + ); + recorder.stop(); + }); + + test("records only the selected extension worker when a competitor emits the same vote", () => { + const { listeners, recorder } = createRecorderHarness("extension", SELECTED_EXTENSION_ID); + const body = { userId: "user-1", value: -1, videoId: "abcdefghijk" }; + const competing = interactionRequest("/interact/vote", body, { workerId: COMPETING_EXTENSION_ID }); + const selected = interactionRequest("/interact/vote", body, { workerId: SELECTED_EXTENSION_ID }); + + emitRequest(listeners, competing); + emitRequest(listeners, selected); + + expect(recorder.mark()).toBe(1); + expect(recorder.records).toEqual([ + expect.objectContaining({ + body, + source: "service-worker", + workerUrl: `chrome-extension://${SELECTED_EXTENSION_ID}/ryd.background.js`, + }), + ]); + recorder.stop(); + }); + + test("binds userscript interaction traffic to the selected page", () => { + const { listeners, page, recorder } = createRecorderHarness("userscript"); + const body = { userId: "user-1", value: -1, videoId: "abcdefghijk" }; + emitRequest(listeners, interactionRequest("/interact/vote", body, { page: {} })); + emitRequest(listeners, interactionRequest("/interact/vote", body, { page })); + + expect(recorder.mark()).toBe(1); + expect(recorder.records[0]).toEqual(expect.objectContaining({ body, source: "page", workerUrl: null })); + recorder.stop(); + }); +}); + +describe("current Shorts control ownership audit", () => { + function createDriver({ + blockedAction = null, + extraAction = false, + hiddenDuplicateInCurrentReel = false, + visibleStaleControl = false, + } = {}) { + document.body.innerHTML = ` + + + +
+ + 123 +
+
+
+
+
+ ${extraAction ? '
' : ""} + ${ + hiddenDuplicateInCurrentReel + ? '' + : "" + } +
+
+ ${ + visibleStaleControl + ? ` +
+
` + : "" + } + `; + const buttons = [...document.querySelectorAll("button")]; + buttons.forEach((element, index) => { + const top = 40 + index * 52; + element.getBoundingClientRect = jest.fn(() => ({ + bottom: top + 48, + height: 48, + left: 900, + right: 948, + top, + width: 48, + })); + }); + document.querySelectorAll("[data-ryd-synthetic-shorts-dislike]").forEach((element) => { + element.getBoundingClientRect = jest.fn(() => ({ + bottom: 140, + height: 100, + left: 900, + right: 948, + top: 40, + width: 48, + })); + }); + document.elementFromPoint = jest.fn((x, y) => { + const target = buttons.find((element) => { + const rect = element.getBoundingClientRect(); + return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom; + }); + return target?.getAttribute("aria-label") === blockedAction ? document.body : target ?? null; + }); + + const button = document.querySelector( + 'ytd-reel-video-renderer[video-id="abcdefghijk"] [data-ryd-synthetic-shorts-dislike] button', + ); + const page = { + evaluate: jest.fn(async (callback) => callback()), + setDefaultNavigationTimeout: jest.fn(), + setDefaultTimeout: jest.fn(), + url: jest.fn(() => "https://www.youtube.com/shorts/abcdefghijk"), + }; + const driver = new LiveYoutubeDriver(page, {}); + driver.visibleDislikeButton = jest.fn(async () => ({ + evaluate: async (callback, argument) => callback(button, argument), + })); + return driver; + } + + test("accepts exactly one current synthetic control in the active reel and viewport", async () => { + const driver = createDriver(); + + await expect(driver.assertCurrentShortsControl("abcdefghijk", "extension")).resolves.toMatchObject({ + count: "123", + synthetic: true, + videoId: "abcdefghijk", + hitTestedActionButtons: 6, + visibleActionButtons: 6, + }); + }); + + test("accepts a seventh optional Shorts action when every action remains visible and hit-testable", async () => { + const driver = createDriver({ extraAction: true }); + + await expect(driver.assertCurrentShortsControl("abcdefghijk", "extension")).resolves.toMatchObject({ + hitTestedActionButtons: 7, + visibleActionButtons: 7, + }); + }); + + test("rejects a visible Shorts action covered by another layer", async () => { + const driver = createDriver({ blockedAction: "Share" }); + + await expect(driver.assertCurrentShortsControl("abcdefghijk", "userscript")).rejects.toThrow( + "Every visible Shorts action must be hit-testable at its center; blocked controls: Share", + ); + }); + + test("rejects a hidden duplicate retained inside the active reel", async () => { + const driver = createDriver({ hiddenDuplicateInCurrentReel: true }); + + await expect(driver.assertCurrentShortsControl("abcdefghijk", "userscript")).rejects.toThrow( + "current Shorts action stack must contain exactly one userscript synthetic dislike control", + ); + }); + + test("rejects a visible synthetic control owned by a stale pre-rendered reel", async () => { + const driver = createDriver({ visibleStaleControl: true }); + + await expect(driver.assertCurrentShortsControl("abcdefghijk", "extension")).rejects.toThrow( + "viewport contains a visible extension synthetic dislike control for a stale video ID", + ); + }); + + test("rejects a stale numeric control count that does not represent the current API response", async () => { + const driver = createDriver(); + + await expect( + driver.assertCurrentShortsControl("abcdefghijk", "extension", { expectedDislikes: 456 }), + ).rejects.toThrow(/Rendered dislike count "123" does not represent API count 456/); + }); +}); + describe("relatedWatchVideoId", () => { const settings = { currentVideoId: "abcdefghijk", @@ -339,6 +2367,837 @@ describe("firstVisibleRelatedWatchLink", () => { }); }); +describe("cold channel tab targeting", () => { + test.each([ + ["https://www.youtube.com/@channel", "short", "https://www.youtube.com/@channel/shorts"], + ["https://www.youtube.com/@channel/featured", "watch", "https://www.youtube.com/@channel/videos"], + ["https://www.youtube.com/@channel/videos", "short", "https://www.youtube.com/@channel/shorts"], + ["https://www.youtube.com/@channel/shorts", "watch", "https://www.youtube.com/@channel/videos"], + ])("derives the relevant tab from %s for a %s target", (channelUrl, kind, expected) => { + expect(channelTabUrl(channelUrl, kind).toString()).toBe(expected); + }); + + test.each([ + ["https://www.youtube.com/shorts/targetvid01", "short", true], + ["https://www.youtube.com/watch?v=targetvid01", "watch", true], + ["https://www.youtube.com/watch?v=targetvid01", "short", false], + ["https://www.youtube.com/shorts/targetvid01", "watch", false], + ["https://example.com/watch?v=targetvid01", "watch", false], + ])("matches only the exact %s destination for %s", (url, kind, expected) => { + expect( + isExactVideoUrl(url, { + kind, + origin: "https://www.youtube.com", + videoId: "targetvid01", + }), + ).toBe(expected); + }); +}); + +describe("hit-tested navigation link activation", () => { + let originalElementFromPoint; + + function makeLink() { + const link = document.createElement("a"); + const title = document.createElement("span"); + link.href = "/watch?v=targetvid01"; + title.textContent = "Related video"; + link.append(title); + link.scrollIntoView = jest.fn(); + link.getBoundingClientRect = jest.fn(() => ({ + bottom: 140, + height: 40, + left: 100, + right: 300, + top: 100, + width: 200, + x: 100, + y: 100, + })); + document.body.append(link); + return { link, title }; + } + + beforeEach(() => { + originalElementFromPoint = document.elementFromPoint; + }); + + afterEach(() => { + document.body.replaceChildren(); + Object.defineProperty(document, "elementFromPoint", { + configurable: true, + value: originalElementFromPoint, + writable: true, + }); + }); + + test("accepts a visible enabled in-viewport link whose center hits a descendant", () => { + const { link, title } = makeLink(); + Object.defineProperty(document, "elementFromPoint", { + configurable: true, + value: jest.fn(() => title), + writable: true, + }); + + const state = readNavigationLinkActionability(link); + + expect(assertNavigationLinkActionable(state, "related link")).toBe(state); + expect(state).toMatchObject({ + center: { x: 200, y: 120 }, + centerHitTarget: true, + centerInViewport: true, + connected: true, + enabled: true, + href: "/watch?v=targetvid01", + visible: true, + }); + expect(link.scrollIntoView).toHaveBeenCalledWith({ block: "center", inline: "nearest" }); + }); + + test("rejects an independently visible link when another element covers its center", () => { + const { link } = makeLink(); + const obstruction = document.createElement("div"); + document.body.append(obstruction); + Object.defineProperty(document, "elementFromPoint", { + configurable: true, + value: jest.fn(() => obstruction), + writable: true, + }); + + expect(() => assertNavigationLinkActionable(readNavigationLinkActionability(link), "related link")).toThrow( + /center is covered by another element/, + ); + }); + + test("uses a force click after independent checks instead of entering the perpetual stability waiter", async () => { + const { link, title } = makeLink(); + Object.defineProperty(document, "elementFromPoint", { + configurable: true, + value: jest.fn(() => title), + writable: true, + }); + const locator = { + click: jest.fn(async (options) => { + if (!options?.force) throw new Error("simulated Playwright stability timeout"); + }), + evaluate: jest.fn(async (callback, argument) => callback(link, argument)), + }; + + await expect( + clickHitTestedNavigationLink(locator, { + expectedKind: "watch", + expectedVideoId: "targetvid01", + label: "related link", + timeout: 250, + }), + ).resolves.toMatchObject({ centerHitTarget: true, enabled: true, visible: true }); + + expect(locator.evaluate).toHaveBeenNthCalledWith(1, scrollElementIntoViewAndWaitForPaint, undefined, { + timeout: 250, + }); + expect(locator.evaluate).toHaveBeenNthCalledWith( + 2, + readElementActionability, + { includeHref: true, scroll: false }, + { timeout: 250 }, + ); + expect(locator.click).toHaveBeenCalledTimes(1); + expect(locator.click).toHaveBeenCalledWith({ force: true, timeout: 250 }); + }); + + test("rejects a link whose target changed after selection without clicking it", async () => { + const { link, title } = makeLink(); + link.href = "/watch?v=stalevideo1"; + Object.defineProperty(document, "elementFromPoint", { + configurable: true, + value: jest.fn(() => title), + writable: true, + }); + const locator = { + click: jest.fn(), + evaluate: jest.fn(async (callback, argument) => callback(link, argument)), + }; + + await expect( + clickHitTestedNavigationLink(locator, { + expectedKind: "watch", + expectedVideoId: "targetvid01", + label: "related link", + }), + ).rejects.toThrow(/changed to another Watch video/); + expect(locator.click).not.toHaveBeenCalled(); + }); + + test("re-resolves an exact channel target when the first visible duplicate is aria-hidden", async () => { + const hidden = makeLink(); + const actionable = makeLink(); + const hiddenContainer = document.createElement("div"); + hiddenContainer.setAttribute("aria-hidden", "true"); + hiddenContainer.append(hidden.link); + document.body.prepend(hiddenContainer); + actionable.link.getBoundingClientRect.mockReturnValue({ + bottom: 140, + height: 40, + left: 400, + right: 600, + top: 100, + width: 200, + x: 400, + y: 100, + }); + Object.defineProperty(document, "elementFromPoint", { + configurable: true, + value: jest.fn((x) => (x < 400 ? hidden.title : actionable.title)), + writable: true, + }); + const candidates = [hidden, actionable].map(({ link }) => ({ + click: jest.fn(), + evaluate: jest.fn(async (callback, argument) => callback(link, argument)), + isVisible: jest.fn(async () => true), + })); + candidates[1].click.mockResolvedValue(undefined); + const collection = { + count: jest.fn(async () => candidates.length), + nth: jest.fn((index) => candidates[index]), + }; + const page = { + locator: jest.fn(() => collection), + url: jest.fn(() => "https://www.youtube.com/@channel"), + }; + + await expect( + clickStabilizedExactVideoLink(page, "targetvid01", "watch", { pollInterval: 0, timeout: 100 }), + ).resolves.toMatchObject({ centerHitTarget: true, visible: true }); + + expect(candidates[0].click).not.toHaveBeenCalled(); + expect(candidates[1].click).toHaveBeenCalledTimes(1); + expect(candidates[1].click).toHaveBeenCalledWith({ force: true, timeout: expect.any(Number) }); + }); + + test("retries the freshly resolved exact channel target while hydration opacity settles", async () => { + const { link, title } = makeLink(); + const container = document.createElement("div"); + container.style.opacity = "0"; + container.append(link); + document.body.append(container); + Object.defineProperty(document, "elementFromPoint", { + configurable: true, + value: jest.fn(() => title), + writable: true, + }); + let actionabilityReads = 0; + const candidate = { + click: jest.fn(async () => undefined), + evaluate: jest.fn(async (callback, argument) => { + const result = callback(link, argument); + if (callback === readElementActionability) { + actionabilityReads += 1; + if (actionabilityReads === 1) container.style.opacity = "1"; + } + return result; + }), + isVisible: jest.fn(async () => true), + }; + const collection = { + count: jest.fn(async () => 1), + nth: jest.fn(() => candidate), + }; + const page = { + locator: jest.fn(() => collection), + url: jest.fn(() => "https://www.youtube.com/@channel"), + }; + + await expect( + clickStabilizedExactVideoLink(page, "targetvid01", "watch", { pollInterval: 0, timeout: 100 }), + ).resolves.toMatchObject({ centerHitTarget: true, visible: true }); + + expect(actionabilityReads).toBe(2); + expect(collection.count).toHaveBeenCalledTimes(1); + expect(candidate.click).toHaveBeenCalledTimes(1); + }); + + test("waits for a transient channel-header avatar overlay to clear without scrolling the exact Short again", async () => { + const { link, title } = makeLink(); + link.href = "/shorts/targetvid01"; + const headerAvatarOverlay = document.createElement("div"); + headerAvatarOverlay.className = "ytSpecAvatarShapeImageOverlays ytSpecAvatarShapeImage"; + document.body.append(headerAvatarOverlay); + let hitReadsSinceScroll = 0; + link.scrollIntoView.mockImplementation(() => { + hitReadsSinceScroll = 0; + }); + Object.defineProperty(document, "elementFromPoint", { + configurable: true, + value: jest.fn(() => (++hitReadsSinceScroll === 1 ? headerAvatarOverlay : title)), + writable: true, + }); + const candidate = { + click: jest.fn(async () => undefined), + evaluate: jest.fn(async (callback, argument) => callback(link, argument)), + isVisible: jest.fn(async () => true), + }; + const collection = { + count: jest.fn(async () => 1), + nth: jest.fn(() => candidate), + }; + const page = { + locator: jest.fn(() => collection), + url: jest.fn(() => "https://www.youtube.com/@channel"), + }; + + await expect( + clickStabilizedExactVideoLink(page, "targetvid01", "short", { pollInterval: 0, timeout: 250 }), + ).resolves.toMatchObject({ centerHitTarget: true, href: "/shorts/targetvid01" }); + + expect(link.scrollIntoView).toHaveBeenCalledTimes(1); + expect(document.elementFromPoint).toHaveBeenCalledTimes(2); + expect(candidate.click).toHaveBeenCalledTimes(1); + expect(candidate.click).toHaveBeenCalledWith({ force: true, timeout: expect.any(Number) }); + }); + + test("cold channel-to-Watch navigation does not retain the preliminary hidden duplicate", async () => { + const hidden = makeLink(); + const actionable = makeLink(); + const hiddenContainer = document.createElement("div"); + hiddenContainer.setAttribute("aria-hidden", "true"); + hiddenContainer.append(hidden.link); + document.body.prepend(hiddenContainer); + actionable.link.getBoundingClientRect.mockReturnValue({ + bottom: 140, + height: 40, + left: 400, + right: 600, + top: 100, + width: 200, + x: 400, + y: 100, + }); + Object.defineProperty(document, "elementFromPoint", { + configurable: true, + value: jest.fn((x) => (x < 400 ? hidden.title : actionable.title)), + writable: true, + }); + let currentUrl = "https://www.youtube.com/@channel"; + const candidates = [hidden, actionable].map(({ link }, index) => ({ + click: jest.fn(async () => { + if (index === 1) currentUrl = "https://www.youtube.com/watch?v=targetvid01"; + }), + evaluate: jest.fn(async (callback, argument) => callback(link, argument)), + isVisible: jest.fn(async () => true), + })); + const collection = { + count: jest.fn(async () => candidates.length), + nth: jest.fn((index) => candidates[index]), + }; + const page = { + evaluate: jest.fn(async (callback) => callback()), + goto: jest.fn(async (url) => { + currentUrl = url; + }), + locator: jest.fn(() => collection), + reload: jest.fn(async () => undefined), + setDefaultNavigationTimeout: jest.fn(), + setDefaultTimeout: jest.fn(), + url: jest.fn(() => currentUrl), + waitForURL: jest.fn(async (predicate, options) => { + expect(options).toEqual({ timeout: 5_000, waitUntil: "commit" }); + for (let attempt = 0; attempt < 100; attempt += 1) { + if (predicate(new URL(currentUrl))) return; + await new Promise((resolve) => setTimeout(resolve, 5)); + } + throw new Error("navigation did not complete"); + }), + }; + const driver = new LiveYoutubeDriver(page, {}); + driver.withExactVotesResponse = jest.fn(async (videoId, action) => ({ + body: { dislikes: 2, id: videoId, likes: 3 }, + result: await action(), + status: 200, + videoId, + })); + driver.waitForVideo = jest.fn(async () => undefined); + + await expect( + driver.navigateFromColdChannelToWatch("https://www.youtube.com/@channel", "targetvid01"), + ).resolves.toMatchObject({ status: 200, videoId: "targetvid01" }); + + expect(candidates[0].click).not.toHaveBeenCalled(); + expect(candidates[1].click).toHaveBeenCalledTimes(1); + expect(page.goto).toHaveBeenCalledWith("https://www.youtube.com/@channel/videos", { + waitUntil: "domcontentloaded", + }); + expect(driver.waitForVideo).toHaveBeenCalledWith("targetvid01"); + }); + + test("retries an ignored exact Watch pointer activation once with a freshly validated keyboard activation", async () => { + const { link, title } = makeLink(); + Object.defineProperty(document, "elementFromPoint", { + configurable: true, + value: jest.fn(() => title), + writable: true, + }); + let currentUrl = "https://www.youtube.com/@channel"; + const candidate = { + click: jest.fn(async () => undefined), + evaluate: jest.fn(async (callback, argument) => callback(link, argument)), + focus: jest.fn(async () => undefined), + isVisible: jest.fn(async () => true), + press: jest.fn(async (key) => { + expect(key).toBe("Enter"); + currentUrl = "https://www.youtube.com/watch?v=targetvid01"; + }), + }; + const collection = { + count: jest.fn(async () => 1), + nth: jest.fn(() => candidate), + }; + let navigationAttempt = 0; + const page = { + evaluate: jest.fn(async (callback) => callback()), + goto: jest.fn(async (url) => { + currentUrl = url; + }), + locator: jest.fn(() => collection), + reload: jest.fn(async () => undefined), + setDefaultNavigationTimeout: jest.fn(), + setDefaultTimeout: jest.fn(), + url: jest.fn(() => currentUrl), + waitForURL: jest.fn(async (predicate, options) => { + navigationAttempt += 1; + expect(options).toEqual({ + timeout: navigationAttempt === 1 ? 5_000 : 25_000, + waitUntil: "commit", + }); + if (navigationAttempt === 1) throw new Error("pointer activation was ignored"); + for (let attempt = 0; attempt < 100; attempt += 1) { + if (predicate(new URL(currentUrl))) return; + await new Promise((resolve) => setTimeout(resolve, 0)); + } + throw new Error("keyboard navigation did not complete"); + }), + }; + const reportProgress = jest.fn(); + const driver = new LiveYoutubeDriver(page, {}, { reportProgress }); + driver.withExactVotesResponse = jest.fn(async (videoId, action) => ({ + body: { dislikes: 2, id: videoId, likes: 3 }, + result: await action(), + status: 200, + videoId, + })); + driver.waitForVideo = jest.fn(async () => undefined); + + await expect( + driver.navigateFromColdChannelToWatch("https://www.youtube.com/@channel/shorts", "targetvid01"), + ).resolves.toMatchObject({ status: 200, videoId: "targetvid01" }); + + expect(page.goto).toHaveBeenCalledWith("https://www.youtube.com/@channel/videos", { + waitUntil: "domcontentloaded", + }); + expect(candidate.click).toHaveBeenCalledTimes(1); + expect(candidate.click).toHaveBeenCalledWith({ timeout: expect.any(Number) }); + expect(candidate.focus).toHaveBeenCalledTimes(1); + expect(candidate.press).toHaveBeenCalledTimes(1); + expect(candidate.press).toHaveBeenCalledWith("Enter", { timeout: expect.any(Number) }); + expect(reportProgress).toHaveBeenCalledWith("cold-channel.target-link.retrying", { + firstFailure: "pointer activation was ignored", + firstTimeoutMs: 5_000, + kind: "watch", + retryTimeoutMs: 25_000, + videoId: "targetvid01", + }); + expect(driver.waitForVideo).toHaveBeenCalledWith("targetvid01"); + }); + + test("keeps the strict center hit test when an avatar overlay persistently covers an exact Short target", async () => { + const { link } = makeLink(); + link.href = "/shorts/targetvid01"; + const obstruction = document.createElement("div"); + obstruction.className = "ytSpecAvatarShapeImageOverlays ytSpecAvatarShapeImage"; + document.body.append(obstruction); + Object.defineProperty(document, "elementFromPoint", { + configurable: true, + value: jest.fn(() => obstruction), + writable: true, + }); + const candidate = { + click: jest.fn(), + evaluate: jest.fn(async (callback, argument) => callback(link, argument)), + isVisible: jest.fn(async () => true), + }; + const collection = { + count: jest.fn(async () => 1), + nth: jest.fn(() => candidate), + }; + const page = { + locator: jest.fn(() => collection), + url: jest.fn(() => "https://www.youtube.com/@channel"), + }; + + await expect( + clickStabilizedExactVideoLink(page, "targetvid01", "short", { pollInterval: 1, timeout: 20 }), + ).rejects.toThrow(/Timed out waiting for an actionable exact short link.*center is covered/); + expect(link.scrollIntoView).toHaveBeenCalledTimes(1); + expect(candidate.click).not.toHaveBeenCalled(); + }); + + test("related-watch navigation re-resolves a hidden duplicate and keeps the SPA oracle", async () => { + const currentVideoId = "abcdefghijk"; + const destinationVideoId = "targetvid01"; + let currentUrl = `https://www.youtube.com/watch?v=${currentVideoId}`; + const hidden = makeLink(); + const { link, title } = makeLink(); + const hiddenContainer = document.createElement("div"); + hiddenContainer.setAttribute("aria-hidden", "true"); + hiddenContainer.append(hidden.link); + document.body.prepend(hiddenContainer); + link.getBoundingClientRect.mockReturnValue({ + bottom: 140, + height: 40, + left: 400, + right: 600, + top: 100, + width: 200, + x: 400, + y: 100, + }); + Object.defineProperty(document, "elementFromPoint", { + configurable: true, + value: jest.fn((x) => (x < 400 ? hidden.title : title)), + writable: true, + }); + const hiddenLocator = { + click: jest.fn(), + evaluate: jest.fn(async (callback, argument) => callback(hidden.link, argument)), + isVisible: jest.fn(async () => true), + }; + const linkLocator = { + click: jest.fn(async (options) => { + if (!options?.force) throw new Error("simulated Playwright stability timeout"); + currentUrl = `https://www.youtube.com/watch?v=${destinationVideoId}`; + }), + evaluate: jest.fn(async (callback, argument) => callback(link, argument)), + isVisible: jest.fn(async () => true), + }; + const candidates = [hiddenLocator, linkLocator]; + const relatedLocator = { + count: jest.fn(async () => candidates.length), + nth: jest.fn((index) => candidates[index]), + }; + const page = { + evaluate: jest.fn(async (callback) => callback()), + locator: jest.fn(() => relatedLocator), + setDefaultNavigationTimeout: jest.fn(), + setDefaultTimeout: jest.fn(), + url: jest.fn(() => currentUrl), + waitForURL: jest.fn(async (predicate, options) => { + expect(options).toEqual({ waitUntil: "commit" }); + for (let attempt = 0; attempt < 100; attempt += 1) { + if (predicate(new URL(currentUrl))) return; + await new Promise((resolve) => setTimeout(resolve, 5)); + } + throw new Error("navigation did not complete"); + }), + }; + const driver = new LiveYoutubeDriver(page, {}); + driver.withExactVotesResponse = jest.fn(async (videoId, action) => ({ + body: { id: videoId }, + result: await action(), + status: 200, + videoId, + })); + driver.waitForVideo = jest.fn(async () => undefined); + + await expect(driver.navigateToRelatedWatch()).resolves.toMatchObject({ + status: 200, + videoId: destinationVideoId, + }); + + expect(hiddenLocator.click).not.toHaveBeenCalled(); + expect(linkLocator.click).toHaveBeenCalledWith({ force: true, timeout: 5_000 }); + expect(driver.withExactVotesResponse).toHaveBeenCalledWith(destinationVideoId, expect.any(Function)); + expect(driver.waitForVideo).toHaveBeenCalledWith(destinationVideoId); + expect(page.url()).toBe(`https://www.youtube.com/watch?v=${destinationVideoId}`); + }); + + test("playlist SPA navigation settles a transient overlay before selecting and clicking its unvisited target", async () => { + const currentVideoId = "abcdefghijk"; + const destinationVideoId = "targetvid01"; + let currentUrl = `https://www.youtube.com/watch?v=${currentVideoId}&list=RDabcdefghijk`; + const current = makeLink(); + current.link.href = `/watch?v=${currentVideoId}&list=RDabcdefghijk`; + const destination = makeLink(); + destination.link.href = `/watch?v=${destinationVideoId}&list=RDabcdefghijk`; + destination.link.getBoundingClientRect.mockReturnValue({ + bottom: 140, + height: 40, + left: 400, + right: 600, + top: 100, + width: 200, + x: 400, + y: 100, + }); + const headerAvatarOverlay = document.createElement("div"); + headerAvatarOverlay.className = "ytSpecAvatarShapeImageOverlays ytSpecAvatarShapeImage"; + document.body.append(headerAvatarOverlay); + let destinationHitReadsSinceScroll = 0; + destination.link.scrollIntoView.mockImplementation(() => { + destinationHitReadsSinceScroll = 0; + }); + Object.defineProperty(document, "elementFromPoint", { + configurable: true, + value: jest.fn((x) => { + if (x < 400) return current.title; + destinationHitReadsSinceScroll += 1; + return destinationHitReadsSinceScroll === 1 ? headerAvatarOverlay : destination.title; + }), + writable: true, + }); + const candidates = [current, destination].map(({ link }, index) => ({ + click: jest.fn(async (options) => { + expect(options.force).toBe(true); + if (index === 1) { + currentUrl = `https://www.youtube.com/watch?v=${destinationVideoId}&list=RDabcdefghijk`; + } + }), + evaluate: jest.fn(async (callback, argument) => callback(link, argument)), + isVisible: jest.fn(async () => true), + })); + const collection = { + count: jest.fn(async () => candidates.length), + nth: jest.fn((index) => candidates[index]), + }; + const page = { + evaluate: jest.fn(async (callback) => callback()), + locator: jest.fn(() => collection), + setDefaultNavigationTimeout: jest.fn(), + setDefaultTimeout: jest.fn(), + url: jest.fn(() => currentUrl), + waitForURL: jest.fn(async (predicate, options) => { + expect(options).toEqual({ waitUntil: "commit" }); + for (let attempt = 0; attempt < 100; attempt += 1) { + if (predicate(new URL(currentUrl))) return; + await new Promise((resolve) => setTimeout(resolve, 5)); + } + throw new Error("navigation did not complete"); + }), + }; + const driver = new LiveYoutubeDriver(page, {}); + driver.withExactVotesResponse = jest.fn(async (videoId, action) => ({ + body: { dislikes: 2, id: videoId, likes: 3 }, + result: await action(), + status: 200, + videoId, + })); + driver.waitForVideo = jest.fn(async () => undefined); + + await expect(driver.navigateWithinPlaylist({ excludedVideoIds: ["excluded001"] })).resolves.toMatchObject({ + body: { id: destinationVideoId }, + status: 200, + videoId: destinationVideoId, + }); + + expect(candidates[0].click).not.toHaveBeenCalled(); + expect(candidates[1].click).toHaveBeenCalledTimes(1); + expect(destination.link.scrollIntoView).toHaveBeenCalledTimes(2); + expect(document.elementFromPoint).toHaveBeenCalledTimes(4); + expect(driver.withExactVotesResponse).toHaveBeenCalledWith(destinationVideoId, expect.any(Function)); + expect(driver.waitForVideo).toHaveBeenCalledWith(destinationVideoId); + }); + + test.each([ + ["disconnected", (link) => link.remove(), /became disconnected/], + ["hidden", (link) => (link.hidden = true), /is not visibly rendered/], + ["disabled", (link) => link.setAttribute("aria-disabled", "true"), /is disabled/], + [ + "outside the viewport", + (link) => + link.getBoundingClientRect.mockReturnValue({ + bottom: 940, + height: 40, + left: 100, + right: 300, + top: 900, + width: 200, + x: 100, + y: 900, + }), + /center is outside the viewport/, + ], + ])("rejects a %s link before forcing a click", async (_description, mutate, expectedError) => { + const { link, title } = makeLink(); + Object.defineProperty(document, "elementFromPoint", { + configurable: true, + value: jest.fn(() => title), + writable: true, + }); + mutate(link); + const locator = { + click: jest.fn(), + evaluate: jest.fn(async (callback, argument) => callback(link, argument)), + }; + + await expect(clickHitTestedNavigationLink(locator, { label: "related link", timeout: 20 })).rejects.toThrow( + expectedError, + ); + expect(locator.click).not.toHaveBeenCalled(); + }); +}); + +describe("hit-tested live control activation", () => { + function makeButton() { + const button = document.createElement("button"); + button.textContent = "Action"; + document.body.append(button); + button.scrollIntoView = jest.fn(); + button.getBoundingClientRect = jest.fn(() => ({ + bottom: 140, + height: 40, + left: 100, + right: 180, + top: 100, + width: 80, + x: 100, + y: 100, + })); + Object.defineProperty(window, "innerHeight", { configurable: true, value: 720 }); + Object.defineProperty(window, "innerWidth", { configurable: true, value: 1280 }); + return button; + } + + test("forces a trusted click only after explicit visibility, enabled, viewport, and hit-target checks", async () => { + const button = makeButton(); + Object.defineProperty(document, "elementFromPoint", { + configurable: true, + value: jest.fn(() => button), + writable: true, + }); + const beforeClick = jest.fn(); + const locator = { + click: jest.fn(async (options) => { + if (!options.force) throw new Error("simulated Playwright stability timeout"); + }), + evaluate: jest.fn(async (callback, argument) => callback(button, argument)), + }; + + await expect( + clickHitTestedElement(locator, { beforeClick, label: "reaction control", timeout: 321 }), + ).resolves.toMatchObject({ centerHitTarget: true, centerInViewport: true, enabled: true, visible: true }); + + expect(button.scrollIntoView).toHaveBeenCalledWith({ block: "center", inline: "nearest" }); + expect(beforeClick).toHaveBeenCalledTimes(1); + expect(locator.click).toHaveBeenCalledWith({ force: true, timeout: 321 }); + }); + + test("does not force a click when the independently hit-tested center is obstructed", async () => { + const button = makeButton(); + const obstruction = document.createElement("div"); + document.body.append(obstruction); + Object.defineProperty(document, "elementFromPoint", { + configurable: true, + value: jest.fn(() => obstruction), + writable: true, + }); + const locator = { + click: jest.fn(), + evaluate: jest.fn(async (callback, argument) => callback(button, argument)), + }; + + await expect(clickHitTestedElement(locator, { label: "More-actions control" })).rejects.toThrow( + /center is covered by another element/, + ); + expect(locator.click).not.toHaveBeenCalled(); + }); + + test("uses DOM scrolling and explicit viewport state for non-interactive visual measurements", async () => { + const button = makeButton(); + Object.defineProperty(document, "elementFromPoint", { + configurable: true, + value: jest.fn(() => null), + writable: true, + }); + const locator = { + evaluate: jest.fn(async (callback, argument) => callback(button, argument)), + scrollIntoViewIfNeeded: jest.fn(() => { + throw new Error("must not enter Playwright's stability waiter"); + }), + }; + + await expect(prepareElementForViewportMeasurement(locator, "ratio bar")).resolves.toMatchObject({ + centerInViewport: true, + connected: true, + visible: true, + }); + expect(locator.scrollIntoViewIfNeeded).not.toHaveBeenCalled(); + }); + + test.each([ + ["disconnected", { connected: false }, /became disconnected/], + ["hidden", { visible: false }, /not visibly rendered/], + ["disabled", { enabled: false }, /is disabled/], + ["offscreen", { centerInViewport: false }, /center is outside the viewport/], + ["covered", { centerHitTarget: false }, /center is covered/], + ])("rejects a %s live control before activation", (_label, override, expectedError) => { + expect(() => + assertElementActionable( + { + centerHitTarget: true, + centerInViewport: true, + connected: true, + enabled: true, + visible: true, + ...override, + }, + "live control", + ), + ).toThrow(expectedError); + }); + + test("rejects a non-interactive measurement target outside the viewport", () => { + expect(() => + assertElementReadyForViewportMeasurement({ centerInViewport: false, connected: true, visible: true }, "top row"), + ).toThrow(/center is outside the viewport/); + }); +}); + +describe("bound authenticated session proof", () => { + function createSignedInDriver(documentState, authenticatedHandle = "@expected.handle") { + const page = { + evaluate: jest.fn(async () => documentState), + locator: jest.fn(() => { + throw new Error("assertSignedIn must not reopen the account menu"); + }), + setDefaultNavigationTimeout: jest.fn(), + setDefaultTimeout: jest.fn(), + }; + return { driver: new LiveYoutubeDriver(page, {}, { authenticatedHandle }), page }; + } + + test("uses the context-selected handle and current ytcfg login flag without reopening the account menu", async () => { + const { driver, page } = createSignedInDriver({ committed: true, configuredLoggedIn: true }); + + await expect(driver.assertSignedIn("@EXPECTED.handle")).resolves.toBeUndefined(); + + expect(page.evaluate).toHaveBeenCalledTimes(1); + expect(page.locator).not.toHaveBeenCalled(); + }); + + test("rejects a current page whose ytcfg state is signed out", async () => { + const { driver } = createSignedInDriver({ committed: true, configuredLoggedIn: false }); + + await expect(driver.assertSignedIn("@expected.handle")).rejects.toThrow(/LOGGED_IN is not true/); + }); + + test("rejects a requested handle different from the context-selected identity", async () => { + const { driver, page } = createSignedInDriver({ committed: true, configuredLoggedIn: true }); + + await expect(driver.assertSignedIn("@different.handle")).rejects.toThrow( + /not bound to the authenticated browser context/, + ); + expect(page.evaluate).not.toHaveBeenCalled(); + }); +}); + describe("live playback diagnostics", () => { test("reports that playback was intentionally paused", async () => { const videos = [{ pause: jest.fn() }, { pause: jest.fn() }]; @@ -405,7 +3264,94 @@ describe("live playback diagnostics", () => { }); }); +describe("Shorts control stability soak", () => { + function createDriver() { + const page = { + setDefaultNavigationTimeout: jest.fn(), + setDefaultTimeout: jest.fn(), + }; + const driver = new LiveYoutubeDriver(page, {}); + driver.assertCurrentVideo = jest.fn(); + return driver; + } + + test("requires a later ownership/count sample instead of trusting the first rendered control", async () => { + const driver = createDriver(); + driver.assertCurrentShortsControl = jest + .fn() + .mockResolvedValueOnce({ count: "123", videoId: "abcdefghijk" }) + .mockRejectedValueOnce(new Error("control disappeared after navigation")); + + await expect( + driver.soakCurrentShortsControl("abcdefghijk", "extension", 123, { + durationMs: 0, + intervalMs: 1, + minimumSamples: 2, + presenceTimeoutMs: 10, + }), + ).rejects.toThrow("control disappeared after navigation"); + expect(driver.assertCurrentShortsControl).toHaveBeenCalledTimes(2); + expect(driver.assertCurrentShortsControl).toHaveBeenNthCalledWith(2, "abcdefghijk", "extension", { + expectedDislikes: 123, + presenceTimeoutMs: 10, + }); + }); + + test("returns evidence from at least two stable samples", async () => { + const driver = createDriver(); + driver.assertCurrentShortsControl = jest.fn(async () => ({ count: "123", videoId: "abcdefghijk" })); + + await expect( + driver.soakCurrentShortsControl("abcdefghijk", "userscript", 123, { + durationMs: 0, + intervalMs: 1, + minimumSamples: 2, + }), + ).resolves.toMatchObject({ expectedDislikes: 123, sampleCount: 2, videoId: "abcdefghijk" }); + }); +}); + describe("watch ratio-bar soak", () => { + test("rejects a current Watch result whose rendered count belongs to another response", async () => { + const page = { + evaluate: jest.fn(async () => "en"), + setDefaultNavigationTimeout: jest.fn(), + setDefaultTimeout: jest.fn(), + }; + const driver = new LiveYoutubeDriver(page, {}); + driver.assertCurrentVideo = jest.fn(); + driver.inspectWatchRatioVisual = jest.fn(async () => ({ count: "999" })); + driver.soakWatchRatioVisual = jest.fn(); + + await expect( + driver.assertCurrentWatchResult("abcdefghijk", "extension", { dislikes: 123, likes: 456 }), + ).rejects.toThrow(/Rendered dislike count "999" does not represent API count 123/); + expect(driver.soakWatchRatioVisual).not.toHaveBeenCalled(); + }); + + test("rejects a visible ratio result that is not owned by the current Watch root", async () => { + const emptyLocator = { + count: jest.fn(async () => 0), + nth: jest.fn(), + }; + const currentRoot = { locator: jest.fn(() => emptyLocator) }; + const page = { + locator: jest.fn(() => currentRoot), + setDefaultNavigationTimeout: jest.fn(), + setDefaultTimeout: jest.fn(), + }; + const driver = new LiveYoutubeDriver(page, {}); + driver.assertCurrentVideo = jest.fn(); + driver.assertRenderedDislikeCount = jest.fn(async () => ({})); + driver.inspectWatchRatioVisual = jest.fn(async () => ({ count: "123" })); + driver.soakWatchRatioVisual = jest.fn(); + + await expect( + driver.assertCurrentWatchResult("abcdefghijk", "userscript", { dislikes: 123, likes: 456 }), + ).rejects.toThrow(/exactly one visible userscript ratio bar owned by current Watch video abcdefghijk; found 0/); + expect(driver.soakWatchRatioVisual).not.toHaveBeenCalled(); + }); + test("rechecks the current video, bar geometry, and stable count for the requested interval", async () => { const page = { setDefaultNavigationTimeout: jest.fn(), @@ -419,15 +3365,21 @@ describe("watch ratio-bar soak", () => { const result = await driver.soakWatchRatioVisual("userscript", { durationMs: 1, expectedCount: "123", + expectedCounts: { dislikes: 123, likes: 456 }, intervalMs: 1, videoId: "targetvid01", }); expect(driver.assertCurrentVideo).toHaveBeenCalledWith("targetvid01"); - expect(driver.assertWatchRatioVisual).toHaveBeenCalledWith("userscript", { expectedCount: "123" }); + expect(driver.assertWatchRatioVisual).toHaveBeenCalledWith("userscript", { + expectedCount: "123", + expectedCounts: { dislikes: 123, likes: 456 }, + expectedVideoId: "targetvid01", + }); expect(result).toEqual({ count: "123", durationMs: 1, + ratioAudit: undefined, sampleCount: driver.assertWatchRatioVisual.mock.calls.length, videoId: "targetvid01", }); @@ -525,13 +3477,142 @@ describe("reaction visual capture hygiene", () => { expect(page.mouse.move).toHaveBeenCalledTimes(1); expect(page.screenshot).not.toHaveBeenCalled(); }); + + test("waits for both synthetic Shorts icons to paint before measuring their geometry", async () => { + const readyIdentity = { + elementRenderedInViewport: true, + hasReel: true, + reelRenderedInViewport: true, + videoMatches: true, + }; + const count = { evaluate: jest.fn(async () => "123"), first: jest.fn() }; + count.first.mockReturnValue(count); + const button = { + evaluate: jest.fn(async () => readyIdentity), + isVisible: jest.fn(async () => true), + }; + const buttonList = { + count: jest.fn(async () => 1), + nth: jest.fn(() => button), + }; + const control = { + evaluate: jest.fn(async () => readyIdentity), + isVisible: jest.fn(async () => true), + locator: jest.fn((selector) => (selector === "button" ? buttonList : count)), + }; + const controls = { + count: jest.fn(async () => 1), + nth: jest.fn(() => control), + }; + const page = { + locator: jest.fn(() => controls), + setDefaultNavigationTimeout: jest.fn(), + setDefaultTimeout: jest.fn(), + }; + const driver = new LiveYoutubeDriver(page, {}); + const likeButton = { evaluate: jest.fn() }; + const paintFailure = new Error("paint did not complete"); + driver.assertCurrentVideo = jest.fn(); + driver.visibleLikeButton = jest.fn(async () => likeButton); + driver.waitForShortsVisualPaint = jest.fn(async () => { + throw paintFailure; + }); + + await expect(driver.captureSyntheticShortsVisual("abcdefghijk", "synthetic.png")).rejects.toBe(paintFailure); + + expect(driver.waitForShortsVisualPaint).toHaveBeenCalledWith([likeButton, button]); + expect(control.evaluate).toHaveBeenCalledTimes(1); + expect(control.evaluate.mock.calls[0][1]).toEqual({ expectedShortVideoId: "abcdefghijk" }); + expect(likeButton.evaluate).not.toHaveBeenCalled(); + }); + + test("waits for both native Shorts icons to paint before measuring their geometry", async () => { + const page = { + setDefaultNavigationTimeout: jest.fn(), + setDefaultTimeout: jest.fn(), + }; + const driver = new LiveYoutubeDriver(page, {}); + const likeButton = { evaluate: jest.fn() }; + const dislikeButton = { evaluate: jest.fn() }; + const paintFailure = new Error("paint did not complete"); + driver.assertCurrentVideo = jest.fn(); + driver.waitForDislikeText = jest.fn(async () => "123"); + driver.visibleLikeButton = jest.fn(async () => likeButton); + driver.visibleDislikeButton = jest.fn(async () => dislikeButton); + driver.waitForShortsVisualPaint = jest.fn(async () => { + throw paintFailure; + }); + + await expect(driver.captureNativeShortsVisual("abcdefghijk", "native.png")).rejects.toBe(paintFailure); + + expect(driver.waitForShortsVisualPaint).toHaveBeenCalledWith([likeButton, dislikeButton]); + expect(likeButton.evaluate).not.toHaveBeenCalled(); + expect(dislikeButton.evaluate).not.toHaveBeenCalled(); + }); + + test.each([ + ["strict-synthetic", "captureSyntheticShortsVisual", "captureNativeShortsVisual"], + ["native-pair", "captureNativeShortsVisual", "captureSyntheticShortsVisual"], + ])( + "selects the %s Shorts capture by capability, not runtime label", + async (shortsVisualModel, expected, rejected) => { + const page = { + setDefaultNavigationTimeout: jest.fn(), + setDefaultTimeout: jest.fn(), + }; + const driver = new LiveYoutubeDriver(page, {}); + driver.assertCurrentVideo = jest.fn(); + driver.assertRenderedDislikeCount = jest.fn(async () => ({})); + driver.readReactionPressedStates = jest.fn(async () => ({ dislikeState: "false", likeState: "false" })); + driver.waitForDislikeText = jest.fn(async () => "123"); + driver.captureSyntheticShortsVisual = jest.fn(async () => ({ capture: "synthetic" })); + driver.captureNativeShortsVisual = jest.fn(async () => ({ capture: "native" })); + + await driver.captureReactionStateVisual({ + expectedCounts: { dislikes: 123, likes: 456 }, + expectedState: "neutral", + isShort: true, + runtime: shortsVisualModel === "strict-synthetic" ? "extension" : "userscript", + screenshotPath: "reaction.png", + shortsVisualModel, + videoId: "abcdefghijk", + }); + + expect(driver[expected]).toHaveBeenCalledWith("abcdefghijk", "reaction.png"); + expect(driver[rejected]).not.toHaveBeenCalled(); + }, + ); + + test("rejects Shorts reaction evidence without an adapter visual capability", async () => { + const page = { + setDefaultNavigationTimeout: jest.fn(), + setDefaultTimeout: jest.fn(), + }; + const driver = new LiveYoutubeDriver(page, {}); + driver.assertCurrentVideo = jest.fn(); + driver.assertRenderedDislikeCount = jest.fn(async () => ({})); + driver.readReactionPressedStates = jest.fn(async () => ({ dislikeState: "false", likeState: "false" })); + driver.waitForDislikeText = jest.fn(async () => "123"); + + await expect( + driver.captureReactionStateVisual({ + expectedCounts: { dislikes: 123, likes: 456 }, + expectedState: "neutral", + isShort: true, + runtime: "extension", + screenshotPath: "reaction.png", + videoId: "abcdefghijk", + }), + ).rejects.toThrow("Unsupported reaction Shorts visual model: missing"); + }); }); describe("watch reaction screenshot bounds", () => { - function visibleBoxLocator(measurement) { + function visibleBoxLocator(measurement, evaluatedValue = null) { const locator = { boundingBox: jest.fn(async () => measurement), count: jest.fn(async () => 1), + evaluate: jest.fn(async () => evaluatedValue), isVisible: jest.fn(async () => true), nth: jest.fn(() => locator), scrollIntoViewIfNeeded: jest.fn(async () => undefined), @@ -539,6 +3620,55 @@ describe("watch reaction screenshot bounds", () => { return locator; } + function createRatioCaptureDriver({ appearance: appearanceOverrides = {}, fillBox = box(100, 144, 120, 2) } = {}) { + const viewport = { height: 300, width: 500 }; + const likeBox = box(100, 100, 80, 40); + const dislikeBox = box(180, 100, 80, 40); + const containerBox = box(100, 144, 160, 2); + const wrapperBox = box(100, 140, 160, 62); + const hitAreaBox = box(100, 138, 160, 20); + const topRowBox = box(80, 80, 400, 140); + const like = visibleBoxLocator(likeBox); + const dislike = visibleBoxLocator(dislikeBox); + const bar = visibleBoxLocator(fillBox); + const wrapper = visibleBoxLocator(wrapperBox); + wrapper.evaluate + .mockResolvedValueOnce({ + inlineFillWidth: "75%", + negativeTrackBackgroundImage: "none", + negativeTrackColor: "rgb(115, 115, 115)", + negativeTrackOpacity: 1, + numberLocale: "en-US", + pageBackgroundColor: "rgb(255, 255, 255)", + pageBackgroundSource: "body", + pageTheme: "light", + tooltipText: "3 / 1", + wrapperVideoId: "abcdefghijk", + ...appearanceOverrides, + }) + .mockResolvedValueOnce({ hitArea: hitAreaBox, nearbyActions: [], topRow: topRowBox }); + const container = visibleBoxLocator(containerBox, { + centerInViewport: true, + connected: true, + visible: true, + }); + container.locator = jest.fn(() => wrapper); + const page = { + locator: jest.fn((selector) => (selector === "#return-youtube-dislike-bar-container" ? container : bar)), + setDefaultNavigationTimeout: jest.fn(), + setDefaultTimeout: jest.fn(), + url: jest.fn(() => "https://www.youtube.com/watch?v=abcdefghijk"), + }; + const driver = new LiveYoutubeDriver(page, {}); + driver.captureCroppedScreenshot = jest.fn(async () => ({})); + driver.readViewportSize = jest.fn(async () => viewport); + driver.visibleDislikeButton = jest.fn(async () => dislike); + driver.visibleLikeButton = jest.fn(async () => like); + driver.assertRenderedDislikeCount = jest.fn(async () => ({})); + driver.waitForDislikeText = jest.fn(async () => "1"); + return driver; + } + test("passes the full ratio wrapper to the crop so its bottom label is retained", async () => { const viewport = { height: 300, width: 500 }; const likeBox = box(100, 100, 80, 40); @@ -546,33 +3676,68 @@ describe("watch reaction screenshot bounds", () => { const containerBox = box(100, 144, 160, 2); const barBox = box(100, 144, 120, 2); const wrapperBox = box(100, 140, 160, 62); + const hitAreaBox = box(100, 138, 160, 20); + const nearbyActionBox = box(280, 100, 80, 40); + const topRowBox = box(80, 80, 400, 140); const like = visibleBoxLocator(likeBox); const dislike = visibleBoxLocator(dislikeBox); const bar = visibleBoxLocator(barBox); const wrapper = visibleBoxLocator(wrapperBox); - const container = visibleBoxLocator(containerBox); + wrapper.evaluate + .mockResolvedValueOnce({ + inlineFillWidth: "75%", + negativeTrackBackgroundImage: "none", + negativeTrackColor: "rgb(115, 115, 115)", + negativeTrackOpacity: 1, + numberLocale: "en-US", + pageBackgroundColor: "rgb(255, 255, 255)", + pageBackgroundSource: "body", + pageTheme: "light", + tooltipText: "3 / 1", + wrapperVideoId: "abcdefghijk", + }) + .mockResolvedValueOnce({ + hitArea: hitAreaBox, + nearbyActions: [nearbyActionBox], + topRow: topRowBox, + }); + const container = visibleBoxLocator(containerBox, { + centerInViewport: true, + connected: true, + visible: true, + }); container.locator = jest.fn(() => wrapper); const page = { locator: jest.fn((selector) => (selector === "#return-youtube-dislike-bar-container" ? container : bar)), setDefaultNavigationTimeout: jest.fn(), setDefaultTimeout: jest.fn(), + url: jest.fn(() => "https://www.youtube.com/watch?v=abcdefghijk"), }; const driver = new LiveYoutubeDriver(page, {}); driver.captureCroppedScreenshot = jest.fn(async (_path, boxes) => croppedScreenshotClip(boxes, viewport)); driver.readViewportSize = jest.fn(async () => viewport); driver.visibleDislikeButton = jest.fn(async () => dislike); driver.visibleLikeButton = jest.fn(async () => like); - driver.waitForDislikeText = jest.fn(async () => "123"); + driver.assertRenderedDislikeCount = jest.fn(async () => ({})); + driver.waitForDislikeText = jest.fn(async () => "1"); - const result = await driver.captureWatchRatioVisual("userscript", "watch-liked.png"); + const result = await driver.captureWatchRatioVisual("userscript", "watch-liked.png", { + expectedCounts: { dislikes: 1, likes: 3 }, + expectedVideoId: "abcdefghijk", + }); expect(driver.captureCroppedScreenshot).toHaveBeenCalledWith("watch-liked.png", [ + topRowBox, likeBox, dislikeBox, containerBox, wrapperBox, + hitAreaBox, + nearbyActionBox, ]); - expect(result.screenshotClip.y + result.screenshotClip.height).toBe(wrapperBox.y + wrapperBox.height + 12); + expect(result.screenshotClip.y + result.screenshotClip.height).toBeGreaterThanOrEqual( + wrapperBox.y + wrapperBox.height + 12, + ); expect(result.screenshotClip.y + result.screenshotClip.height).toBeGreaterThan( containerBox.y + containerBox.height + 12, ); @@ -591,6 +3756,7 @@ describe("watch reaction screenshot bounds", () => { ), setDefaultNavigationTimeout: jest.fn(), setDefaultTimeout: jest.fn(), + url: jest.fn(() => "https://www.youtube.com/watch?v=abcdefghijk"), }; const driver = new LiveYoutubeDriver(page, {}); @@ -598,6 +3764,27 @@ describe("watch reaction screenshot bounds", () => { "Expected exactly one visible userscript watch ratio bar; found 2.", ); }); + + test.each([ + ["stale wrapper owner", { wrapperVideoId: "stalevid001" }, /Watch ratio bar is owned by stalevid001/], + ["wrong API ratio", { inlineFillWidth: "25%" }, /inline width represents 25\.000%, expected 75\.000%/], + ["wrong API tooltip", { tooltipText: "9 / 1" }, /tooltip.*does not exactly match the API counts/], + [ + "near-invisible negative track", + { negativeTrackColor: "rgb(248, 248, 248)" }, + /negative track is effectively invisible/, + ], + ])("rejects a %s before recording Watch evidence", async (_label, appearance, expectedError) => { + const driver = createRatioCaptureDriver({ appearance }); + + await expect( + driver.captureWatchRatioVisual("userscript", "invalid.png", { + expectedCounts: { dislikes: 1, likes: 3 }, + expectedVideoId: "abcdefghijk", + }), + ).rejects.toThrow(expectedError); + expect(driver.captureCroppedScreenshot).not.toHaveBeenCalled(); + }); }); describe("live Shorts Next navigation retry", () => { @@ -688,21 +3875,21 @@ describe("watch ratio viewport alignment", () => { const like = box(-25.828, 700, 80.77, 40); const dislike = box(54.942, 700, 84.658, 40); - test("allows the ratio bar to share YouTube's native horizontal clipping exactly", () => { + test("rejects the captured 390px failure even when the ratio bar shares the native clipping", () => { const container = box(-25.828, 744, 165.428, 2); - expect(assertWatchRatioViewportAlignment(container, like, dislike, viewport)).toEqual({ - nativeControlsAreHorizontallyClipped: true, - nativeLeft: -25.828, - nativeRight: 139.6, - }); + expect(() => assertWatchRatioViewportAlignment(container, like, dislike, viewport)).toThrow( + /Watch like control is clipped past the viewport's left edge/, + ); }); - test("rejects ratio-bar clipping that extends beyond the native reaction controls", () => { - const container = box(-35.828, 744, 175.428, 2); + test("rejects native reaction controls clipped past the right edge", () => { + const rightClippedLike = box(240, 700, 80, 40); + const rightClippedDislike = box(320, 700, 84, 40); + const container = box(240, 744, 164, 2); - expect(() => assertWatchRatioViewportAlignment(container, like, dislike, viewport)).toThrow( - /left edge alignment with native reaction controls/, + expect(() => assertWatchRatioViewportAlignment(container, rightClippedLike, rightClippedDislike, viewport)).toThrow( + /Watch dislike control is clipped past the viewport's right edge/, ); }); @@ -715,6 +3902,457 @@ describe("watch ratio viewport alignment", () => { /Watch ratio bar is clipped past the viewport's left edge/, ); }); + + test("accepts only an entirely visible reaction pair and ratio bar", () => { + const inBoundsLike = box(10, 700, 80, 40); + const inBoundsDislike = box(90, 700, 85, 40); + const container = box(10, 744, 165, 2); + + expect(assertWatchRatioViewportAlignment(container, inBoundsLike, inBoundsDislike, viewport)).toEqual({ + nativeControlsAreHorizontallyClipped: false, + nativeLeft: 10, + nativeRight: 175, + }); + }); +}); + +describe("watch ratio surroundings", () => { + const viewport = { height: 844, width: 390 }; + + test("discovers visible non-reaction controls sharing the current top row", () => { + document.body.innerHTML = ` +
+ + +
`; + Object.defineProperty(window, "innerWidth", { configurable: true, value: viewport.width }); + Object.defineProperty(window, "innerHeight", { configurable: true, value: viewport.height }); + const setBox = (selector, measurement) => { + document.querySelector(selector).getBoundingClientRect = () => ({ + ...measurement, + bottom: measurement.y + measurement.height, + left: measurement.x, + right: measurement.x + measurement.width, + top: measurement.y, + }); + }; + setBox("#top-row", box(-26, 690, 427, 154)); + setBox("#owner-action", box(-20, 700, 40, 40)); + setBox("like-button-view-model button", box(12, 700, 90, 40)); + setBox("dislike-button-view-model button", box(102, 700, 90, 40)); + setBox("button-view-model button", box(20, 754, 96, 40)); + setBox(".ryd-tooltip-bar-container", box(12, 742, 180, 20)); + + expect(readWatchRatioSurroundings(document.querySelector(".ryd-tooltip"))).toEqual({ + hitArea: box(12, 742, 180, 20), + nearbyActions: [box(20, 754, 96, 40)], + topRow: box(-26, 690, 427, 154), + }); + }); + + test("rejects a second-row action that encroaches on the ratio bar hit area", () => { + const surroundings = { + hitArea: box(12, 742, 180, 20), + nearbyActions: [box(20, 754, 96, 40)], + topRow: box(0, 690, 390, 154), + }; + + expect(() => assertWatchRatioSurroundings(surroundings, viewport)).toThrow( + /Watch nearby action 1 overlaps the ratio bar hit area/, + ); + }); + + test("rejects a partially visible adjacent action", () => { + const surroundings = { + hitArea: box(12, 742, 180, 20), + nearbyActions: [box(370, 700, 48, 40)], + topRow: box(0, 690, 390, 154), + }; + + expect(() => assertWatchRatioSurroundings(surroundings, viewport)).toThrow( + /Watch nearby action 1 is clipped past the viewport's right edge/, + ); + }); + + test("accepts separated in-viewport controls", () => { + const surroundings = { + hitArea: box(12, 742, 180, 20), + nearbyActions: [box(210, 700, 80, 40)], + topRow: box(-26, 690, 427, 154), + }; + + expect(assertWatchRatioSurroundings(surroundings, viewport)).toBe(surroundings); + }); +}); + +describe("watch ratio appearance", () => { + test("uses the runtime number locale rather than the YouTube document language for tooltip expectations", () => { + const numberFormat = jest.spyOn(Intl, "NumberFormat").mockImplementation(() => ({ + resolvedOptions: () => ({ locale: "en-US" }), + })); + document.documentElement.lang = "ru-RU"; + document.body.style.backgroundColor = "rgb(255, 255, 255)"; + document.body.innerHTML = ` +
+
+
+
+
48,515 / 13
+
`; + const wrapper = document.querySelector(".ryd-tooltip"); + const container = document.querySelector("#return-youtube-dislike-bar-container"); + const bar = document.querySelector("#return-youtube-dislike-bar"); + container.getBoundingClientRect = () => ({ bottom: 102, height: 2, left: 0, right: 200, top: 100 }); + bar.getBoundingClientRect = () => ({ bottom: 102, height: 2, left: 0, right: 150, top: 100 }); + + try { + const appearance = readWatchRatioAppearance(wrapper, { + bar: "#return-youtube-dislike-bar", + container: "#return-youtube-dislike-bar-container", + tooltip: "#ryd-dislike-tooltip", + }); + + expect(appearance.numberLocale).toBe("en-US"); + expect(appearance.numberLocale).not.toBe(document.documentElement.lang); + expect(appearance.tooltipText).toBe("48,515 / 13"); + } finally { + numberFormat.mockRestore(); + } + }); +}); + +describe("Watch action-topology assertions", () => { + test("discovers current flexible action siblings without mistaking them for More buttons", () => { + document.body.innerHTML = ` + +
+ +
+
`; + const topRow = document.querySelector("#top-row"); + for (const element of document.querySelectorAll("*")) { + element.getBoundingClientRect = () => ({ + bottom: 140, + height: 40, + left: 10, + right: 110, + top: 100, + width: 100, + x: 10, + y: 100, + }); + } + + const topology = readWatchTopRowTopology(topRow, "abcdefghijk"); + + expect(topology.counts).toMatchObject({ + dislikeButtons: 1, + likeButtons: 1, + moreButtons: 1, + reactionGroups: 1, + }); + expect(topology.topLevelOptionalSignatures).toEqual(["Share", "Save", "Thanks"]); + }); + + function validTopology() { + return { + boxes: { + actionSurface: box(600, 520, 500, 50), + dislikeButton: box(680, 525, 80, 40), + likeButton: box(600, 525, 80, 40), + menu: box(590, 510, 570, 70), + moreButton: box(1110, 525, 40, 40), + reactionGroup: box(600, 525, 160, 40), + topLevelActionHosts: [box(770, 525, 80, 40)], + topRow: box(10, 500, 1180, 100), + }, + counts: { + dislikeButtons: 1, + likeButtons: 1, + moreButtons: 1, + reactionGroups: 1, + visibleCurrentWatchRoots: 1, + visibleMenus: 1, + visibleOverflowMenus: 1, + visibleSurfaces: 1, + visibleTopRows: 1, + }, + inventorySignatures: ["Report", "Save", "Share"], + overflowBox: box(850, 200, 250, 180), + overflowItemBoxes: [box(860, 210, 230, 50), box(860, 270, 230, 50)], + overflowSignatures: ["Save", "Report"], + topLevelOptionalSignatures: ["Share"], + videoId: "abcdefghijk", + videoMatches: true, + viewport: { height: 800, width: 1200 }, + }; + } + + function createActionTopologyDriver({ keyboardOpensPopup = true, pointerOpensPopup = true } = {}) { + const topology = validTopology(); + let popupVisible = false; + const reportProgress = jest.fn(); + const actionable = { + centerHitTarget: true, + centerInViewport: true, + connected: true, + enabled: true, + visible: true, + }; + const moreButton = { + click: jest.fn(async (options) => { + if (!options?.force) throw new Error("simulated stability timeout"); + if (pointerOpensPopup) popupVisible = true; + }), + evaluate: jest.fn(async (callback) => (callback === readElementActionability ? actionable : true)), + focus: jest.fn(async () => undefined), + isEnabled: jest.fn(async () => true), + isVisible: jest.fn(async () => true), + press: jest.fn(async (key) => { + if (key === "Enter" && keyboardOpensPopup) popupVisible = true; + }), + }; + const menuButtons = { + count: jest.fn(async () => 1), + nth: jest.fn(() => moreButton), + }; + const menu = { + count: jest.fn(async () => 1), + isVisible: jest.fn(async () => true), + locator: jest.fn(() => menuButtons), + nth: jest.fn(() => menu), + }; + const topRow = { + evaluate: jest.fn(async (callback) => + callback === readElementActionability + ? actionable + : { + boxes: topology.boxes, + counts: topology.counts, + topLevelOptionalSignatures: topology.topLevelOptionalSignatures, + videoId: topology.videoId, + videoMatches: topology.videoMatches, + }, + ), + isVisible: jest.fn(async () => true), + locator: jest.fn(() => menu), + }; + const topRows = { + count: jest.fn(async () => 1), + nth: jest.fn(() => topRow), + }; + const watchRoot = { isVisible: jest.fn(async () => true), locator: jest.fn(() => topRows) }; + const watchRoots = { + count: jest.fn(async () => 1), + nth: jest.fn(() => watchRoot), + }; + const popup = { + evaluate: jest.fn(async () => ({ + box: topology.overflowBox, + itemBoxes: topology.overflowItemBoxes, + signatures: topology.overflowSignatures, + })), + isVisible: jest.fn(async () => popupVisible), + }; + const popups = { + evaluateAll: jest.fn(async () => (popupVisible ? [0] : [])), + nth: jest.fn(() => popup), + }; + const page = { + evaluate: jest.fn(async () => undefined), + keyboard: { + press: jest.fn(async () => { + popupVisible = false; + }), + }, + locator: jest.fn((selector) => + selector.startsWith('ytd-watch-flexy[video-id="abcdefghijk"]') ? watchRoots : popups, + ), + setDefaultNavigationTimeout: jest.fn(), + setDefaultTimeout: jest.fn(), + url: jest.fn(() => "https://www.youtube.com/watch?v=abcdefghijk"), + }; + const driver = new LiveYoutubeDriver(page, {}, { reportProgress }); + driver.inspectWatchRatioVisual = jest.fn(async () => ({})); + driver.readViewportSize = jest.fn(async () => topology.viewport); + + return { driver, moreButton, page, reportProgress, topology }; + } + + test("opens More through independent hit testing without Playwright stability scrolling", async () => { + const { driver, moreButton, page, topology } = createActionTopologyDriver(); + + await expect(driver.inspectWatchActionTopology("extension", { presenceTimeoutMs: 100 })).resolves.toMatchObject({ + inventorySignatures: topology.inventorySignatures, + overflowActivation: "pointer", + overflowSignatures: topology.overflowSignatures, + topLevelOptionalSignatures: topology.topLevelOptionalSignatures, + }); + + expect(moreButton.click).toHaveBeenCalledWith({ force: true, timeout: 100 }); + expect(moreButton.focus).not.toHaveBeenCalled(); + expect(moreButton.press).not.toHaveBeenCalled(); + expect(page.keyboard.press).toHaveBeenCalledWith("Escape"); + }); + + test("re-resolves More and retries once with a hit-tested keyboard activation when YouTube ignores the pointer click", async () => { + const { driver, moreButton, page, reportProgress, topology } = createActionTopologyDriver({ + pointerOpensPopup: false, + }); + + await expect(driver.inspectWatchActionTopology("extension", { presenceTimeoutMs: 250 })).resolves.toMatchObject({ + inventorySignatures: topology.inventorySignatures, + overflowActivation: "keyboard-retry", + overflowSignatures: topology.overflowSignatures, + }); + + expect(moreButton.click).toHaveBeenCalledTimes(1); + expect(moreButton.focus).toHaveBeenCalledTimes(1); + expect(moreButton.press).toHaveBeenCalledWith("Enter", { timeout: 250 }); + expect( + page.locator.mock.calls.filter(([selector]) => selector.startsWith('ytd-watch-flexy[video-id="abcdefghijk"]')), + ).toHaveLength(2); + expect(reportProgress).toHaveBeenCalledWith( + "watch-action-topology.more-menu.retrying", + expect.objectContaining({ runtime: "extension", videoId: "abcdefghijk" }), + ); + }); + + test("fails after the single Watch More retry instead of hiding a persistently broken native menu", async () => { + const { driver, moreButton } = createActionTopologyDriver({ + keyboardOpensPopup: false, + pointerOpensPopup: false, + }); + + await expect(driver.inspectWatchActionTopology("extension", { presenceTimeoutMs: 250 })).rejects.toThrow( + /hit-tested pointer activation or its single keyboard retry/, + ); + + expect(moreButton.click).toHaveBeenCalledTimes(1); + expect(moreButton.focus).toHaveBeenCalledTimes(1); + expect(moreButton.press).toHaveBeenCalledTimes(1); + }); + + test("accepts one complete, accessible, non-overlapping action topology", () => { + const snapshot = validTopology(); + + expect(() => + assertWatchActionTopologySnapshot(snapshot, { + expectedInventorySignatures: ["Share", "Save", "Report"], + expectedTopLevelOptionalSignatures: ["Share"], + minimumTopLevelOptionalActions: 1, + }), + ).not.toThrow(); + }); + + test("accepts YouTube's four-pixel menu-shell overhang while keeping actionable controls inside", () => { + const snapshot = validTopology(); + snapshot.boxes.menu.width = 604; + + expect(() => assertWatchActionTopologySnapshot(snapshot)).not.toThrow(); + }); + + test("rejects a menu shell that overhangs the top row by more than five pixels", () => { + const snapshot = validTopology(); + snapshot.boxes.menu.width = 606; + + expect(() => assertWatchActionTopologySnapshot(snapshot)).toThrow(/Watch action menu escapes the top row/); + }); + + test("does not apply the menu-shell tolerance to the actionable More button", () => { + const snapshot = validTopology(); + snapshot.boxes.moreButton.x = 1153; + + expect(() => assertWatchActionTopologySnapshot(snapshot)).toThrow(/More-actions button escapes the top row/); + }); + + test("rejects the former false-green state where every wide optional action is hidden", () => { + const snapshot = validTopology(); + snapshot.topLevelOptionalSignatures = []; + snapshot.overflowSignatures = ["Share", "Save", "Report"]; + + expect(() => assertWatchActionTopologySnapshot(snapshot, { minimumTopLevelOptionalActions: 1 })).toThrow( + /at least 1 optional action/, + ); + }); + + test("rejects an action duplicated across top level and More", () => { + const snapshot = validTopology(); + snapshot.overflowSignatures = ["Share", "Save", "Report"]; + snapshot.inventorySignatures = ["Report", "Save", "Share", "Share"]; + + expect(() => assertWatchActionTopologySnapshot(snapshot)).toThrow(/both at top level and in the More menu/); + }); + + test("rejects an action missing after a width change", () => { + const snapshot = validTopology(); + snapshot.overflowSignatures = ["Report"]; + snapshot.inventorySignatures = ["Report", "Share"]; + + expect(() => + assertWatchActionTopologySnapshot(snapshot, { + expectedInventorySignatures: ["Report", "Save", "Share"], + }), + ).toThrow(/disappeared or duplicated while the layout changed/); + }); + + test.each([ + ["stale current root", (snapshot) => (snapshot.videoMatches = false), /stale video root/], + [ + "clipped More button", + (snapshot) => { + snapshot.boxes.moreButton.x = 1190; + }, + /More-actions button is clipped/, + ], + [ + "overlapping optional action", + (snapshot) => { + snapshot.boxes.topLevelActionHosts[0].x = 740; + }, + /top-row controls 1 and 2 overlap/, + ], + [ + "clipped overflow menu", + (snapshot) => { + snapshot.overflowBox.x = 1100; + }, + /More-actions menu is clipped/, + ], + [ + "overlapping overflow items", + (snapshot) => { + snapshot.overflowItemBoxes[1].y = 250; + }, + /More-actions items 1 and 2 overlap/, + ], + ])("rejects a %s", (_label, mutate, expected) => { + const snapshot = validTopology(); + mutate(snapshot); + expect(() => assertWatchActionTopologySnapshot(snapshot)).toThrow(expected); + }); }); describe("assertReactionPressedStates", () => { @@ -750,6 +4388,57 @@ describe("assertSyntheticShortsGeometry", () => { expect(() => assertSyntheticShortsGeometry(validSyntheticShortsGeometry())).not.toThrow(); }); + test("accepts a centered host widened by a localized count", () => { + const measurement = validSyntheticShortsGeometry(); + widenShortsControlForLocalizedCount(measurement.like); + widenShortsControlForLocalizedCount(measurement.synthetic); + + expect(() => assertSyntheticShortsGeometry(measurement)).not.toThrow(); + }); + + test("accepts the real 51.6125px count-driven shell while preserving native Like control parity", () => { + const measurement = validSyntheticShortsGeometry(); + widenShortsControlForLocalizedCount(measurement.synthetic, { + countWidth: 39.6125, + shellWidth: 51.6125, + }); + + expect(() => assertSyntheticShortsGeometry(measurement)).not.toThrow(); + }); + + test("accepts the exact Next-hop rail width when a native peer establishes the 51.6125px column", () => { + const measurement = validSyntheticShortsGeometry(); + widenShortsControlForLocalizedCount(measurement.synthetic, { countWidth: 7, shellWidth: 51.6125 }); + measurement.nativeActionHosts.push(box(98.19375, 244, 51.6125, 78)); + + expect(() => assertSyntheticShortsGeometry(measurement)).not.toThrow(); + }); + + test("rejects a 51.6125px shell with a short count when every native action remains 48px", () => { + const measurement = validSyntheticShortsGeometry(); + widenShortsControlForLocalizedCount(measurement.synthetic, { countWidth: 7, shellWidth: 51.6125 }); + + expect(() => assertSyntheticShortsGeometry(measurement)).toThrow(/content-aware maximum of 48px/); + }); + + test("uses the measured fractional native Like button width instead of assuming 48px", () => { + const measurement = validSyntheticShortsGeometry(); + widenShortsButtonAroundCenter(measurement.like, 51.6125); + widenShortsButtonAroundCenter(measurement.synthetic, 51.6125); + widenShortsControlForLocalizedCount(measurement.like, { countWidth: 39.6125, shellWidth: 51.6125 }); + widenShortsControlForLocalizedCount(measurement.synthetic, { countWidth: 39.6125, shellWidth: 51.6125 }); + + expect(() => assertSyntheticShortsGeometry(measurement)).not.toThrow(); + }); + + test("rejects a synthetic button width that diverges from a fractional native Like control", () => { + const measurement = validSyntheticShortsGeometry(); + widenShortsButtonAroundCenter(measurement.like, 51.6125); + widenShortsControlForLocalizedCount(measurement.like, { countWidth: 39.6125, shellWidth: 51.6125 }); + + expect(() => assertSyntheticShortsGeometry(measurement)).toThrow(/button width parity with native Like/); + }); + test.each([ ["icon container", "icon"], ["SVG", "svg"], @@ -824,6 +4513,22 @@ describe("assertSyntheticShortsGeometry", () => { expect(() => assertSyntheticShortsGeometry(measurement)).toThrow(/Synthetic Shorts button horizontal center/); }); + test("rejects a host and label that are centered but much wider than their rendered content", () => { + const measurement = validSyntheticShortsGeometry(); + widenShortsControlForLocalizedCount(measurement.synthetic, { shellWidth: 96 }); + + expect(() => assertSyntheticShortsGeometry(measurement)).toThrow(/content-aware maximum/); + }); + + test("rejects an overlapping synthetic action even when its internal geometry remains valid", () => { + const measurement = validSyntheticShortsGeometry(); + measurement.synthetic.host.y -= 4; + + expect(() => assertSyntheticShortsGeometry(measurement)).toThrow( + /Gap between native Like and synthetic Shorts action hosts/, + ); + }); + test("allows a one-pixel fractional rendering difference", () => { const measurement = validSyntheticShortsGeometry(); measurement.synthetic.button.width = 47; @@ -839,6 +4544,13 @@ describe("assertNativeShortsPairGeometry", () => { expect(() => assertNativeShortsPairGeometry(validNativeShortsPairGeometry())).not.toThrow(); }); + test("accepts a native Dislike host widened by a localized count", () => { + const measurement = validNativeShortsPairGeometry(); + widenShortsControlForLocalizedCount(measurement.dislike); + + expect(() => assertNativeShortsPairGeometry(measurement)).not.toThrow(); + }); + test("rejects controls selected from different retained Shorts reels", () => { const measurement = validNativeShortsPairGeometry(); measurement.dislike.reelIndex = 1; @@ -888,7 +4600,9 @@ describe("live Shorts full-stack visual readiness", () => { effectiveOpacity: 1, paintedGraphicCount: 1, rendered: true, + svgHeight: 24, svgPresent: true, + svgWidth: 24, }), ).toBe(true); }); @@ -910,10 +4624,30 @@ describe("live Shorts full-stack visual readiness", () => { }); test.each([ - ["unhydrated SVG", { effectiveOpacity: 1, paintedGraphicCount: 0, rendered: true, svgPresent: true }], - ["missing SVG", { effectiveOpacity: 1, paintedGraphicCount: 0, rendered: true, svgPresent: false }], - ["hidden ancestor", { effectiveOpacity: 0, paintedGraphicCount: 1, rendered: true, svgPresent: true }], - ["non-rendered ancestor", { effectiveOpacity: 1, paintedGraphicCount: 1, rendered: false, svgPresent: true }], + [ + "unhydrated SVG", + { effectiveOpacity: 1, paintedGraphicCount: 0, rendered: true, svgHeight: 24, svgPresent: true, svgWidth: 24 }, + ], + [ + "missing SVG", + { effectiveOpacity: 1, paintedGraphicCount: 0, rendered: true, svgHeight: 0, svgPresent: false, svgWidth: 0 }, + ], + [ + "zero-width SVG", + { effectiveOpacity: 1, paintedGraphicCount: 1, rendered: true, svgHeight: 24, svgPresent: true, svgWidth: 0 }, + ], + [ + "zero-height SVG", + { effectiveOpacity: 1, paintedGraphicCount: 1, rendered: true, svgHeight: 0, svgPresent: true, svgWidth: 24 }, + ], + [ + "hidden ancestor", + { effectiveOpacity: 0, paintedGraphicCount: 1, rendered: true, svgHeight: 24, svgPresent: true, svgWidth: 24 }, + ], + [ + "non-rendered ancestor", + { effectiveOpacity: 1, paintedGraphicCount: 1, rendered: false, svgHeight: 24, svgPresent: true, svgWidth: 24 }, + ], ])("does not screenshot a %s as a ready icon", (_label, state) => { expect(isShortsIconVisualReady(state)).toBe(false); }); diff --git a/Extensions/UserScript/live/watch-ratio-audit.spec.js b/Extensions/UserScript/live/watch-ratio-audit.spec.js new file mode 100644 index 0000000..01014f1 --- /dev/null +++ b/Extensions/UserScript/live/watch-ratio-audit.spec.js @@ -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); + }); +}); diff --git a/Extensions/UserScript/src/userscript-entry.js b/Extensions/UserScript/src/userscript-entry.js index c6976d9..80a4c76 100644 --- a/Extensions/UserScript/src/userscript-entry.js +++ b/Extensions/UserScript/src/userscript-entry.js @@ -1,4 +1,14 @@ 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 { LIKED_STATE, DISLIKED_STATE, @@ -58,7 +68,6 @@ function getShortVideoIdFromPathname(pathname) { let isShorts = () => getShortVideoIdFromPathname(location.pathname) !== null; let mobileDislikes = 0; let suppressNextLikeActivation = false; -const boundLikeButtons = new WeakSet(); const boundDislikeButtons = new WeakSet(); const boundActivationVideoIds = new WeakMap(); const suppressedStaleRefreshTargets = new WeakSet(); @@ -71,6 +80,7 @@ let shortsLifecycleObserver = null; let shortsLifecycleObserverTarget = null; let initializationGeneration = 0; let initializationTimer = null; +let pendingShortsMutationSurface = null; let activeCountRequest = null; let countStateVideoId = null; 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) { if (!element?.isConnected || element.closest("[hidden], [aria-hidden='true'], [inert]")) { return false; @@ -136,26 +156,22 @@ function hasRenderedBox(element) { function getRendererShortVideoIds(renderer) { const identities = new Set(); + if (!renderer) { + return identities; + } const attributeVideoId = renderer.getAttribute("video-id"); if (attributeVideoId) { identities.add(attributeVideoId); - return identities; } - for (const link of renderer.querySelectorAll('a[href*="/shorts/"]')) { - try { - const linkVideoId = getShortVideoIdFromPathname(new URL(link.getAttribute("href"), location.origin).pathname); - if (linkVideoId) { - identities.add(linkVideoId); - } - } catch { - // Ignore malformed or incomplete links while YouTube hydrates the reel. - } + for (const linkVideoId of getShortsIdentityLinkVideoIds(renderer, location.href)) { + identities.add(linkVideoId); } return identities; } 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) { @@ -438,9 +454,9 @@ function getActiveDesktopShortsActionBar() { })) .filter(({ actionBar }) => actionBar); - const matchingCandidate = candidates.find(({ renderer }) => rendererMatchesShort(renderer, videoId)); - if (matchingCandidate) { - return matchingCandidate.actionBar; + const matchingCandidates = candidates.filter(({ renderer }) => rendererMatchesShort(renderer, videoId)); + if (matchingCandidates.length === 1) { + return matchingCandidates[0].actionBar; } // 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; } +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() { const videoId = getVideoId(); const candidates = Array.from(document.querySelectorAll("ytm-like-button-renderer")) @@ -486,18 +543,7 @@ function getActiveMobileShortsButtons() { } function getExactShortLinkVideoIds(element) { - const identities = new Set(); - for (const link of element.querySelectorAll('a[href*="/shorts/"]')) { - try { - const linkVideoId = getShortVideoIdFromPathname(new URL(link.getAttribute("href"), location.origin).pathname); - if (linkVideoId) { - identities.add(linkVideoId); - } - } catch { - // Ignore malformed or incomplete links while YouTube hydrates the reel. - } - } - return identities; + return getShortsIdentityLinkVideoIds(element, location.href); } 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) { 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 readyForMutation = shortsControlSurfaceIsReadyForMutation(buttons, currentVideoId, { + allowUnhydratedFallback, + isHydrated: isHydrated && actionBarHasHydratedData(buttons), + isStable, + }); if (nativeDislike) { - removeSyntheticShortsDislike(syntheticDislike); + if (readyForMutation) syntheticDislikes.forEach(removeSyntheticShortsDislike); + return; + } + if (!readyForMutation) { return; } if (syntheticDislike) { - const videoId = getVideoId(); + syntheticDislikes.slice(1).forEach(removeSyntheticShortsDislike); + const videoId = currentVideoId; if (videoId && syntheticDislike.getAttribute("data-ryd-video-id") !== videoId) { syntheticDislike.setAttribute("data-ryd-video-id", videoId); setSyntheticShortsPressed(false, syntheticDislike); @@ -715,9 +774,7 @@ function ensureSyntheticShortsDislikeButton(buttons) { likeButton.insertAdjacentElement("afterend", ownedDislike); } -function getDislikeButton() { - const buttons = getButtons(); - ensureSyntheticShortsDislikeButton(buttons); +function getDislikeButton(buttons = getButtons()) { if (buttons?.tagName === "REEL-ACTION-BAR-VIEW-MODEL") { return ( buttons.querySelector("dislike-button-view-model, #dislike-button") ?? @@ -746,8 +803,7 @@ function getDislikeButton() { } } -function getLikeButton() { - const buttons = getButtons(); +function getLikeButton(buttons = getButtons()) { const firstButton = buttons?.children?.[0]; if (!firstButton) { return null; @@ -768,18 +824,27 @@ function getLikeTextContainer() { ); } -function getDislikeTextContainer() { - const dislikeButton = getDislikeButton(); +function getDislikeTextContainer(dislikeButton = getDislikeButton()) { + if (!dislikeButton) { + return null; + } + let result = - dislikeButton?.querySelector("#text") ?? - dislikeButton?.getElementsByTagName("yt-formatted-string")[0] ?? - dislikeButton?.querySelector("span[role='text']"); - if (result === null) { - let textSpan = document.createElement("span"); + dislikeButton.querySelector("#text") ?? + dislikeButton.getElementsByTagName("yt-formatted-string")[0] ?? + dislikeButton.querySelector("span[role='text']"); + if (result == null) { + const activationTarget = getActivationTarget(dislikeButton); + if (!activationTarget?.matches("button, tp-yt-paper-button#button")) { + return null; + } + + const textSpan = document.createElement("span"); textSpan.id = "text"; + textSpan.setAttribute("role", "text"); textSpan.style.marginLeft = "6px"; - dislikeButton?.querySelector("button").appendChild(textSpan); - if (dislikeButton) dislikeButton.querySelector("button").style.width = "auto"; + activationTarget.appendChild(textSpan); + activationTarget.style.width = "auto"; result = textSpan; } return result; @@ -834,6 +899,9 @@ function createObserver(options, callback) { observerWrapper.disconnect = function () { this.observer.disconnect(); }; + observerWrapper.takeRecords = function () { + return this.observer.takeRecords(); + }; return observerWrapper; } @@ -1293,11 +1361,7 @@ function removeWatchRateBarArtifacts(buttons) { } } -function clearStaleWatchPresentation(buttons, dislikeButton, videoId) { - if (isMobile || isShorts() || !initializedVideoId || initializedVideoId === videoId) { - return; - } - +function clearWatchPresentation(buttons, dislikeButton) { removeWatchRateBarArtifacts(buttons); const dislikeText = 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) { if ( isMobile || @@ -1465,8 +1537,14 @@ function setState() { } function updateDOMDislikes() { + const videoId = getVideoId(); + if (!videoId || !countStateLoaded || countStateVideoId !== videoId) { + return false; + } + setDislikes(numberFormat(dislikesvalue)); createRateBar(likesvalue, dislikesvalue); + return true; } function reportVoteFailure(error) { @@ -1651,6 +1729,49 @@ function dislikeClicked(event) { 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) { if (boundActivationVideoIds.get(event.currentTarget) === getVideoId()) { updateDOMDislikes(); @@ -1862,9 +1983,14 @@ function shortsControlsNeedInitialization() { const buttons = getButtons(); const likeButton = getLikeButton(); 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) { return true; } + if ((hasNativeDislike && syntheticDislikes > 0) || (!hasNativeDislike && syntheticDislikes !== 1)) { + return true; + } if ( initializedVideoId !== videoId || initializedLikeButton !== likeButton || @@ -2016,12 +2142,7 @@ function bindVoteButtonListeners(likeButton, dislikeButton, { enableSynthetic = const videoId = getVideoId(); boundActivationVideoIds.set(likeActivationTarget, videoId); boundActivationVideoIds.set(dislikeActivationTarget, videoId); - if (!boundLikeButtons.has(likeActivationTarget)) { - likeActivationTarget.addEventListener("click", likeClicked); - boundLikeButtons.add(likeActivationTarget); - } if (!boundDislikeButtons.has(dislikeActivationTarget)) { - dislikeActivationTarget.addEventListener("click", dislikeClicked); dislikeActivationTarget.addEventListener("focusin", refreshDislikesForBoundControl); dislikeActivationTarget.addEventListener("focusout", refreshDislikesForBoundControl); boundDislikeButtons.add(dislikeActivationTarget); @@ -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) { const videoId = getVideoId(); if (!videoId) { @@ -2045,15 +2266,31 @@ async function initializeCurrentButtons(generation) { return false; } - const buttons = getButtons(); - const likeButton = getLikeButton(); - const dislikeButton = getDislikeButton(); + let buttons = getButtons(); + const needsStableShortsControls = + 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) { return false; } if (!isShorts() && !watchControlsAreReadyForVideo(buttons, likeButton, dislikeButton, videoId)) { return false; } + if (!isShorts() && !getDislikeTextContainer(dislikeButton)) { + return false; + } observeShortsLifecycle(buttons); observeWatchRateBar(buttons, videoId); @@ -2238,7 +2475,13 @@ function checkPageLifecycle() { } function handleNavigateStart() { + pendingShortsMutationSurface = null; disconnectWatchRateBarObserver(); + if (smartimationObserver) { + smartimationObserver.takeRecords(); + smartimationObserver.disconnect(); + smartimationObserver.container = null; + } clearPendingWatchNavigationBoundary(); clearPendingWatchControlObservers(); if (isShorts()) { @@ -2266,6 +2509,11 @@ function handleNavigateStart() { return; } + activeCountRequest = null; + countStateLoaded = false; + countStateEpoch += 1; + clearWatchPresentation(buttons, dislikeButton); + const boundary = { buttons, completedVideoId: null, @@ -2317,6 +2565,7 @@ function handleNavigateFinish(event) { (function () { "use strict"; void voteClient.ensureRegistered().catch(reportVoteFailure); + document.addEventListener("click", handleDelegatedVoteClick, true); window.addEventListener("yt-navigate-start", handleNavigateStart, true); window.addEventListener("yt-navigate-finish", handleNavigateFinish, true); window.addEventListener("popstate", checkPageLifecycle, true); diff --git a/Extensions/UserScript/userscript-artifact.spec.js b/Extensions/UserScript/userscript-artifact.spec.js index 417806a..f297770 100644 --- a/Extensions/UserScript/userscript-artifact.spec.js +++ b/Extensions/UserScript/userscript-artifact.spec.js @@ -4,9 +4,26 @@ const path = require("path"); const vm = require("vm"); const webpack = require("webpack"); 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 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) { return new Promise((resolve, reject) => { @@ -51,6 +68,7 @@ describe("generated userscript artifact", () => { it("is a standalone syntactically valid script with editable user options", () => { expect(() => new vm.Script(artifact, { filename: ARTIFACT_PATH })).not.toThrow(); expect(artifact).not.toMatch(/^\s*(?:import|export)\s/m); + expect(artifact).not.toMatch(/[ \t]+(?=\r?\n|$)/); expect(artifact).toContain("BEGIN USER OPTIONS"); expect(artifact).toContain("disableVoteSubmission: false"); 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 () => { 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 { const config = createUserscriptConfig({ liveTest: "true" }, { mode: "production" }); 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).not.toContain("// @downloadURL"); expect(liveArtifact).not.toContain("// @updateURL"); + expect(liveArtifact).not.toMatch(/[ \t]+(?=\r?\n|$)/); } finally { + expectFileUnchanged(receiptPath, receiptBefore); fs.rmSync(temporaryDirectory, { recursive: true, force: true }); } }); it("rebuilds deterministically", async () => { 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 { const config = createUserscriptConfig({}, { mode: "production" }); 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"); expect(rebuilt).toBe(artifact); } finally { + expectFileUnchanged(receiptPath, receiptBefore); fs.rmSync(temporaryDirectory, { recursive: true, force: true }); } }); diff --git a/Extensions/combined/_locales/az/messages.json b/Extensions/combined/_locales/az/messages.json index e9ad70e..9943101 100644 --- a/Extensions/combined/_locales/az/messages.json +++ b/Extensions/combined/_locales/az/messages.json @@ -83,6 +83,9 @@ "patreonLogin": { "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": { "message": "Premium features available for GitHub contributors" }, @@ -548,6 +551,21 @@ "changelog_support_contact": { "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": { "message": "Return YouTube Dislike loqosu" } diff --git a/Extensions/combined/_locales/cs/messages.json b/Extensions/combined/_locales/cs/messages.json index a34e8d0..61271e4 100644 --- a/Extensions/combined/_locales/cs/messages.json +++ b/Extensions/combined/_locales/cs/messages.json @@ -83,6 +83,9 @@ "patreonLogin": { "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": { "message": "Premium features available for GitHub contributors" }, @@ -548,6 +551,21 @@ "changelog_support_contact": { "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": { "message": "Logo Return YouTube Dislike" } diff --git a/Extensions/combined/_locales/de/messages.json b/Extensions/combined/_locales/de/messages.json index 92c0c8c..ad6cf72 100644 --- a/Extensions/combined/_locales/de/messages.json +++ b/Extensions/combined/_locales/de/messages.json @@ -83,6 +83,9 @@ "patreonLogin": { "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": { "message": "Premium features available for GitHub contributors" }, @@ -548,6 +551,21 @@ "changelog_support_contact": { "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": { "message": "Return YouTube Dislike-Logo" } diff --git a/Extensions/combined/_locales/el/messages.json b/Extensions/combined/_locales/el/messages.json index 9b6c9e8..df3ce1a 100644 --- a/Extensions/combined/_locales/el/messages.json +++ b/Extensions/combined/_locales/el/messages.json @@ -83,6 +83,9 @@ "patreonLogin": { "message": "Σύνδεση με Patreon" }, + "patreonPrivacyNote": { + "message": "Οι πληροφορίες συνδρομής στο Patreon χρησιμοποιούνται για την επαλήθευση της πρόσβασης στις premium λειτουργίες. Δεν λαμβάνουμε στοιχεία της κάρτας ή του τραπεζικού σας λογαριασμού." + }, "githubLoggedOutMessage": { "message": "Premium features available for GitHub contributors" }, @@ -548,6 +551,21 @@ "changelog_support_contact": { "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": { "message": "Λογότυπο Return YouTube Dislike" } diff --git a/Extensions/combined/_locales/en/messages.json b/Extensions/combined/_locales/en/messages.json index f89e816..5001b5e 100644 --- a/Extensions/combined/_locales/en/messages.json +++ b/Extensions/combined/_locales/en/messages.json @@ -83,6 +83,9 @@ "patreonLogin": { "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": { "message": "Premium features available for GitHub contributors" }, @@ -95,6 +98,27 @@ "githubLoginCompleteFailed": { "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": { "message": "Failed to complete login. Please try again." }, @@ -548,6 +572,21 @@ "changelog_support_contact": { "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": { "message": "Return YouTube Dislike logo" } diff --git a/Extensions/combined/_locales/es/messages.json b/Extensions/combined/_locales/es/messages.json index 78634d7..dd3320e 100644 --- a/Extensions/combined/_locales/es/messages.json +++ b/Extensions/combined/_locales/es/messages.json @@ -83,6 +83,9 @@ "patreonLogin": { "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": { "message": "Premium features available for GitHub contributors" }, @@ -548,6 +551,21 @@ "changelog_support_contact": { "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": { "message": "Logotipo de Return YouTube Dislike" } diff --git a/Extensions/combined/_locales/fr/messages.json b/Extensions/combined/_locales/fr/messages.json index 1aa1fb8..fbccbc5 100644 --- a/Extensions/combined/_locales/fr/messages.json +++ b/Extensions/combined/_locales/fr/messages.json @@ -83,6 +83,9 @@ "patreonLogin": { "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": { "message": "Premium features available for GitHub contributors" }, @@ -548,6 +551,21 @@ "changelog_support_contact": { "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": { "message": "Logo de Return YouTube Dislike" } diff --git a/Extensions/combined/_locales/hu/messages.json b/Extensions/combined/_locales/hu/messages.json index af470a6..f5c0709 100644 --- a/Extensions/combined/_locales/hu/messages.json +++ b/Extensions/combined/_locales/hu/messages.json @@ -83,6 +83,9 @@ "patreonLogin": { "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": { "message": "Premium features available for GitHub contributors" }, @@ -548,6 +551,21 @@ "changelog_support_contact": { "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": { "message": "Return YouTube Dislike logója" } diff --git a/Extensions/combined/_locales/id/messages.json b/Extensions/combined/_locales/id/messages.json index 2a24d9c..88643aa 100644 --- a/Extensions/combined/_locales/id/messages.json +++ b/Extensions/combined/_locales/id/messages.json @@ -83,6 +83,9 @@ "patreonLogin": { "message": "Masuk dengan Patreon" }, + "patreonPrivacyNote": { + "message": "Informasi keanggotaan Patreon digunakan untuk memverifikasi akses premium. Kami tidak menerima detail kartu atau rekening bank Anda." + }, "githubLoggedOutMessage": { "message": "Premium features available for GitHub contributors" }, @@ -548,6 +551,21 @@ "changelog_support_contact": { "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": { "message": "Logo Return YouTube Dislike" } diff --git a/Extensions/combined/_locales/it/messages.json b/Extensions/combined/_locales/it/messages.json index 6b3e86c..98462bd 100644 --- a/Extensions/combined/_locales/it/messages.json +++ b/Extensions/combined/_locales/it/messages.json @@ -83,6 +83,9 @@ "patreonLogin": { "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": { "message": "Premium features available for GitHub contributors" }, @@ -548,6 +551,21 @@ "changelog_support_contact": { "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": { "message": "Logo Return YouTube Dislike" } diff --git a/Extensions/combined/_locales/ja/messages.json b/Extensions/combined/_locales/ja/messages.json index a113923..bdceef5 100644 --- a/Extensions/combined/_locales/ja/messages.json +++ b/Extensions/combined/_locales/ja/messages.json @@ -83,6 +83,9 @@ "patreonLogin": { "message": "Patreonでログイン" }, + "patreonPrivacyNote": { + "message": "Patreonのメンバーシップ情報は、プレミアム機能の利用資格を確認するために使用されます。カード情報や銀行口座情報は受け取りません。" + }, "githubLoggedOutMessage": { "message": "Premium features available for GitHub contributors" }, @@ -548,6 +551,21 @@ "changelog_support_contact": { "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": { "message": "Return YouTube Dislike のロゴ" } diff --git a/Extensions/combined/_locales/ko/messages.json b/Extensions/combined/_locales/ko/messages.json index 9849498..6ade487 100644 --- a/Extensions/combined/_locales/ko/messages.json +++ b/Extensions/combined/_locales/ko/messages.json @@ -83,6 +83,9 @@ "patreonLogin": { "message": "Patreon으로 로그인" }, + "patreonPrivacyNote": { + "message": "Patreon 멤버십 정보는 프리미엄 이용 자격을 확인하는 데 사용됩니다. 카드 또는 은행 계좌 정보는 전달받지 않습니다." + }, "githubLoggedOutMessage": { "message": "Premium features available for GitHub contributors" }, @@ -548,6 +551,21 @@ "changelog_support_contact": { "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": { "message": "Return YouTube Dislike 로고" } diff --git a/Extensions/combined/_locales/nl/messages.json b/Extensions/combined/_locales/nl/messages.json index cc7e3ba..bf9c6c4 100644 --- a/Extensions/combined/_locales/nl/messages.json +++ b/Extensions/combined/_locales/nl/messages.json @@ -83,6 +83,9 @@ "patreonLogin": { "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": { "message": "Premium features available for GitHub contributors" }, @@ -548,6 +551,21 @@ "changelog_support_contact": { "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": { "message": "Return YouTube Dislike-logo" } diff --git a/Extensions/combined/_locales/pl/messages.json b/Extensions/combined/_locales/pl/messages.json index 9472e20..a1e69a9 100644 --- a/Extensions/combined/_locales/pl/messages.json +++ b/Extensions/combined/_locales/pl/messages.json @@ -83,6 +83,9 @@ "patreonLogin": { "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": { "message": "Premium features available for GitHub contributors" }, @@ -548,6 +551,21 @@ "changelog_support_contact": { "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": { "message": "Logo Return YouTube Dislike" } diff --git a/Extensions/combined/_locales/pt_BR/messages.json b/Extensions/combined/_locales/pt_BR/messages.json index 0623766..739d571 100644 --- a/Extensions/combined/_locales/pt_BR/messages.json +++ b/Extensions/combined/_locales/pt_BR/messages.json @@ -83,6 +83,9 @@ "patreonLogin": { "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": { "message": "Premium features available for GitHub contributors" }, @@ -548,6 +551,21 @@ "changelog_support_contact": { "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": { "message": "Logo do Return YouTube Dislike" } diff --git a/Extensions/combined/_locales/ru/messages.json b/Extensions/combined/_locales/ru/messages.json index 4ac35fb..0e4ff5d 100644 --- a/Extensions/combined/_locales/ru/messages.json +++ b/Extensions/combined/_locales/ru/messages.json @@ -83,6 +83,9 @@ "patreonLogin": { "message": "Войти через Patreon" }, + "patreonPrivacyNote": { + "message": "Информация о подписке на Patreon используется для проверки доступа к премиум-функциям. Мы не получаем данные вашей карты или банковского счёта." + }, "githubLoggedOutMessage": { "message": "Premium features available for GitHub contributors" }, @@ -548,6 +551,21 @@ "changelog_support_contact": { "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": { "message": "Логотип Return YouTube Dislike" } diff --git a/Extensions/combined/_locales/sv_SE/messages.json b/Extensions/combined/_locales/sv_SE/messages.json index f8cfb35..8321fd2 100644 --- a/Extensions/combined/_locales/sv_SE/messages.json +++ b/Extensions/combined/_locales/sv_SE/messages.json @@ -83,6 +83,9 @@ "patreonLogin": { "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": { "message": "Premium features available for GitHub contributors" }, @@ -548,6 +551,21 @@ "changelog_support_contact": { "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": { "message": "Return YouTube Dislike-logotyp" } diff --git a/Extensions/combined/_locales/tr/messages.json b/Extensions/combined/_locales/tr/messages.json index 0a86ca1..68f3d0b 100644 --- a/Extensions/combined/_locales/tr/messages.json +++ b/Extensions/combined/_locales/tr/messages.json @@ -83,6 +83,9 @@ "patreonLogin": { "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": { "message": "Premium features available for GitHub contributors" }, @@ -548,6 +551,21 @@ "changelog_support_contact": { "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": { "message": "Return YouTube Dislike logosu" } diff --git a/Extensions/combined/_locales/uk/messages.json b/Extensions/combined/_locales/uk/messages.json index c0ececf..598479d 100644 --- a/Extensions/combined/_locales/uk/messages.json +++ b/Extensions/combined/_locales/uk/messages.json @@ -83,6 +83,9 @@ "patreonLogin": { "message": "Увійти через Patreon" }, + "patreonPrivacyNote": { + "message": "Інформація про підписку на Patreon використовується для перевірки доступу до преміум-функцій. Ми не отримуємо дані вашої картки або банківського рахунку." + }, "githubLoggedOutMessage": { "message": "Premium features available for GitHub contributors" }, @@ -548,6 +551,21 @@ "changelog_support_contact": { "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": { "message": "Логотип Return YouTube Dislike" } diff --git a/Extensions/combined/_locales/vi/messages.json b/Extensions/combined/_locales/vi/messages.json index e8b6471..47e5f54 100644 --- a/Extensions/combined/_locales/vi/messages.json +++ b/Extensions/combined/_locales/vi/messages.json @@ -83,6 +83,9 @@ "patreonLogin": { "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": { "message": "Premium features available for GitHub contributors" }, @@ -548,6 +551,21 @@ "changelog_support_contact": { "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": { "message": "Logo Return YouTube Dislike" } diff --git a/Extensions/combined/_locales/zh_CN/messages.json b/Extensions/combined/_locales/zh_CN/messages.json index e7fdd16..c8012a5 100644 --- a/Extensions/combined/_locales/zh_CN/messages.json +++ b/Extensions/combined/_locales/zh_CN/messages.json @@ -83,6 +83,9 @@ "patreonLogin": { "message": "使用 Patreon 登录" }, + "patreonPrivacyNote": { + "message": "Patreon 会员信息用于验证高级功能的访问权限。我们不会接收您的银行卡或银行账户信息。" + }, "githubLoggedOutMessage": { "message": "Premium features available for GitHub contributors" }, @@ -548,6 +551,21 @@ "changelog_support_contact": { "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": { "message": "Return YouTube Dislike 标志" } diff --git a/Extensions/combined/_locales/zh_TW/messages.json b/Extensions/combined/_locales/zh_TW/messages.json index e6e1356..76daee7 100644 --- a/Extensions/combined/_locales/zh_TW/messages.json +++ b/Extensions/combined/_locales/zh_TW/messages.json @@ -83,6 +83,9 @@ "patreonLogin": { "message": "使用 Patreon 登入" }, + "patreonPrivacyNote": { + "message": "Patreon 會員資訊用於驗證進階功能的存取權限。我們不會接收您的卡片或銀行帳戶資料。" + }, "githubLoggedOutMessage": { "message": "Premium features available for GitHub contributors" }, @@ -548,6 +551,21 @@ "changelog_support_contact": { "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": { "message": "Return YouTube Dislike 標誌" } diff --git a/Extensions/combined/changelog/3/changelog_3.0.html b/Extensions/combined/changelog/3/changelog_3.0.html index 58f0e6f..5b92481 100644 --- a/Extensions/combined/changelog/3/changelog_3.0.html +++ b/Extensions/combined/changelog/3/changelog_3.0.html @@ -4,12 +4,6 @@ __MSG_extensionName__ - - -
diff --git a/Extensions/combined/changelog/4/changelog_4.0.html b/Extensions/combined/changelog/4/changelog_4.0.html index ecb9a9e..974f0f7 100644 --- a/Extensions/combined/changelog/4/changelog_4.0.html +++ b/Extensions/combined/changelog/4/changelog_4.0.html @@ -4,12 +4,6 @@ __MSG_changelog_title__ - - - diff --git a/Extensions/combined/content-style.css b/Extensions/combined/content-style.css index 9a3ff42..784275c 100644 --- a/Extensions/combined/content-style.css +++ b/Extensions/combined/content-style.css @@ -20,11 +20,7 @@ #ryd-bar-container { background: #737373; - background: color-mix( - in srgb, - var(--yt-spec-text-primary, #f1f1f1) 55%, - var(--yt-spec-base-background, #0f0f0f) 45% - ); + background: color-mix(in srgb, var(--yt-spec-text-primary, #f1f1f1) 55%, var(--yt-spec-base-background, #0f0f0f) 45%); border-radius: 2px; } @@ -47,6 +43,7 @@ .ryd-tooltip-new-design { position: absolute; bottom: -10px; + inset-inline-start: 0; } .ryd-tooltip-bar-container { @@ -67,6 +64,67 @@ ytd-menu-renderer.ytd-watch-metadata { 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 { background: var(--yt-spec-base-background, #202020); 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); font-family: Roboto, Arial, sans-serif; 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 { @@ -116,7 +176,11 @@ ytd-menu-renderer.ytd-watch-metadata { color: var(--yt-spec-text-primary, #ffffff); box-shadow: 0 8px 20px rgba(0, 0, 0, 0.18); 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; display: flex; flex-direction: column; @@ -184,7 +248,9 @@ ytd-menu-renderer.ytd-watch-metadata { font-weight: 600; font-size: 13px; 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 { @@ -243,7 +309,9 @@ ytd-menu-renderer.ytd-watch-metadata { background: rgba(148, 163, 184, 0.16); color: var(--yt-spec-text-secondary, #94a3b8); cursor: pointer; - transition: background 0.2s ease, color 0.2s ease; + transition: + background 0.2s ease, + color 0.2s ease; align-self: center; margin: 8px auto 0; order: 3; @@ -284,7 +352,9 @@ ytd-menu-renderer.ytd-watch-metadata { align-items: center; justify-content: center; cursor: pointer; - transition: background 0.2s ease, color 0.2s ease; + transition: + background 0.2s ease, + color 0.2s ease; position: absolute; top: 0; right: 0; @@ -361,7 +431,10 @@ ytd-menu-renderer.ytd-watch-metadata { border-radius: 999px; padding: 8px 18px; 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 { @@ -436,7 +509,10 @@ html[dark] .ryd-premium-teaser__toggle:focus-visible { padding: 6px 12px; border-radius: 999px; 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, @@ -522,7 +598,9 @@ html[dark] .ryd-analytics__window-hint { flex-direction: column; gap: 12px; 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 { @@ -547,7 +625,11 @@ html[dark] .ryd-analytics__window-hint { padding: 4px 12px; border-radius: 999px; 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, @@ -624,7 +706,10 @@ html[dark] .ryd-analytics__section-expand:hover { padding: 6px 14px; border-radius: 999px; 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 { diff --git a/Extensions/combined/images/hide-clutter-after.png b/Extensions/combined/images/hide-clutter-after.png new file mode 100644 index 0000000000000000000000000000000000000000..10fb1647a5fda265c323461f09b28949be2621a2 GIT binary patch literal 3552 zcmb7HX*iT^8=feYEM?zH*|R5ECR>)6EMe?x!pJfvV;fn72xuBX=-^=y#gEgM9^TA}j;k*}hCch&rZ>&d+mBV+k$>>2S2$lhCiIGvrNrcSyk)mAi(~z(%k`E7-qbH*HBL92WJk1AIeh~j zZ|5{knqxkBXCA|b)+~I&A!6|IHqr>6#s@+PmZ(1*ffK5Sv~3LmNpd9(aIFd!!< z$M1bMsf!IzcfO&af#_(BvjG;W1%e@2E)rZ<+J@s>Rz^ohXJ$m00rDqlvY)K7v$M@D zETl5~2~fC%v9N+`OO|RFZPS|t@X#0vRq$P>)3z9wlL!9i7lCnS1B0$|Q?!(nlmOu4 zjm0IIQNqEmR_^4-b^_MNuOHov+>ys?umFCa$sbOywySn1$@Y?H_Uh+?BiR6R%o2+E zZ*c>6?ty4rPWoDBWA9obaFC;`qJpn<%YciffZx0s4z{+oGCZuAdyM^&GBD6D1s5x; zTSKT5fT$;rZ(mY0Jie?6kkHc?=d3uH^IjPcd(uY9crsJ@*9wy!(`rcmBHw)gAXyI% zFfpRz3Tj+8#;_#`6xdE))F7=}_s5RNI&!6aFSdn%jdnMd-M!65fKM+zkTVjXiHR^`0K&2==#MK9{$7~md3_Sj~4P@t*>zz_qtdhv`F{Jhdgs^h4O+`>Z9Olbv2c`xhDjV1@!IQ-Q1|g z`jx&uT$!(FYxcEQ^(X|otwFpl>C?xzxEq&X-hozYLIW`}JB|tM{Sk2mF}59$MO6d= ziH!1q0wwHsp50BoaaG4wPCNQNGSvUM%eM_vd?ZKr?(UmqqG7qC^U3?jxigrI0n=h- zpXcZdZT|cL+(oO2pjDy4il#tFdE&gHIoauw{PM-Ar?NF;v!M$S2eK!YHq64LWL4%* zTBKcYU^RAC|8Y~(&}aI9a$6$`D#2Xf)}5{i7EVzHYtEu% z9Dl7fFP=tVx%H>W_q3PD4Uty(={cG3eLo=x1bfRoCz(3K!g+CYicHyn9GF!Ns>rlo z3nEggbh$Wz9J%18n8W?+K0^#ZA6GjyvZ_a&xXf$90l@u1@~I4FJ=ru7fOXs=AdOYx zbgT`717%G8|GA_{8)Ru`XV-C6Nr}_jqr9snlzc>65w#N=^afQB!C)}X<{2$^80+-4 zeJs*{A#O3SmD1Fc!k_6Okd}D#+|Jh4=r|L=;5GyT$>ry%!@Z%hoUqRj?=lp-Kv1j_ zu$&#<-VRa83lRsjs!=ORb8}idrKsAPnncnwR+ZC&Jwe^`6IewuI}eAXy{oG$7;I3l z!)(MNm}q9ea$haT7u-i>VVBM*4QyLL-F(^n>QBA0x|-)(4>tz*T0M3mM8Tqd;+edp!C(yQn6Lw*+WBOpWs2|NUk+YxJE!+N z!0C^eQ&QVGlDYz|t*KOM_-rUYGu-N1mVe_|x%tI$VXuIYzbVb%h6?mq_O^+4*9lug zhX=bz`bOWHUEKa+vUJf)f5-h!&ps4%qVARg{b2pWF@5W9*w#zs7aSQM4!cgOtA&DL zX&16Zs4p>QqnUSgK-}BN;Y~5XTJ^c7cTl})3xaArnRj&$x221t;LC$~>Lf(z@7_qxZ5V7Y zMnH#Wda_2&v+Uz?aqD_Jk@Z{TdRs|J zN%*&DbPnz-r!KvYHbj3Q-g2AIjf@QnDe!8?;cx`Fx*BgsB`H=Mhz<@0MlI+0)Vkm| zd-&bY1WY$oqd2eg>hSDdnjo1Z$no7&74cows#9-}Ol=u_t$Ia#WZs~CH5KurB&o8p z5+$P9xz>3~75i@Ui?aVrZ6nC5rnEQDVuE!di=(7CxU9mQU|7dE>E}|wQdECUjYEAr zA4KDY_Kt(SJ-zmd)R&WUB>tO$!T; zkDOT>+DN2*aj;A(8;(S<;@=m??62-q$%9f*Oul%)-fQ^7*_)oTJ7Yk<0$-`F5iC@j zGMOo;+fV%^!XvZFSED|+Cx#z)M@Ap+Q-5x{BM(FOw|&3z7HCDlKNo5H(NdJ!-$&6l z>Du^AaqgYnaL5y0B@im;0tZUc(Q{TM#=;trDAUj$el9T_iOH%yacVIz?vJc%^+`?nlMbCB{dVo?RUs z9Rp~ks-?yA7GC%;O8)TT%!Jlc8&BDRwDQObBTHUiUtgP}{BBRu=8w`BZUltY)`ZZO z$O2p^{dSmYj;Op+-B`qeSyFqpdE^TYsZi0r+mS|hy8k&xOnQB^)hAdU`wVw5s+?Ni z0)g-w)tA|SFtP`OuLFU8@=6N=Xm++@Ymw~l?*%z?632kh0vt^qI@Ky2BZjkmtznbS zHUEei_28aw)%v!uGsVPeX}&9IGJYF{3bj`4FRys)lIL5<+s_?TJHa5Xb5_tK1%_B< zyL7oKg%EL}qj5;s=PsN+Gw%PP+m1KRXmr48IpJ)nZX>2>SrjoBH#IfY-cBE9-;~G| zGOu*x295`Z_pPw{scmKbr94oOAgt~vO`HL{y3TewHU}2kIk>yuMK8x3Qth8<7Dw+) z;_;dbC@op=3v+*LM$+24eH41t37vZVMzI10XrRw4ne-ZsDJ-Na&zouvy2sFZ5dKW; zSo5sQ_6^dLi1Nsl=>b$om}~=<=?dHRsIOL%_>mD3ag5xT7=L_p@CJ{^S5z1u?#%>$ zKHTUNeWA5^W|dOYOd3%M4GDdmcX4JJ@;Bb;9qzYN=s8TP_*Nd@eqpyd17lCyjclXW z(8(Ke8^XUw;-$*@bbM_y2%Vf6`TOKs~J?s zz}oRWQe)Fi&}l-%R+4B8g8I|e&Wq>$(w!MBX^f$gCW?7xtobVgN*@R-MUkUR9;ld_ z3`vcLv9tP7jDgY`Mgby^2ovT|>k_cAD^sJBTp$?sr&9@I#S|ue>iD^HTV0&Gb(c4W zjYS#cw$Dx@1@9Lcemj@lwOB3 zX^ZP29crk8N~_gtVL2zgq*QWN6K`o3P21FwKHfjz(|E@B`!x)t2jOC)5E^lawvO(8 z*9yjPYvFCLeQ<@C78lS~+V$^|q7hY)EY-*JW_)yX&Tiv=f*7#Ha(*s8_a6y0|QqS%-BQR^zM}K?KABOJdezl6Yq$%X_DSn3T^!zeon;2>0g_WBu!`uaA!tmDPPz#LbQ##Nkk!}| z07C3`BSU8!d_McV!6>BME}inrC#8${Kp?%ny$r{HQ59K=$e?46s6(0=;ABHFMjCkj zAtE|I0sV&gvVKGaP0Yf`=u~uBR5B1r^c2$+<6%uVu-Pp;2I2lh9@VSj{N?+yEtm9Y rpar$#(*VHnF~Xsf5z_!He*zja;MwZ5O##wf`cDaDWMNpVe=q)Dgt_N6 literal 0 HcmV?d00001 diff --git a/Extensions/combined/images/hide-clutter-before.png b/Extensions/combined/images/hide-clutter-before.png new file mode 100644 index 0000000000000000000000000000000000000000..ae80eee3783da7104206f3de2a224d6339eadb01 GIT binary patch literal 6320 zcma)>Wl$Vjw}uH2BtQZa+yaC9;1)D9xI=&dgS)%CI}Gj~2ohij5(c+GaCZq1ToNEy zfbh*Z`KoT+x_|DUuI{~8_p0vQYrRju9iyfqi}#HD843ytp1d4b0|n&?BXU0&3l+I# zNz)ugLBU6n2TN&r=N;$!*lWGL8&p-id-nc~V#P5R55DHRXCbq*MUjlsx{Xp#DwVZn z!~du!D8vFL!zHR@@V ziSl}7yPdAKcDM-_g0t6mR`-M_e8w;G+)h(qsi|-HaLIVg_a}=Vu~T6%DSCkXYhORV zx`VX>WsQ-9VvTJ$&3F8xNP;i?TveQdIDm=DWGUqIsU<< zR~u7P#g0l=kDwq?mo0Q@N|+qIKnMZV>fvs#lXgqEf#YZvH&zfB2%~=@L=tI#+>)Ye z+KJv}F3tj@$1cmX-+grfJGj9#WZF}tPbotsp%Adw_4W0|$V-x=Px&6^qZr;2pX-Rg z?ny)*)1%4030E8pn%xnW4=1v_!;HynsYmdnF)RsaK6eQegD!b+CV)i=2p0v z)zk!QzM*pCza66P8yX^~C-F?Xq%1lnEw2R_4QTbV(&_LCs_||l5YTm#X!n&2> z(a+o3jayf#n93uYTBG=fti^FZp;Nfvi^s1wyX0VlM31^>AYc;FT=PoN56Tl!Pf7;= zhbBq5LSc8&k}d#oMaq>JD^F+_%hEJF89%mVs9qSUV@*s^m5P_qh7ywy4H0G-@a6yR zd8SzrZEf<+twy=1>}g=Ha=Yo=6k`nb^JapV#y~R-QnuI#OemNlN4WMwQi@vSDyOYC zGZ|n1OGS}}Vvgn4O~r5Un~usaTzuC8`!bmouJdKl7$Cb*c>|HahIeedN}?1>fcY?q zdm;*B+~G|%r5D6mz~8)o*vLu9JS`HtndJpgIV+U5Ar?&KGFESrL*#{Q-9)YdeLc%m zvN2@#JZ7>dQpKiyJLHyL>H{)wIxM>NO9?9mfT}n@4BAZ+q=a3Fc!LeaOqqv*26RC) zWMU|CDwCO5wDp`(Kd)>8Po9Nm5)BtxUopzOXqD-wL^mCbWzpRCw;HwCU)<~GRGgZU zb=kk}?v^QAbZ*5WmH2=r@?h$r^-(Bhr#882Dhg=a2XD+L2IKh^zi^@>`0TF@eWdcqF*W-D-gR|gq!_SriEWqnMNo&k%a@}WP%CB zx(mnjtCx+)(kRfqv%&7`p;!MZm%*@Wt&dOa<-^!*ItiyI#wFDRfk#Z+15TS8=+mDr?|DGcq<)K2WB7LK^0)ueWMp6G!{mCD|k zo11r+hpmM0Kqvc)6`h>8&z8*us zeP1mFoxNop-7ZVlv$dwCW_8uTyc@j5V@#NZfdLGq32@C-hAr=W|E@bZkcX~chg?@( zd3F~#0i6tfjJ{NEa0uwv-m712^W{n;_xp#3Ku_bia$;Luh|n-?JrIo%un>0Boh<}c z$8+KKR3=+o@x8SZ0&r*c5mt>dX+F((>^9$xqV;j>Ql_O|_A-Eu!aFWq=bdp)Iirko z^r{^Mg+SV?^sHy)$GG@BJ>ej-kC_#bW~!SKgfoLNO0(bh_L9i?W99N90$3KTok9gX zNZp{L@&)B38rYN~{x{C1MA4V)em@c@h0oW#HUjQWT4Ho0^J@%R0cOQR32k0K3Pim) zS8k5#x=vSGl5nZz0%^+5p#U)eyz(OJ$W-N=k0?EMVMomo$VUG{;vN+F5!v^RnrzcE z4NlJnUv=#j_!VU$b~5xGS=ialOAp~@gX=DXR6>!stRMqf*}==B1+!+iW%pnEQ&!`7 zw5Q#2ByIrv4i|!ySV1_LqT%%^Sp0UsG^z7qTvWfsF(FsUsm6)CDA4Rzb3XqLY%pWt zIDV1%L1(`#i`V`sQG@%zbWTprFxGP_5#%-1ukJ1;Bm`$O6(}mpZr8zSVR?v`cCQ8U zh23^#Uj;Neu8C|tk9X&0g3XVFC2O^FX38<0^}kd#`0Tvd_d?>kP_uttVeFIO^7RQ@l0xhuQCL@{D>pX8FQ+D zvR-R0&GbN`x)E$;TiO~Tb!2t$J1jT){ph&9Trm1^aBy&R^v0^_3#iOIM-%5>9ANJh zv)BL!Ny_sTKDh3GG1m3?$8V-wdEaPjJkY_w@H>u2qAxT;SvO0Hd$zH?9JuNb zg>Wn2h>;)g%S#^VxIZ8HWHn*gb^T-Galt4EuJ&_;tBYpMbY-CyDr(XjzQ}8n9A>Jl z{I=K<(h{Cm|6WWE^^Z$qMTpH_3&(;>dx|f^4a3?7s3mYO-%I@A*9@3)zK*cu=odGn zq$GXGuzL7lx5dQ)5%}PH|5zMFE#h%V_?rZPY0LTy5i585jP--qTw-XX%Y1BhLVA}x zD0)+TF7w0PC0`S0%BhUyWDP@dhV>Om5Du-#*QZ@t?St6wd!x6V+RrdX@bvZc#tS7T zn(1ixqFViL8=8UEM3aFj9%+#w?TAsD%5x&3r)fc{3tuC38_d5heX=5`HfuJjunrpB z!b$$JwP-FpZg@0&Ab>@e+j+AUhvhiLx>We^@K8l;Pr^geadF5>q5m$3jH4i_vAaY+ zXUZs}yGUV{rYb?Pm+`8Jjl8dh;^>VH|J4!y6C6&fG8?FwB4|tXyT)sM_qc|Q9q?Xs zjk=C-;k*d81x!5X2?69xcadjFB{Mg7Wq!VVtw3h#&wRIrEQ51YibX+(cKQr|AxxQs zL(l#D5P>`MGIZ!22ro{UXzis9PzhSi=c>oNIE@uI-_-O;f{1PbfZn-J$nZL^2n|~g zWv$Kul{lwllf@&wX^b{XdXDu;x>ejH1K&@^3~65(YI}EJ!!VVoXY%K6jkiOLHXUp% zBsLDbxx82c3mQodoRzE^%N8E4Gm3VT=-Cfthqpr4dO9jYJCXqb0e*gMzFEHCNsRot zgZ}uNV!WJrHHZ`KkEWH*Q(^DW8)BpyQ3qY5Fa}HS3mU73_I}Si3)I z*(k`&T$seai|C22bY5O!2@u2c;tR9)J=)}a}^y>9S zP?F*AuS|tt?Q${LE~}EjTV;VCbzqFO4s6y6J8_j<@yGj%u7@j%!H_BpdRG)`oRtdn z&Jef}JzA=HBfoWNtD0t`zrs0Z55*1=mL1xSFx;{>0KU>Cf1*kfXrdMp|DBJd}2zx&hO%{z_4e6&k^efgh zZKh^QS?C@B*(OqH66vZ|ckaRUc{_LPaXO!_l40sd2LJZQKX-EEXF>OdpDf3|e@!j) z*(LMf3hisz2)ygX;W}RL$o(7%HWUociu~Hq;dgC%ayR%Y$p7GdX7orm?tGKof~mma zG9f1>q#oaEs~>xKv_I*ho@5c4X0iG`2ekclLdBAPw3ONwKc!1 zV_L(I1W#(8@37-zd!;!*X2^H#7Jg$o&jF<-xYA9ksyH`GJsb|h1q^L~9}*UqeLc9V zOrYr6MC!~|?vdyt863Gtc`0!~3c;^~nK^V^r5rx&QfI4jNh-9F-&s1{=)VWzNMc4l z`(9Q})gpENqq0t&A(dOHCvMb>SBzodPlGg7l?)wiH2U<=4W4HUR!k0w{JGnBY}oji zHqJ;`ec&{_N;YXtupjZTy=kXX-S^FXTul5U1!39 z${Cd$5uV&JGm+=ic^kPZ*M2##ztZf`mllKkLLx+E)EaS1`Z_b)9@!MV zRvTH1N;j3}9ldC>df}}|Z(ZclhsqOF2FrN$;Eu~@XA#N!BxTCi$5sdf?q>y(>#J!Q zj5INPY8eW5C&4g#-gh(eAfRj@x1!Hwg*Qm!-$pJ%VVAU{K#5_wrR2TpqzMM-8?M;b zmEZGI#-z6((a7~FNh*B2shba!Jjq7vQZH&oH8XHt6GN=8R~JF94bSTN6k_k_SnI!; zxguGBwg(4gCt`%VK`(o`Mi5!4#z)F(tVdy%2It31pLFlXhKEy}n%&X%eW$WO^2V(= zQOXnfqI(#*jJ0vX^Llk08Vr1@5jflD_@J~<@1m@$kOZg#~@gwz{_j2Y4G44l+y$}$jef^Z*b$^eHKy1SfP>+1mLka zXwp*7Xkh^V9}Ee1fO2whoG`|6jFfYal*;RY;5wFc?PYPyE;y9t*)srCIy*egkY zVYbTnH!by=SXl4_4-b!l&_H-z|M#Yf)3hZlt^;6f>#3o1_fF)CS1PNR(}0&ft1N%d za9is@uNx_rF%bQP-s2u6=$P?09(GC{CYVNddZdlXSi{D~M%O~a^>{wQWhUN{twlrH zKNILYBxfnOjeHi|(`#lw5qat)ZPrsI_xJbEHqT0ZM%99L-^)WJ?WuIp{C4sD&9>NX z(FrXi{6ZsZ$I516BH0d#%}N;FOIuMECv%eX!5gQS&e5B+xH?{vO{C=ht$>kkVhrv1 z;;Umj;#4Wbn%XSm;c-pc9`pVy<>}e>;3HS)!`(H-hNzCACV=MSN?cqEA7;RCMQx1O zeqy_a#g{MHyjJT-g1y)squ{sIIzByYFpW6**l5ElvB$KnxNZ6Ks00-%lSnB)s>TX6 zQRHQ*nhv~JXIRDJWo{>5ZnW3p)Y7E+51B46&$kU`tE*Jw;Ud;UwZ^d~6e?Fch!!NqkgI>MZZjIYN9Hw+D4!mF z9eD2z{#F?D=eD-(G}H`P*O(B1ivLN1lAO`hd4j?b3*O|U6x!8!z1$O5n)46%442kP z)?SSi?-I)^^T?(bN;u=W&AxnSb9HqUa(q`~_a<}if`pkI)KbEVuHZF~-?VG9Aa}n# zEbk-#q)e=Pb!5+}?!D~bvs#uagSyZ(4))dg<~xrcMKDgmU}#+x#2tr!s497bMc-B) zqM0fZtr4JdDKv2WE1q#Vl~p<9&Fhmd@~MnSNZ&u-8HskKqN3v9;6PSz_{;fIz2!G( zj0{g96R!YY_nBksJ}?K1C9v;L1t_#=k|U|Zc1BwTN{Vdf>+L?gdWR_tisj0=^K)|$ z@8YJF`)r^i)Phb)Lk(~xj9??}&q#n#%O()GE_*}L?btr}=Sdhy%=U*HDRXT$j;Irw zF+&-ea9tD1t3oq4>`@@tw+3RkWR4YwlL|rJ14~NW$9dubzmeFEbWZ}#wg$*}Ep`9I z0#Lc~BI`|TR!h^f6P%5)u0_CS16BF_#BDFqJ&xv005}I{>m7dBOg&e`YbTAn4-pZ8 zTK*1-lY3mhWWDq7O^FDl5vwRJU4hBj*}_)ilow`MWV&=_rHE#c<;$t{)IX+^wHjmq z$gh}LiOMir{mM@7PHW>Q zB--~&UJwJT$_2mWiyxev@VBn)pSJxlZ1X(n47hV_I}wOa!&R)Tu6bL&0;AO?u*g!D zT}l|qRY+InBb=1ND3)QzL+_oZ7D|#4b>Jz#<5q-b9~~XRf_u)A!yHzcKX!EF>!OxU zr&IwM#LC<*6lzyYJzx70?7!3Nf+K3|MJ<84RQoJ_bvU+uNMM5LgLoS~j@}u zCX+Sf1)Aem-VrS)(h{eKE7##420n!efFLc*%1_^Fj2)EEYcJ~QC~tX8*O$2+FTy_$ z!Uw&ZRx&yCJs%&g&4L?kW*G00zUMIJEB~KAH~Wqj>X@E~mX|;D7r;h2ql`${=Lg~; zQ|2&GY8phxi!0=<-8yqrtYpE*d_g;FYssR;4nM)Wi|r(dKbujnC>{5atdH>INm&}h zI@p3wY>^(Ea-;8>dsLUUwdr39evv>@CX%u6F}YifXm^zMk$v;*K7a}LNhU9%(ArsQ zBS}7}wBvtwYmkbh5*;TRFgw0=?SI;J_@b$V2=dmprL#Wc*g2;D+pnJ`ofb%q?tr>G zoHs)i=hQZ7nDJxTU3~$fwnK6#`{EpROGi^gWAzH7n)wY30ZejP9i`7KulcFrQZ8 z#Gc~S-Fm&s^3)XUGc51FIt4{ka0PK0y$vP(PNDo!&|g><~hG#~mm9m5i$0_fRk z3nX&7y_j~0rEc4quJ(Rx^@{0#`>)HyKt&fqu~*6j_)3E+i!^r4Ouk3{Mr49{bi1d% zIsW55MH$xxss7g##)1fcUQPJf`ZC6RGNjamh>6NB4au>UH+s}D^btSr9-vSta$Sdk zQXo?B(8c}U2z^Qq>UcT~);L-I2JsKny`d{e14C3qgi$#sym$@o zvb_kLqV>(_C8Ls%%T~Crn~k1|N+N=yPxP~ys%p}X7pWpWfGB<=Srym`Ss$g^am6j1 z5VT(eNf&#2+Gp1*&@L_v9ce8sASh5YY;@egF6p*VHAZGOF}_>CMvk%y>&Yo(1t{{!*} BGJgO7 literal 0 HcmV?d00001 diff --git a/Extensions/combined/manifest-firefox.json b/Extensions/combined/manifest-firefox.json index bf727fb..dc33aee 100644 --- a/Extensions/combined/manifest-firefox.json +++ b/Extensions/combined/manifest-firefox.json @@ -4,56 +4,41 @@ "default_locale": "en", "version": "__RYD_VERSION__", "manifest_version": 2, + "incognito": "not_allowed", "background": { - "scripts": [ - "ryd.background.js" - ] + "scripts": ["ryd.background.js"] }, "icons": { "48": "icons/icon48.png", "128": "icons/icon128.png" }, - "permissions": [ - "activeTab", - "*://*.youtube.com/*", - "storage", - "*://returnyoutubedislikeapi.com/*", - "identity" - ], - + "permissions": ["activeTab", "*://*.youtube.com/*", "storage", "*://returnyoutubedislikeapi.com/*", "identity"], + "browser_specific_settings": { + "gecko": { + "id": "{762f9885-5a13-4abd-9c77-433dcd38b8fd}", + "strict_min_version": "140.0", + "data_collection_permissions": { + "required": ["personallyIdentifyingInfo", "browsingActivity", "websiteContent", "websiteActivity"], + "optional": ["authenticationInfo"] + } + } + }, + "browser_action": { "default_popup": "popup.html" }, "content_scripts": [ { - "matches": [ - "*://*.youtube.com/*" - ], - "exclude_matches": [ - "*://*.music.youtube.com/*" - ], + "matches": ["*://*.youtube.com/*"], + "exclude_matches": ["*://*.music.youtube.com/*"], "run_at": "document_idle", - "css": [ - "content-style.css" - ], - "js": [ - "ryd.content-script.js" - ] + "css": ["content-style.css"], + "js": ["ryd.content-script.js"] } ], "options_ui": { "page": "popup.html", "open_in_tab": false }, - "web_accessible_resources": [ - "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" -// } -// } + "web_accessible_resources": ["menu-fixer.js"] } diff --git a/Extensions/combined/manifest-safari.json b/Extensions/combined/manifest-safari.json index 0c7f8e3..f6ca020 100644 --- a/Extensions/combined/manifest-safari.json +++ b/Extensions/combined/manifest-safari.json @@ -12,12 +12,7 @@ "48": "icons/icon48.png", "128": "icons/icon128.png" }, - "permissions": [ - "activeTab", - "*://*.youtube.com/*", - "storage", - "*://returnyoutubedislikeapi.com/*" - ], + "permissions": ["activeTab", "*://*.youtube.com/*", "storage", "*://returnyoutubedislikeapi.com/*"], "browser_action": { "default_popup": "popup.html" }, @@ -32,5 +27,6 @@ ], "options_ui": { "page": "popup.html" - } + }, + "web_accessible_resources": ["menu-fixer.js"] } diff --git a/Extensions/combined/menu-fixer.js b/Extensions/combined/menu-fixer.js index 73649a5..7cc5acc 100644 --- a/Extensions/combined/menu-fixer.js +++ b/Extensions/combined/menu-fixer.js @@ -1,61 +1,511 @@ -function debounceAsync(func, wait, renderer) { - let timeout; - let lastCallTime = 0; +(() => { + const HIDE_CLUTTER_ATTRIBUTE = "data-ryd-hide-clutter-buttons"; + 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) { - const context = this; - const now = Date.now(); + function hideClutterButtonsEnabled() { + return document.documentElement.getAttribute(HIDE_CLUTTER_ATTRIBUTE) === "true"; + } - if (!lastCallTime || now - lastCallTime > wait) { - // If the last call was long enough ago or this is the first call, execute immediately - lastCallTime = now; - return await func.apply(context, args); - } else { - // Hide all optional menu items - prevents endless loop - if (renderer?.polymerController?.flexAsTopLevelButtons) { - renderer.polymerController.flexAsTopLevelButtons = []; + function reportShortsActionBarHydration(event) { + const actionBar = event.target; + if (!(actionBar instanceof Element) || !actionBar.matches("reel-action-bar-view-model")) return; + + // Content scripts run in an isolated JavaScript world and cannot read the + // page-owned `data` property directly. Reflect only the boolean readiness + // result into the shared DOM for the synchronous requester. + 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 - return new Promise((resolve) => { - clearTimeout(timeout); - timeout = setTimeout(async () => { - lastCallTime = Date.now(); - resolve(await func.apply(context, args)); - }, wait); - }); + const flexibleItem = convertTopLevelButtonToFlexibleItem(topLevelButton); + if (flexibleItem) movedButtons.push(flexibleItem); + else reactionButtons.push(topLevelButton); } - }; -} -const fixYtdMenuRenderer = (ytdMenuRenderer) => { - if (!ytdMenuRenderer?.polymerController?.maybeUpdateFlexibleMenuImpl) { - return; + return { + ...data, + 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, - 100, - ytdMenuRenderer, - ); -}; + controller, + { + 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 observer = new MutationObserver(() => { - const ytdMenuRenderers = [...document.querySelectorAll("ytd-menu-renderer")].filter( - (el) => !fixedYtdMenuRenderers.includes(el), - ); - if (!ytdMenuRenderers.length) return; + const resetBurst = () => { + lastCallTime = Number.NEGATIVE_INFINITY; + observedButtonCounts = []; + stabilizedButtonCount = null; + }; - for (const el of ytdMenuRenderers) { - fixYtdMenuRenderer(el); - fixedYtdMenuRenderers.push(el); + // YouTube's fitter removes items from this array but does not add them back + // before a later width change. Retain the widest prefix for the current + // 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, { - subtree: true, - childList: true, -}); + + function fixYtdMenuRenderer(ytdMenuRenderer, { refresh = false, restoreCandidates = false } = {}) { + const controller = ytdMenuRenderer?.polymerController; + 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(); + } +})(); diff --git a/Extensions/combined/popup.css b/Extensions/combined/popup.css index 7a4a19c..e8fcb65 100644 --- a/Extensions/combined/popup.css +++ b/Extensions/combined/popup.css @@ -24,7 +24,7 @@ border-radius: 4px; font-weight: 500; 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; } .m3-btn:hover { @@ -58,6 +58,14 @@ h1 { 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 { padding: 0.25rem 0.5rem; } @@ -143,7 +151,7 @@ button { z-index: 69; height: 2rem; width: 2rem; - transition-duration: .25s; + transition-duration: 0.25s; } #advancedToggle:hover { @@ -269,11 +277,97 @@ input:checked + .slider::before { display: inline-block; } -#server-status { - height: 72px; - width: 90px; - /* filter: invert(21%) sepia(100%) saturate(3618%) hue-rotate(102deg) - brightness(96%) contrast(108%); */ +.setting-row { + position: relative; + display: grid; + grid-template-columns: 30px minmax(0, 1fr) 24px; + 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 { diff --git a/Extensions/combined/popup.html b/Extensions/combined/popup.html index 6b16c93..68f9c72 100644 --- a/Extensions/combined/popup.html +++ b/Extensions/combined/popup.html @@ -1,97 +1,94 @@ - __MSG_extensionName__ - - -
-

- __MSG_extensionName__ -

-

- __MSG_textDeveloper__ -

+

__MSG_extensionName__

+

__MSG_textDeveloper__

- - +
- + -
- -
@@ -114,28 +111,22 @@
- +



+
+ + +
+ + +
+
- - - + + +    

-
diff --git a/Extensions/combined/popup.js b/Extensions/combined/popup.js index 665f123..1faf6d8 100644 --- a/Extensions/combined/popup.js +++ b/Extensions/combined/popup.js @@ -1,4 +1,11 @@ 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 */ const config = { @@ -13,6 +20,7 @@ const config = { tooltipPercentageMode: "dash_like", numberDisplayReformatLikes: false, hidePremiumTeaser: false, + hideClutterButtons: false, showAdvancedMessage: '', hideAdvancedMessage: @@ -95,6 +103,10 @@ document.getElementById("hide_premium_teaser").addEventListener("click", (ev) => 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() { const loggedOutView = document.getElementById("patreon-logged-out"); const loggedInView = document.getElementById("patreon-logged-in"); @@ -104,16 +116,30 @@ function initPatreonAuth() { const userAvatar = document.getElementById("patreon-user-avatar"); const userName = document.getElementById("patreon-user-name"); 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 sessionToken = data.patreonSessionToken; 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. 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.membershipTier && result.membershipTier !== cachedUser.membershipTier) { const updatedUser = { ...cachedUser, membershipTier: result.membershipTier, hasActiveMembership: true }; @@ -147,6 +173,10 @@ function initPatreonAuth() { function showLoggedOutView() { loggedOutView.style.display = "block"; loggedInView.style.display = "none"; + userAvatar.removeAttribute("src"); + userAvatar.style.display = "none"; + userName.textContent = ""; + userTier.textContent = ""; } function showLoggedInView(user) { @@ -179,8 +209,12 @@ function initPatreonAuth() { } } - async function verifySession(token) { + async function verifySession(token, generation) { try { + if (!(await hasAuthenticationDataPermission()) || generation !== authenticationGeneration) { + return { status: "consent_required" }; + } + const response = await fetch(getApiEndpoint("/api/auth/verify"), { method: "POST", headers: { @@ -311,63 +345,108 @@ function initPatreonAuth() { } loginBtn.addEventListener("click", () => { - // Request permission immediately within this user gesture - ensureIdentityPermission(async (granted) => { - const identityNow = getIdentityApi(); - if (!granted || !identityNow || typeof identityNow.getRedirectURL !== "function") { + const generation = ++authenticationGeneration; + const continueLogin = (consentGranted) => { + if (generation !== authenticationGeneration) return; + if (!consentGranted) { alert(chrome.i18n.getMessage("patreonPermissionRequired")); 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) { - githubLoginBtn.addEventListener("click", () => { - ensureIdentityPermission(async (granted) => { + 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" }, (resp) => { + // Delegate OAuth to background so it persists if the popup closes + chrome.runtime.sendMessage({ message: "patreon_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")); + console.error("Login failed:", chrome.runtime.lastError.message); + alert(chrome.i18n.getMessage("patreonLoginStartFailed")); 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(chrome.i18n.getMessage("githubLoginCompleteFailed")); + console.error("Login failed:", resp && resp.error); + 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", () => { - chrome.storage.sync.remove(["patreonUser", "patreonSessionToken"], () => { - showLoggedOutView(); - chrome.runtime.sendMessage({ message: "patreon_logout" }); - }); + authenticationGeneration++; + showLoggedOutView(); + chrome.storage.sync.remove(["patreonAuthenticated", "patreonUser", "patreonSessionToken"]); + chrome.runtime.sendMessage({ message: "patreon_logout" }); }); } @@ -405,43 +484,12 @@ function initConfig() { initializeTooltipPercentageMode(); initializeNumberDisplayReformatLikes(); initializeHidePremiumTeaser(); + initializeHideClutterButtons(); } function initializeVersionNumber() { const version = chrome.runtime.getManifest().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() { @@ -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); function storageChangeHandler(changes, area) { @@ -543,6 +600,9 @@ function storageChangeHandler(changes, area) { if (changes.hidePremiumTeaser !== undefined) { handleHidePremiumTeaserChangeEvent(changes.hidePremiumTeaser.newValue); } + if (changes.hideClutterButtons !== undefined) { + handleHideClutterButtonsChangeEvent(changes.hideClutterButtons.newValue); + } } function handleDisableVoteSubmissionChangeEvent(value) { @@ -609,6 +669,12 @@ function handleHidePremiumTeaserChangeEvent(value) { 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) { let formatterNotation; let formatterCompactDisplay; @@ -642,24 +708,6 @@ function getNumberFormatter(optionSelect) { 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) { let colorString; switch (colorTheme) { @@ -687,15 +735,3 @@ function getColorFromTheme(colorTheme, voteIsLike) { } 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" }); -}); -*/ diff --git a/Extensions/combined/popup.spec.js b/Extensions/combined/popup.spec.js new file mode 100644 index 0000000..2fd26d8 --- /dev/null +++ b/Extensions/combined/popup.spec.js @@ -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(); + }, + ); +}); diff --git a/Extensions/combined/ryd.background-changelog.spec.js b/Extensions/combined/ryd.background-changelog.spec.js new file mode 100644 index 0000000..81d6a3e --- /dev/null +++ b/Extensions/combined/ryd.background-changelog.spec.js @@ -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); + }); +}); diff --git a/Extensions/combined/ryd.background.js b/Extensions/combined/ryd.background.js index b7bfb95..e5ae7e8 100644 --- a/Extensions/combined/ryd.background.js +++ b/Extensions/combined/ryd.background.js @@ -1,6 +1,12 @@ import { config, getApiUrl, getApiEndpoint, getChangelogUrl } from "./src/config"; import { createVoteClient } from "../common/vote-client"; 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 voteDisabledIconName = config.voteDisabledIconName; @@ -11,10 +17,20 @@ const PENDING_CHANGELOG_STORAGE_KEY = "pendingChangelogVersion"; /** stores extension's global config */ let extConfig = { ...config.defaultExtConfig }; +let authenticationGeneration = 0; +let accountStorageQueue = Promise.resolve(); if (isChrome()) api = chrome; 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({ apiBaseUrl: apiUrl, fetchImpl: (...args) => fetch(...args), @@ -25,8 +41,9 @@ const voteClient = createVoteClient({ initExtConfig(); 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) => { + if (generation !== authenticationGeneration) return; tabs .filter((tab) => tab.url && tab.url.includes("youtube.com")) .forEach((tab) => { @@ -54,23 +71,83 @@ function broadcastPatreonStatus(authenticated, user, sessionToken) { }); } -function handlePatreonAuthComplete(user, sessionToken, done) { - if (!user) { - done?.(); +function handlePatreonAuthComplete(user, sessionToken, generation, done) { + if (!user || !sessionToken || generation !== authenticationGeneration) { + done?.(false); return; } - chrome.storage.sync.set( - { - patreonAuthenticated: true, - patreonUser: user, - patreonSessionToken: sessionToken, - }, - () => { - broadcastPatreonStatus(true, user, sessionToken); - done?.(); - }, + if (usesFirefoxDataCollectionConsent()) { + hasAuthenticationDataPermission({ queryBackground: false }).then((granted) => { + if (granted && generation === authenticationGeneration) persistPatreonAuth(user, sessionToken, generation, done); + else done?.(false); + }); + return; + } + + 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() { @@ -115,22 +192,11 @@ function extractOAuthParams(responseUrl) { } api.runtime.onMessage.addListener((request, sender, sendResponse) => { - if (request.message === "get_auth_token") { - chrome.identity.getAuthToken({ interactive: true }, function (token) { - console.log(token); - chrome.identity.getProfileUserInfo(function (userInfo) { - console.log(JSON.stringify(userInfo)); - }); - }); - } 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); - }); + if (request.message === "patreon_logout") { + clearPatreonAuth(); + } else if (request.message === "ryd_has_authentication_consent") { + hasAuthenticationDataPermission({ queryBackground: false }).then((granted) => sendResponse({ granted })); + return true; } else if (request.message === "ryd_open_tab") { const targetUrl = typeof request?.url === "string" ? request.url : null; if (!targetUrl) { @@ -197,8 +263,11 @@ api.runtime.onMessage.addListener((request, sender, sendResponse) => { .catch((error) => sendResponse?.({ success: false, error: error.message })); return true; } else if (request.message === "patreon_oauth_login") { + const generation = ++authenticationGeneration; (async () => { try { + await requireCurrentAuthenticationConsent(generation); + const idApi = getIdentityApi(); if ( !idApi || @@ -220,6 +289,7 @@ api.runtime.onMessage.addListener((request, sender, sendResponse) => { ); const startData = await startRes.json(); + await requireCurrentAuthenticationConsent(generation); const responseUrl = await launchWebAuthFlow(startData.authUrl); const { code, state } = extractOAuthParams(responseUrl); if (!code) { @@ -227,6 +297,8 @@ api.runtime.onMessage.addListener((request, sender, sendResponse) => { return; } + await requireCurrentAuthenticationConsent(generation); + const exchangeRes = await fetch(getApiEndpoint("/api/auth/oauth/exchange"), { method: "POST", headers: { "Content-Type": "application/json" }, @@ -239,8 +311,12 @@ api.runtime.onMessage.addListener((request, sender, sendResponse) => { }); const authData = await exchangeRes.json(); if (authData && authData.success) { - handlePatreonAuthComplete(authData.user, authData.sessionToken, () => { - sendResponse({ success: true, user: authData.user }); + handlePatreonAuthComplete(authData.user, authData.sessionToken, generation, (stored) => { + sendResponse( + stored + ? { success: true, user: authData.user } + : { success: false, error: "authentication data consent removed" }, + ); }); } else { sendResponse({ success: false, error: (authData && authData.error) || "OAuth exchange failed" }); @@ -252,8 +328,11 @@ api.runtime.onMessage.addListener((request, sender, sendResponse) => { })(); return true; } else if (request.message === "github_oauth_login") { + const generation = ++authenticationGeneration; (async () => { try { + await requireCurrentAuthenticationConsent(generation); + const idApi = getIdentityApi(); if ( !idApi || @@ -271,32 +350,18 @@ api.runtime.onMessage.addListener((request, sender, sendResponse) => { ? chrome.identity.getRedirectURL() : ""; - const startRes = await fetch( - getApiEndpoint(`/api/auth/github/login?redirectUri=${encodeURIComponent(redirectUri)}`), - ); - const startData = await startRes.json(); - - 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 loginWithGitHub({ + redirectUri, + launchWebAuthFlow, + requireConsent: () => requireCurrentAuthenticationConsent(generation), }); - const authData = await exchangeRes.json(); if (authData && authData.success) { - handlePatreonAuthComplete(authData.user, authData.sessionToken, () => { - sendResponse({ success: true, user: authData.user }); + handlePatreonAuthComplete(authData.user, authData.sessionToken, generation, (stored) => { + sendResponse( + stored + ? { success: true, user: authData.user } + : { success: false, error: "authentication data consent removed" }, + ); }); } else { sendResponse({ success: false, error: (authData && authData.error) || "OAuth exchange failed" }); @@ -316,6 +381,7 @@ function openChangelogTab(version) { api.tabs.create({ url }, () => { if (api.runtime.lastError) { console.debug("Changelog tab could not open:", api.runtime.lastError.message); + return; } persistChangelogVersion(version); }); @@ -427,7 +493,8 @@ function maybeShowChangelog(details) { const manifest = api.runtime.getManifest(); const currentVersion = manifest?.version; 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 = () => { openChangelogTab(currentVersion || null); @@ -450,7 +517,7 @@ function maybeShowChangelog(details) { const lastShownValue = result?.[CHANGELOG_STORAGE_KEY]; const pendingValue = result?.[PENDING_CHANGELOG_STORAGE_KEY]; - if (isInstall) { + if (showImmediately) { if (hasStoredValue(pendingValue)) { clearPendingChangelogVersion(); } @@ -536,6 +603,9 @@ function storageChangeHandler(changes, area) { if (changes.hidePremiumTeaser !== undefined) { handleHidePremiumTeaserChangeEvent(changes.hidePremiumTeaser.newValue); } + if (changes.hideClutterButtons !== undefined) { + handleHideClutterButtonsChangeEvent(changes.hideClutterButtons.newValue); + } } function handleDisableVoteSubmissionChangeEvent(value) { @@ -595,6 +665,10 @@ function handleHidePremiumTeaserChangeEvent(value) { extConfig.hidePremiumTeaser = value === true; } +function handleHideClutterButtonsChangeEvent(value) { + extConfig.hideClutterButtons = value === true; +} + api.storage.onChanged.addListener(storageChangeHandler); function initExtConfig() { @@ -608,6 +682,7 @@ function initExtConfig() { initializeTooltipPercentage(); initializeTooltipPercentageMode(); initializeHidePremiumTeaser(); + initializeHideClutterButtons(); } 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() { return typeof chrome !== "undefined" && typeof chrome.runtime !== "undefined"; } diff --git a/Extensions/combined/ryd.background.spec.js b/Extensions/combined/ryd.background.spec.js new file mode 100644 index 0000000..73204e5 --- /dev/null +++ b/Extensions/combined/ryd.background.spec.js @@ -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 })); + }); +}); diff --git a/Extensions/combined/ryd.content-script.js b/Extensions/combined/ryd.content-script.js index a842f41..5fa1a23 100644 --- a/Extensions/combined/ryd.content-script.js +++ b/Extensions/combined/ryd.content-script.js @@ -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 { hasLoadedStateForVideo, initExtConfig, isLikesDisabled, isShorts, + clearRenderedVoteState, restoreCurrentState, setInitialState, } from "./src/state"; import { getBrowser, getVideoId, isVideoLoaded } from "./src/utils"; import { addLikeDislikeEventListener, createSmartimationObserver, storageChangeHandler } from "./src/events"; -import { createInitializationCycleRunner } from "./src/initialization-cycle"; +import { hasUsableRateBar } from "./src/bar"; +import { + createInitializationCycleRunner, + createPendingNavigationTracker, + pendingIncompleteShortsControlsCanInitialize, + pendingNavigationControlsAreReady, + reactionControlsCanInitialize, +} from "./src/initialization-cycle"; 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__) { document.documentElement.setAttribute("data-ryd-extension-version", getBrowser().runtime.getManifest().version); document.documentElement.setAttribute("data-ryd-extension-build", __RYD_LIVE_BUILD_ID__); } -await initExtConfig(); -initPatreonFeatures(); - let jsInitChecktimer = null; let isSetInitialStateDone = false; let isStorageListenerRegistered = false; @@ -29,7 +53,52 @@ let initializedVideoId = null; let initializedButtons = null; let initializedLikeButton = null; let initializedDislikeButton = null; +let initializedNativeLikeButton = null; +let initializedNativeDislikeButton = null; 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() { if (!isShorts()) { @@ -38,18 +107,35 @@ function ensureShortsNavigationObserver() { const shortsRoot = document.querySelector("ytd-shorts"); if (!shortsRoot) { + if (shortsNavigationObserverTarget && !shortsNavigationObserverTarget.isConnected) { + shortsNavigationObserver?.disconnect(); + shortsNavigationObserverTarget = null; + } return; } if (!shortsNavigationObserver) { shortsNavigationObserver = new MutationObserver((mutations) => { for (const mutation of mutations) { - if ( + const activatedRenderer = mutation.type === "attributes" && mutation.attributeName === "is-active" && 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(); break; } @@ -61,53 +147,196 @@ function ensureShortsNavigationObserver() { shortsNavigationObserver.disconnect(); shortsNavigationObserver.observe(shortsRoot, { attributes: true, + childList: true, subtree: true, - attributeFilter: ["is-active"], + attributeFilter: ["href", "is-active"], }); 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() { if (initializationCheckRunning) return; initializationCheckRunning = true; try { + syncShortsRouteEpoch(); if (isShorts()) { ensureShortsNavigationObserver(); } - const buttons = getButtons(); const videoId = getVideoId(window.location.href); - if ((isShorts() && isVideoLoaded()) || (hasRenderedBox(buttons) && isVideoLoaded())) { - if (!buttons) return; - const likeButton = getLikeButton(); - const dislikeButton = getDislikeButton(); - if (!likeButton || !dislikeButton) return; - if (jsInitChecktimer !== null) { - clearInterval(jsInitChecktimer); - jsInitChecktimer = null; - } - markButtonsForVideo(buttons, videoId); - createSmartimationObserver(); - addLikeDislikeEventListener(); + const pendingOrigin = pendingNavigation.get(); + if (pendingOrigin && (!pendingOrigin.videoId || pendingOrigin.videoId === videoId)) return; + const controls = isShorts() ? getStableShortsControls(videoId) : getButtonControls(); + if (!controls) return; + const { buttons, dislikeButton, likeButton, nativeDislikeButton, nativeLikeButton } = controls; + const shortsRoute = isShorts(); + if ( + controls.ready && + reactionControlsCanInitialize({ + hasRenderedButtons: hasRenderedBox(buttons), + isShortsRoute: shortsRoute, + isVideoLoaded: !shortsRoute && isVideoLoaded(), + }) + ) { + if (!getDislikeTextContainer(controls)) return; + createSmartimationObserver(buttons); + addLikeDislikeEventListener(likeButton, dislikeButton); + let rendered; if (hasLoadedStateForVideo(videoId)) { - restoreCurrentState(); + rendered = restoreCurrentState(); } else { - await setInitialState(); + rendered = await setInitialState(); } + if (rendered === false) return; + const currentControls = getButtonControls(); if ( videoId !== getVideoId(window.location.href) || - buttons !== getButtons() || - likeButton !== getLikeButton() || - dislikeButton !== getDislikeButton() + !currentControls.ready || + !currentControls.dislikeTextContainer || + buttons !== currentControls.buttons || + likeButton !== currentControls.likeButton || + dislikeButton !== currentControls.dislikeButton || + nativeLikeButton !== currentControls.nativeLikeButton || + nativeDislikeButton !== currentControls.nativeDislikeButton ) { return; } + setSyntheticShortsDislikeEnabled(true, currentControls.dislikeButton); + markButtonsForVideo(buttons, videoId); initializedVideoId = videoId; initializedButtons = buttons; initializedLikeButton = likeButton; initializedDislikeButton = dislikeButton; + initializedNativeLikeButton = nativeLikeButton; + initializedNativeDislikeButton = nativeDislikeButton; + pendingNavigation.clear(); isSetInitialStateDone = true; + if (jsInitChecktimer !== null) { + clearInterval(jsInitChecktimer); + jsInitChecktimer = null; + } if (!isStorageListenerRegistered) { getBrowser().storage.onChanged.addListener(storageChangeHandler); isStorageListenerRegistered = true; @@ -151,44 +380,120 @@ async function setEventListeners() { 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) { + pendingNavigation.clear(); + syncShortsRouteEpoch(); await setEventListeners(); }); function watchControlsNeedInitialization() { const videoId = getVideoId(window.location.href); - if (!videoId || initializationCycle.isRunning() || jsInitChecktimer !== null) return false; - const buttons = getButtons(); - if (!buttons) return true; - const likeButton = getLikeButton(); - const dislikeButton = getDislikeButton(); + if (!videoId) return false; + + let controls; + const pendingOrigin = pendingNavigation.get(); + 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 ( initializedVideoId !== videoId || initializedButtons !== buttons || initializedLikeButton !== likeButton || initializedDislikeButton !== dislikeButton || + initializedNativeLikeButton !== nativeLikeButton || + initializedNativeDislikeButton !== nativeDislikeButton || !buttons?.isConnected || !likeButton?.isConnected || !dislikeButton?.isConnected || + !nativeLikeButton?.isConnected || + !nativeDislikeButton?.isConnected || !buttons?.contains(likeButton) || !buttons?.contains(dislikeButton) || - (!isShorts() && - hasLoadedStateForVideo(videoId) && - !isLikesDisabled() && - !buttons.querySelector("#ryd-bar-container")) + !likeButton?.contains(nativeLikeButton) || + !dislikeButton?.contains(nativeDislikeButton) || + (isShorts() && + ((hasNativeShortsDislike && syntheticShortsDislikes > 0) || + (!hasNativeShortsDislike && syntheticShortsDislikes !== 1))) || + (!isShorts() && hasLoadedStateForVideo(videoId) && !isLikesDisabled() && !hasUsableRateBar(buttons, videoId)) ); } setInterval(() => { + syncShortsRouteEpoch(); + if (isShorts()) ensureShortsNavigationObserver(); if (watchControlsNeedInitialization()) void triggerInitializationCycle(); }, 500); -const s = document.createElement("script"); -s.src = chrome.runtime.getURL("menu-fixer.js"); -s.onload = function () { - this.remove(); -}; - -(document.head || document.documentElement).appendChild(s); +// Navigation must be observable even while the first count request is pending. +await setEventListeners(); diff --git a/Extensions/combined/src/amo-policy.spec.js b/Extensions/combined/src/amo-policy.spec.js new file mode 100644 index 0000000..ab3991b --- /dev/null +++ b/Extensions/combined/src/amo-policy.spec.js @@ -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"); + }); +}); diff --git a/Extensions/combined/src/bar.js b/Extensions/combined/src/bar.js index 8144025..cd5a8c4 100644 --- a/Extensions/combined/src/bar.js +++ b/Extensions/combined/src/bar.js @@ -1,6 +1,6 @@ import { getButtons, getDislikeButton, getLikeButton } from "./buttons"; 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) { 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; } +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) { const buttons = getButtons(); + const videoId = getVideoId(window.location.href); for (const wrapper of document.querySelectorAll(".ryd-tooltip")) { if (!buttons?.contains(wrapper)) wrapper.remove(); } let rateBar = buttons?.querySelector("#ryd-bar-container"); + if (!isShorts() && (!videoId || isMobile())) { + return; + } if (!isLikesDisabled()) { - // sometimes rate bar is hidden - if (rateBar && !isInViewport(rateBar)) { - (rateBar.closest(".ryd-tooltip") ?? rateBar).remove(); + // YouTube can leave an extension-owned subtree connected while hiding, + // collapsing, or partially replacing it. Treat that as missing so the + // periodic lifecycle check can rebuild a complete control. + if (rateBar && (!hasUsableRateBar(buttons, videoId) || !isInViewport(rateBar))) { + removeRateBarParts(buttons); rateBar = null; } @@ -65,7 +134,7 @@ function createRateBar(likes, dislikes) { } if (!isShorts()) { - if (!rateBar && !isMobile()) { + if (!rateBar) { let colorLikeStyle = ""; let colorDislikeStyle = ""; if (extConfig.coloredBar) { @@ -76,7 +145,7 @@ function createRateBar(likes, dislikes) { (actions || querySelector(extConfig.selectors.rateBar.mobileActionBar)).insertAdjacentHTML( "beforeend", ` -
+
${tooltipInnerHTML}
- `, + `, ); + getRateBarParts(buttons).wrapper?.setAttribute("data-ryd-video-id", videoId); + if (isNewDesign()) { // Add border between info and comments 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.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 { - buttons.querySelector(`.ryd-tooltip`).style.width = widthPx + "px"; - buttons.querySelector("#ryd-bar").style.width = widthPercent + "%"; - const tooltip = buttons.querySelector("#ryd-dislike-tooltip > #tooltip"); + const currentParts = getRateBarParts(buttons); + currentParts.wrapper.setAttribute("data-ryd-video-id", videoId); + 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 (extConfig.coloredBar) { - buttons.querySelector("#ryd-bar-container").style.backgroundColor = getColorFromTheme(false); - buttons.querySelector("#ryd-bar").style.backgroundColor = getColorFromTheme(true); + currentParts.rateBar.style.backgroundColor = getColorFromTheme(false); + currentParts.fill.style.backgroundColor = getColorFromTheme(true); } } } @@ -138,4 +196,4 @@ function createRateBar(likes, dislikes) { } } -export { createRateBar }; +export { createRateBar, hasUsableRateBar }; diff --git a/Extensions/combined/src/bar.spec.js b/Extensions/combined/src/bar.spec.js index 2b1c71a..5969dd3 100644 --- a/Extensions/combined/src/bar.spec.js +++ b/Extensions/combined/src/bar.spec.js @@ -33,6 +33,7 @@ jest.mock("./state", () => ({ jest.mock("./utils", () => ({ getColorFromTheme: jest.fn(() => "red"), + getVideoId: jest.fn(() => "BBBBBBBBBBB"), isInViewport: jest.fn(() => true), querySelector: jest.fn((selectors, element) => { const scope = element ?? globalThis.document; @@ -45,8 +46,9 @@ jest.mock("./utils", () => ({ })); const { getButtons, getDislikeButton, getLikeButton } = require("./buttons"); -const { createRateBar } = require("./bar"); +const { createRateBar, hasUsableRateBar } = require("./bar"); const { isLikesDisabled, isMobile, isNewDesign, isRoundedDesign, isShorts } = require("./state"); +const { getVideoId } = require("./utils"); describe("rate-bar stylesheet contrast", () => { 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).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) { @@ -84,6 +94,7 @@ describe("rate-bar visual ownership", () => { isNewDesign.mockReturnValue(true); isRoundedDesign.mockReturnValue(true); isShorts.mockReturnValue(false); + getVideoId.mockReturnValue("BBBBBBBBBBB"); const buttons = document.querySelector('[data-watch-buttons="BBBBBBBBBBB"]'); getButtons.mockReturnValue(buttons); getLikeButton.mockReturnValue(buttons.querySelector("like-button-view-model")); @@ -108,9 +119,96 @@ describe("rate-bar visual ownership", () => { expect(staleActionsInner.style.width).toBe("999px"); expect(staleActions.style.flexDirection).toBe(""); expect(currentRow.style.paddingBottom).toBe("10px"); - expect(currentActionsInner.style.width).toBe("revert"); - expect(currentActions.style.flexDirection).toBe("row-reverse"); - expect(document.querySelector('[data-watch-buttons="BBBBBBBBBBB"] > .ryd-tooltip')).not.toBeNull(); + expect(currentActionsInner.style.width).toBe("999px"); + expect(currentActions.style.flexDirection).toBe(""); + 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(); }); + + 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); + }); }); diff --git a/Extensions/combined/src/buttons.js b/Extensions/combined/src/buttons.js index e6d84b1..a55e7b2 100644 --- a/Extensions/combined/src/buttons.js +++ b/Extensions/combined/src/buttons.js @@ -1,7 +1,16 @@ 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 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) { 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) { const matches = []; for (const selector of Array.isArray(selectors) ? selectors : [selectors]) { @@ -68,69 +87,181 @@ function getButtonsVideoOwnership(candidate) { function selectCurrentWatchButtons(candidates) { const videoId = getVideoId(window.location.href); - const ranked = candidates - .map((candidate, index) => { - const watchRoot = candidate.closest("ytd-watch-flexy, ytd-watch-grid"); - const rootVideoId = watchRoot?.getAttribute("video-id"); - const rootMatches = Boolean(videoId && rootVideoId === videoId); - const rootConflicts = Boolean(videoId && rootVideoId && rootVideoId !== videoId); - const controlsVideoId = getButtonsVideoOwnership(candidate); - const controlsMatch = Boolean(videoId && controlsVideoId === videoId); - const controlsConflict = Boolean(videoId && controlsVideoId && controlsVideoId !== videoId); - const rendered = hasRenderedBox(candidate); - const inViewport = rendered && intersectsViewport(candidate); - const tier = - controlsMatch && inViewport - ? 8 - : rootMatches && inViewport - ? 7 - : inViewport - ? 6 - : controlsMatch && rendered - ? 5 - : rootMatches && rendered - ? 4 - : rendered - ? 3 - : controlsMatch || rootMatches - ? 2 - : 1; - return { candidate, controlsConflict, index, rootConflicts, tier }; - }) - // Do not bind outgoing controls to the destination video while YouTube is - // switching the current watch root during an SPA navigation. - .filter(({ rootConflicts }) => !rootConflicts); - const ownershipCompatible = ranked.filter(({ controlsConflict }) => !controlsConflict); - const selectionPool = ownershipCompatible.length > 0 ? ownershipCompatible : ranked; - return selectionPool.sort((left, right) => right.tier - left.tier || left.index - right.index)[0]?.candidate; + return ( + candidates + .map((candidate, index) => { + const watchRoot = candidate.closest("ytd-watch-flexy, ytd-watch-grid"); + const rootVideoId = watchRoot?.getAttribute("video-id"); + const rootMatches = Boolean(videoId && rootVideoId === videoId); + const rootConflicts = Boolean(videoId && rootVideoId && rootVideoId !== videoId); + const controlsVideoId = getButtonsVideoOwnership(candidate); + const controlsMatch = Boolean(videoId && controlsVideoId === videoId); + const controlsConflict = Boolean(videoId && controlsVideoId && controlsVideoId !== videoId); + const rendered = hasRenderedBox(candidate); + const inViewport = rendered && intersectsViewport(candidate); + const usable = hasUsableButtonStructure(candidate); + // Visible controls must win while YouTube is reusing a button tree across + // videos. Ownership is supporting evidence, not a reason to prefer a + // hidden duplicate or an unrelated unowned menu group. + const tier = + rootMatches && inViewport && !controlsConflict + ? 10 + : rootMatches && inViewport + ? 9 + : inViewport && !controlsConflict + ? 8 + : inViewport + ? 7 + : rootMatches && rendered && !controlsConflict + ? 6 + : rootMatches && rendered + ? 5 + : rendered && !controlsConflict + ? 4 + : rendered + ? 3 + : (rootMatches || controlsMatch) && !controlsConflict + ? 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) { + if (!buttonContainer) return undefined; return querySelector(extConfig.selectors.buttons.nativeButton, buttonContainer); } -function isSegmentedButtonLayout() { - return querySelector(extConfig.selectors.buttons.segmentedContainer, getButtons()) !== undefined; +function isSegmentedButtonLayout(buttons = getButtons()) { + 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() { //--- If Watching Youtube Shorts: ---// if (isShorts()) { - let elements = isMobile() + const elements = isMobile() ? querySelectorAll(extConfig.selectors.buttons.shorts.mobile) : querySelectorAll(extConfig.selectors.buttons.shorts.desktop); - - 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]; - } + return selectCurrentShortsButtons(elements); } //--- If Watching On Mobile: ---// if (isMobile()) { @@ -139,47 +270,284 @@ function getButtons() { return selectCurrentWatchButtons(getDesktopWatchButtonCandidates()); } -function getLikeButton() { - const buttons = getButtons(); - return isSegmentedButtonLayout() +function getLikeButton(buttons = getButtons()) { + if (!buttons) return undefined; + return isSegmentedButtonLayout(buttons) ? querySelector(extConfig.selectors.buttons.likeButton.segmented, buttons) ?? querySelector(extConfig.selectors.buttons.likeButton.segmentedGetButtons, buttons) : querySelector(extConfig.selectors.buttons.likeButton.notSegmented, buttons); } -function getLikeTextContainer() { - return querySelector(extConfig.selectors.likeTextContainer, getLikeButton()); +function getLikeTextContainer(likeButton = getLikeButton()) { + if (!likeButton) return undefined; + return querySelector(extConfig.selectors.likeTextContainer, likeButton); } -function getDislikeButton() { - const buttons = getButtons(); - if (isSegmentedButtonLayout()) { +function isSyntheticShortsDislike(dislikeButton) { + return dislikeButton?.matches?.(SYNTHETIC_SHORTS_DISLIKE_SELECTOR) === true; +} + +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 ( querySelector(extConfig.selectors.buttons.dislikeButton.segmented, 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); if (notSegmentedMatch != null) { return notSegmentedMatch; } - if (isShorts()) { - return querySelector(extConfig.selectors.buttons.dislikeButton.shortsFallback, buttons); - } - return null; } -function getTextContainerTemplate() { - const likeButton = getLikeButton(); +function getTextContainerTemplate(likeButton, buttons) { + if (!likeButton || !buttons || !buttons.contains(likeButton)) return undefined; const parentTemplate = 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) { @@ -192,38 +560,39 @@ function updateDislikeButtonShape(dislikeButton) { } } -function createDislikeTextContainer() { - const textNodeClone = getTextContainerTemplate().cloneNode(true); - const dislikeButton = getNativeButton(getDislikeButton()); - const insertPreChild = dislikeButton; - insertPreChild.insertBefore(textNodeClone, null); - updateDislikeButtonShape(dislikeButton); - if (querySelector(extConfig.selectors.textContainerInner, textNodeClone) === undefined) { - const span = document.createElement("span"); - span.setAttribute("role", "text"); +function createDislikeTextContainer(controls = getButtonControls()) { + const { dislikeButton, dislikeTextContainer, nativeDislikeButton, textContainerTemplate } = controls; + if (dislikeTextContainer) return dislikeTextContainer; + if ( + !controls.ready || + !nativeDislikeButton?.isConnected || + !dislikeButton?.contains(nativeDislikeButton) || + !textContainerTemplate?.isConnected + ) { + 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) { textNodeClone.removeChild(textNodeClone.firstChild); } - textNodeClone.appendChild(span); + textNodeClone.appendChild(textContainer); } - textNodeClone.innerText = ""; - return textNodeClone; + if (!textContainer.id) textContainer.id = "text"; + textContainer.innerText = ""; + nativeDislikeButton.insertBefore(textNodeClone, null); + updateDislikeButtonShape(nativeDislikeButton); + return textContainer; } -function getDislikeTextContainer() { - let result; - 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 getDislikeTextContainer(controls = getButtonControls()) { + return controls.dislikeTextContainer ?? createDislikeTextContainer(controls); } function checkForSignInButton() { @@ -235,12 +604,22 @@ function checkForSignInButton() { } export { + ensureSyntheticShortsDislikeButton, getButtons, + getButtonControls, getLikeButton, getDislikeButton, getLikeTextContainer, getDislikeTextContainer, + getShortsCandidateVideoId, + getShortsCandidateVideoIds, + getShortsControlSurfaceSignature, checkForSignInButton, hasRenderedBox, + isSyntheticShortsDislike, markButtonsForVideo, + setSyntheticShortsDislikeEnabled, + setSyntheticShortsDislikePressed, + shortsControlSurfaceIsReadyForMutation, + shortsNativeControlInventoryIsReadyForFallback, }; diff --git a/Extensions/combined/src/buttons.spec.js b/Extensions/combined/src/buttons.spec.js index dd7b134..80313fa 100644 --- a/Extensions/combined/src/buttons.spec.js +++ b/Extensions/combined/src/buttons.spec.js @@ -1,15 +1,29 @@ /** @jest-environment jsdom */ 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 = "") { return ` `; @@ -148,4 +162,592 @@ describe("desktop watch button ownership", () => { extConfig.selectors.buttons.segmentedContainer = originalSegmentedContainer; } }); + + test("keeps visible reused reactions ahead of an unrelated unowned menu group and hidden duplicate", () => { + document.body.innerHTML = ` + + ${controls("reused", "AAAAAAAAAAA")} + + ${controls("hidden-duplicate", "BBBBBBBBBBB", "hidden")} + `; + 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 = ` + + + `; + 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 = ` + + + `; + 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 = ` + + ${controls("current", "BBBBBBBBBBB")} + `; + 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 = ''; + + 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 = ` +
999
+ + + `; + 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 = ` + + ${controls("current", "BBBBBBBBBBB")} + `; + 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 = ` + + + `; + 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 = ` + + + + + + + + `; + 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 = ` + + + + + + + + + + + `; + 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 = ` + + + + + + + + + + `; + 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 = ` + + + + + + + + + + `; + 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 = ''; + 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 = ` + + + + + + + + `; + 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 = ` + + + + + + + + `; + 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 = ` + + + + + + + + + `; + 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 = ` + + + + + + + + `; + 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 = ` + + +
+ +
+ + + Another Short + + + + + + + + +
+
`; + 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 = ` + + + + + + + `; + 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 = ` + + + + + + + + + `; + 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); + }); }); diff --git a/Extensions/combined/src/clutter-button-setting.js b/Extensions/combined/src/clutter-button-setting.js new file mode 100644 index 0000000..cf54198 --- /dev/null +++ b/Extensions/combined/src/clutter-button-setting.js @@ -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, +}; diff --git a/Extensions/combined/src/clutter-button-setting.spec.js b/Extensions/combined/src/clutter-button-setting.spec.js new file mode 100644 index 0000000..9758848 --- /dev/null +++ b/Extensions/combined/src/clutter-button-setting.spec.js @@ -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); + }); +}); diff --git a/Extensions/combined/src/config.js b/Extensions/combined/src/config.js index 5ce8ae8..6524ce8 100644 --- a/Extensions/combined/src/config.js +++ b/Extensions/combined/src/config.js @@ -35,6 +35,7 @@ const config = { numberDisplayFormat: "compactShort", numberDisplayReformatLikes: false, hidePremiumTeaser: false, + hideClutterButtons: false, }, }; diff --git a/Extensions/combined/src/data-collection-permissions.js b/Extensions/combined/src/data-collection-permissions.js new file mode 100644 index 0000000..7845d9c --- /dev/null +++ b/Extensions/combined/src/data-collection-permissions.js @@ -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, +}; diff --git a/Extensions/combined/src/data-collection-permissions.spec.js b/Extensions/combined/src/data-collection-permissions.spec.js new file mode 100644 index 0000000..731c310 --- /dev/null +++ b/Extensions/combined/src/data-collection-permissions.spec.js @@ -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); + }); +}); diff --git a/Extensions/combined/src/events.js b/Extensions/combined/src/events.js index 142723f..814fb2e 100644 --- a/Extensions/combined/src/events.js +++ b/Extensions/combined/src/events.js @@ -1,14 +1,32 @@ import { getBrowser, getVideoId, numberFormat, createObserver, querySelector } from "./utils"; -import { checkForSignInButton, getButtons, getDislikeButton, getLikeButton } from "./buttons"; -import { setDislikes, extConfig, storedData, setLikes, getLikeCountFromButton } from "./state"; +import { + 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 { LIKE_ACTION, DISLIKE_ACTION, + DISLIKED_STATE, + LIKED_STATE, resolveVoteTransition, applyVoteTransitionCounts, shouldSubmitVote, } from "../../common/vote-transition"; +import { resolveDelegatedVoteActivation } from "../../common/delegated-vote-activation"; function sendVote(vote) { if (shouldSubmitVote({ disableVoteSubmission: extConfig.disableVoteSubmission })) { @@ -24,18 +42,51 @@ function sendVote(vote) { } function updateDOMDislikes() { + const videoId = getVideoId(window.location.href); + if (!videoId || storedData.videoId !== videoId) return false; setDislikes(numberFormat(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) { 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 syntheticShortsDislike = isSyntheticShortsDislike(getDislikeButton()); + clearNativeLikeForSyntheticDislike(action, previousState, syntheticShortsDislike); sendVote(transition.value); storedData.likes = counts.likes; storedData.dislikes = counts.dislikes; 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(); if (extConfig.numberDisplayReformatLikes === true) { @@ -48,6 +99,7 @@ function handleVoteAction(action) { } function likeClicked() { + if (suppressNextLikeActivation) return; handleVoteAction(LIKE_ACTION); } @@ -55,18 +107,31 @@ function dislikeClicked() { handleVoteAction(DISLIKE_ACTION); } -const boundLikeButtons = new WeakSet(); const boundDislikeButtons = new WeakSet(); +let delegatedReactionListenerRegistered = false; -function addLikeDislikeEventListener() { - const likeButton = getLikeButton(); - const dislikeButton = getDislikeButton(); - if (likeButton && !boundLikeButtons.has(likeButton)) { - likeButton.addEventListener("click", likeClicked); - boundLikeButtons.add(likeButton); +function delegatedReactionClicked(event) { + const buttons = getButtons(); + const likeButton = getLikeButton(buttons); + const dislikeButton = getDislikeButton(buttons); + const activation = resolveDelegatedVoteActivation({ buttons, dislikeButton, event, 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("focusout", updateDOMDislikes); boundDislikeButtons.add(dislikeButton); @@ -75,7 +140,7 @@ function addLikeDislikeEventListener() { let smartimationObserver = null; -function createSmartimationObserver() { +function createSmartimationObserver(buttons = getButtons()) { if (!smartimationObserver) { smartimationObserver = createObserver( { @@ -88,7 +153,7 @@ function createSmartimationObserver() { smartimationObserver.container = null; } - const smartimationContainer = querySelector(extConfig.selectors.buttons.smartimation, getButtons()); + const smartimationContainer = querySelector(extConfig.selectors.buttons.smartimation, buttons); if (smartimationContainer && smartimationObserver.container != smartimationContainer) { console.log("Initializing smartimation mutation observer"); smartimationObserver.disconnect(); @@ -119,6 +184,9 @@ function storageChangeHandler(changes, area) { if (changes.hidePremiumTeaser !== undefined) { handleHidePremiumTeaserChangeEvent(changes.hidePremiumTeaser.newValue); } + if (changes.hideClutterButtons !== undefined) { + handleHideClutterButtonsChangeEvent(changes.hideClutterButtons.newValue); + } } function handleDisableVoteSubmissionChangeEvent(value) { @@ -150,6 +218,10 @@ function handleHidePremiumTeaserChangeEvent(value) { extConfig.hidePremiumTeaser = value === true; } +function handleHideClutterButtonsChangeEvent(value) { + extConfig.hideClutterButtons = publishHideClutterButtons(value); +} + export { sendVote, likeClicked, @@ -157,4 +229,5 @@ export { addLikeDislikeEventListener, createSmartimationObserver, storageChangeHandler, + updateDOMDislikes, }; diff --git a/Extensions/combined/src/events.spec.js b/Extensions/combined/src/events.spec.js new file mode 100644 index 0000000..03da443 --- /dev/null +++ b/Extensions/combined/src/events.spec.js @@ -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" }); +}); diff --git a/Extensions/combined/src/github-auth.js b/Extensions/combined/src/github-auth.js new file mode 100644 index 0000000..5ebb358 --- /dev/null +++ b/Extensions/combined/src/github-auth.js @@ -0,0 +1,102 @@ +import { getApiEndpoint } from "./config"; + +function base64Url(bytes) { + return btoa(String.fromCharCode(...bytes)) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); +} + +async function createPkce(cryptoApi = globalThis.crypto) { + const verifier = base64Url(cryptoApi.getRandomValues(new Uint8Array(32))); + const digest = await cryptoApi.subtle.digest("SHA-256", new TextEncoder().encode(verifier)); + return { verifier, challenge: base64Url(new Uint8Array(digest)) }; +} + +const API_ERRORS = new Set([ + "github_not_configured", + "not_contributor", + "github_pkce_required", + "github_invalid_state", + "github_token_exchange_failed", + "github_identity_failed", +]); + +async function readAuthResponse(response) { + const data = await response.json().catch(() => null); + if (!response.ok || data?.error || data?.success === false) { + if (API_ERRORS.has(data?.error)) throw new Error(data.error); + if (data?.error === "Disallowed redirectUri") throw new Error("github_redirect_rejected"); + if (response.status === 404 || response.status === 503) throw new Error("github_unavailable"); + if (response.status === 429) throw new Error("github_rate_limited"); + throw new Error("github_login_failed"); + } + if (!data) throw new Error("github_invalid_response"); + return data; +} + +async function loginWithGitHub({ + redirectUri, + launchWebAuthFlow, + requireConsent, + fetchImpl = globalThis.fetch, + cryptoApi = globalThis.crypto, +}) { + await requireConsent(); + const { verifier, challenge } = await createPkce(cryptoApi); + await requireConsent(); + const start = await readAuthResponse( + await fetchImpl( + getApiEndpoint( + `/api/auth/github/login?redirectUri=${encodeURIComponent(redirectUri)}&codeChallenge=${encodeURIComponent(challenge)}`, + ), + ), + ); + let authUrl; + try { + authUrl = new URL(start.authUrl); + } catch (_) { + throw new Error("github_invalid_response"); + } + if ( + authUrl.origin !== "https://github.com" || + authUrl.pathname !== "/login/oauth/authorize" || + typeof start.state !== "string" || + !start.state || + start.redirectUri !== redirectUri || + authUrl.searchParams.get("state") !== start.state || + authUrl.searchParams.get("redirect_uri") !== redirectUri || + authUrl.searchParams.get("code_challenge") !== challenge || + authUrl.searchParams.get("code_challenge_method") !== "S256" + ) { + throw new Error("github_invalid_response"); + } + + await requireConsent(); + const callback = new URL(await launchWebAuthFlow(authUrl.href)); + const expectedRedirect = new URL(redirectUri); + if ( + callback.origin !== expectedRedirect.origin || + callback.pathname !== expectedRedirect.pathname || + callback.searchParams.get("state") !== start.state + ) { + throw new Error("github_invalid_state"); + } + if (callback.searchParams.has("error")) throw new Error("github_authorization_denied"); + const code = callback.searchParams.get("code"); + if (!code) throw new Error("github_invalid_response"); + + await requireConsent(); + const account = await readAuthResponse( + await fetchImpl(getApiEndpoint("/api/auth/github/exchange"), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ code, state: start.state, redirectUri, codeVerifier: verifier }), + }), + ); + await requireConsent(); + if (account.success !== true || !account.user || !account.sessionToken) throw new Error("github_invalid_response"); + return account; +} + +export { createPkce, loginWithGitHub }; diff --git a/Extensions/combined/src/github-auth.spec.js b/Extensions/combined/src/github-auth.spec.js new file mode 100644 index 0000000..06c5ddb --- /dev/null +++ b/Extensions/combined/src/github-auth.spec.js @@ -0,0 +1,144 @@ +const { webcrypto, createHash } = require("node:crypto"); +const { createPkce, loginWithGitHub } = require("./github-auth"); + +const redirectUri = "https://test.chromiumapp.org/"; +const response = (data, status = 200) => ({ ok: status >= 200 && status < 300, status, json: async () => data }); + +describe("GitHub contributor OAuth", () => { + let fetchImpl; + let launchWebAuthFlow; + let requireConsent; + let startData; + let account; + + beforeEach(() => { + account = { success: true, user: { fullName: "Contributor" }, sessionToken: "session" }; + requireConsent = jest.fn(async () => {}); + fetchImpl = jest.fn(async (url) => { + if (url.includes("/login?")) { + const challenge = new URL(url).searchParams.get("codeChallenge"); + const params = new URLSearchParams({ + state: "protected-state", + redirect_uri: redirectUri, + code_challenge: challenge, + code_challenge_method: "S256", + }); + startData = { + authUrl: `https://github.com/login/oauth/authorize?${params}`, + state: "protected-state", + redirectUri, + }; + return response(startData); + } + return response(account); + }); + launchWebAuthFlow = jest.fn(async () => `${redirectUri}?code=provider-code&state=protected-state`); + }); + + const login = () => + loginWithGitHub({ redirectUri, fetchImpl, launchWebAuthFlow, requireConsent, cryptoApi: webcrypto }); + + test("generates unique verifiers and the corresponding S256 challenge", async () => { + const first = await createPkce(webcrypto); + const second = await createPkce(webcrypto); + expect(first.verifier).toMatch(/^[A-Za-z0-9_-]{43}$/); + expect(first.challenge).toBe(createHash("sha256").update(first.verifier).digest("base64url")); + expect(second.verifier).not.toBe(first.verifier); + }); + + test("keeps the verifier out of the authorization URL and sends it only at exchange", async () => { + await expect(login()).resolves.toEqual(account); + const [url, options] = fetchImpl.mock.calls[1]; + const exchange = JSON.parse(options.body); + expect(url).toContain("/api/auth/github/exchange"); + expect(exchange).toEqual({ + code: "provider-code", + state: "protected-state", + redirectUri, + codeVerifier: expect.any(String), + }); + const challenge = new URL(fetchImpl.mock.calls[0][0]).searchParams.get("codeChallenge"); + expect(createHash("sha256").update(exchange.codeVerifier).digest("base64url")).toBe(challenge); + expect(launchWebAuthFlow.mock.calls[0][0]).not.toContain(exchange.codeVerifier); + expect(fetchImpl.mock.calls[0][0]).not.toContain(exchange.codeVerifier); + }); + + test.each([ + "?code=provider-code&state=wrong-state", + "?code=provider-code", + "other-path?code=provider-code&state=protected-state", + ])("rejects callback %s before exchanging the code", async (suffix) => { + launchWebAuthFlow.mockResolvedValue(redirectUri + suffix); + await expect(login()).rejects.toThrow("github_invalid_state"); + expect(fetchImpl).toHaveBeenCalledTimes(1); + }); + + test("rejects another extension's callback", async () => { + launchWebAuthFlow.mockResolvedValue("https://other.chromiumapp.org/?code=code&state=protected-state"); + await expect(login()).rejects.toThrow("github_invalid_state"); + expect(fetchImpl).toHaveBeenCalledTimes(1); + }); + + test.each([ + [503, { error: "github_not_configured" }, "github_not_configured"], + [404, null, "github_unavailable"], + [429, null, "github_rate_limited"], + [400, { error: "Disallowed redirectUri" }, "github_redirect_rejected"], + [400, { error: "github_pkce_required" }, "github_pkce_required"], + [200, {}, "github_invalid_response"], + ])("does not open OAuth for a %s startup failure", async (status, data, error) => { + fetchImpl.mockResolvedValueOnce(response(data, status)); + await expect(login()).rejects.toThrow(error); + expect(launchWebAuthFlow).not.toHaveBeenCalled(); + expect(fetchImpl).toHaveBeenCalledTimes(1); + }); + + test("handles a non-JSON 404 without losing the unavailable error", async () => { + fetchImpl.mockResolvedValueOnce({ + ok: false, + status: 404, + json: async () => { + throw new Error("not JSON"); + }, + }); + await expect(login()).rejects.toThrow("github_unavailable"); + expect(launchWebAuthFlow).not.toHaveBeenCalled(); + }); + + test("rejects a startup response that omits PKCE protection", async () => { + const original = fetchImpl.getMockImplementation(); + fetchImpl.mockImplementation(async (url) => { + await original(url); + const authUrl = new URL(startData.authUrl); + authUrl.searchParams.delete("code_challenge"); + return response({ ...startData, authUrl: authUrl.href }); + }); + await expect(login()).rejects.toThrow("github_invalid_response"); + expect(launchWebAuthFlow).not.toHaveBeenCalled(); + }); + + test("preserves contributor ineligibility returned by exchange", async () => { + const original = fetchImpl.getMockImplementation(); + fetchImpl.mockImplementation((url) => + url.includes("/exchange") + ? Promise.resolve(response({ success: false, error: "not_contributor" }, 403)) + : original(url), + ); + await expect(login()).rejects.toThrow("not_contributor"); + }); + + test("does not exchange after authorization is declined", async () => { + launchWebAuthFlow.mockResolvedValue(`${redirectUri}?error=access_denied&state=protected-state`); + await expect(login()).rejects.toThrow("github_authorization_denied"); + expect(fetchImpl).toHaveBeenCalledTimes(1); + }); + + test("stops before exchange when consent is revoked in the authorization window", async () => { + launchWebAuthFlow.mockImplementation(async () => { + requireConsent.mockRejectedValue(new Error("consent removed")); + return `${redirectUri}?code=provider-code&state=protected-state`; + }); + await expect(login()).rejects.toThrow("consent removed"); + expect(fetchImpl).toHaveBeenCalledTimes(1); + }); +}); diff --git a/Extensions/combined/src/github-login-error.js b/Extensions/combined/src/github-login-error.js new file mode 100644 index 0000000..a76f045 --- /dev/null +++ b/Extensions/combined/src/github-login-error.js @@ -0,0 +1,17 @@ +const GITHUB_ERROR_MESSAGES = { + 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", +}; + +export function getGitHubLoginErrorMessage(error, getMessage) { + const key = Object.prototype.hasOwnProperty.call(GITHUB_ERROR_MESSAGES, error) + ? GITHUB_ERROR_MESSAGES[error] + : "githubLoginCompleteFailed"; + return getMessage(key); +} diff --git a/Extensions/combined/src/initialization-cycle.js b/Extensions/combined/src/initialization-cycle.js index 1ba587c..40479c1 100644 --- a/Extensions/combined/src/initialization-cycle.js +++ b/Extensions/combined/src/initialization-cycle.js @@ -32,4 +32,63 @@ function createInitializationCycleRunner(runCycle) { }; } -export { createInitializationCycleRunner }; +function pendingNavigationControlsAreReady({ + currentControls, + destinationVideoId, + previousControls, + shortsControlsVideoId = null, +}) { + if (!previousControls) return true; + + const sameControls = + currentControls.buttons === previousControls.buttons && + currentControls.likeButton === previousControls.likeButton && + currentControls.dislikeButton === previousControls.dislikeButton && + currentControls.nativeLikeButton === previousControls.nativeLikeButton && + currentControls.nativeDislikeButton === previousControls.nativeDislikeButton; + if (!sameControls) return true; + + return Boolean(destinationVideoId && shortsControlsVideoId === destinationVideoId); +} + +function pendingIncompleteShortsControlsCanInitialize({ destinationVideoId, isShortsRoute, previousVideoId }) { + return Boolean(isShortsRoute && destinationVideoId && previousVideoId && destinationVideoId !== previousVideoId); +} + +function reactionControlsCanInitialize({ hasRenderedButtons, isShortsRoute, isVideoLoaded }) { + return Boolean(isShortsRoute || (hasRenderedButtons && isVideoLoaded)); +} + +function createPendingNavigationTracker() { + let origin = null; + + function begin(captureOrigin) { + if (origin) return origin; + if (typeof captureOrigin !== "function") { + throw new TypeError("A pending navigation origin capture function is required."); + } + + const capturedOrigin = captureOrigin(); + if (!capturedOrigin || typeof capturedOrigin !== "object") { + throw new TypeError("The pending navigation origin must be an object."); + } + origin = capturedOrigin; + return origin; + } + + return { + begin, + clear() { + origin = null; + }, + get: () => origin, + }; +} + +export { + createInitializationCycleRunner, + createPendingNavigationTracker, + pendingIncompleteShortsControlsCanInitialize, + pendingNavigationControlsAreReady, + reactionControlsCanInitialize, +}; diff --git a/Extensions/combined/src/initialization-cycle.spec.js b/Extensions/combined/src/initialization-cycle.spec.js index 491b5ab..43dd4c7 100644 --- a/Extensions/combined/src/initialization-cycle.spec.js +++ b/Extensions/combined/src/initialization-cycle.spec.js @@ -1,4 +1,10 @@ -import { createInitializationCycleRunner } from "./initialization-cycle"; +import { + createInitializationCycleRunner, + createPendingNavigationTracker, + pendingIncompleteShortsControlsCanInitialize, + pendingNavigationControlsAreReady, + reactionControlsCanInitialize, +} from "./initialization-cycle"; function deferred() { let resolve; @@ -56,3 +62,133 @@ test("continues accepting independent cycles after a queued rerun", async () => test("rejects invalid cycle callbacks", () => { expect(() => createInitializationCycleRunner()).toThrow(TypeError); }); + +describe("pending navigation origin tracking", () => { + test("preserves the first origin across repeated starts until initialization completes", () => { + const tracker = createPendingNavigationTracker(); + const originA = { controls: { buttons: "A" }, videoId: "AAAAAAAAAAA" }; + const captureDestinationAsOrigin = jest.fn(() => ({ controls: { buttons: "B" }, videoId: "BBBBBBBBBBB" })); + + expect(tracker.begin(() => originA)).toBe(originA); + expect(tracker.begin(captureDestinationAsOrigin)).toBe(originA); + expect(tracker.get()).toBe(originA); + expect(captureDestinationAsOrigin).not.toHaveBeenCalled(); + }); + + test("captures a new origin after the completed navigation is cleared", () => { + const tracker = createPendingNavigationTracker(); + const originA = { controls: null, videoId: "AAAAAAAAAAA" }; + const originB = { controls: null, videoId: "BBBBBBBBBBB" }; + + tracker.begin(() => originA); + tracker.clear(); + + expect(tracker.get()).toBeNull(); + expect(tracker.begin(() => originB)).toBe(originB); + expect(tracker.get()).toBe(originB); + }); + + test("rejects invalid first-origin capture without poisoning the tracker", () => { + const tracker = createPendingNavigationTracker(); + + expect(() => tracker.begin()).toThrow(TypeError); + expect(() => tracker.begin(() => null)).toThrow(TypeError); + expect(tracker.get()).toBeNull(); + }); +}); + +function reactionControls() { + const buttons = {}; + const likeButton = {}; + const dislikeButton = {}; + const nativeLikeButton = {}; + const nativeDislikeButton = {}; + return { buttons, dislikeButton, likeButton, nativeDislikeButton, nativeLikeButton }; +} + +describe("pending navigation control ownership", () => { + test.each([ + ["no action surface yet", undefined], + ["an ambiguous surface rejected by the read-only selector", null], + ["a selected incomplete action surface", {}], + ])("starts guarded initialization for a changed Shorts destination with %s", (_label, currentButtons) => { + expect( + pendingIncompleteShortsControlsCanInitialize({ + currentButtons, + destinationVideoId: "BBBBBBBBBBB", + isShortsRoute: true, + previousVideoId: "AAAAAAAAAAA", + }), + ).toBe(true); + }); + + test.each([ + ["a Watch route", { destinationVideoId: "BBBBBBBBBBB", isShortsRoute: false, previousVideoId: "AAAAAAAAAAA" }], + ["the same video", { destinationVideoId: "AAAAAAAAAAA", isShortsRoute: true, previousVideoId: "AAAAAAAAAAA" }], + ["a missing destination", { destinationVideoId: null, isShortsRoute: true, previousVideoId: "AAAAAAAAAAA" }], + ["a missing origin", { destinationVideoId: "BBBBBBBBBBB", isShortsRoute: true, previousVideoId: null }], + ])("does not initialize incomplete controls for %s", (_label, options) => { + expect(pendingIncompleteShortsControlsCanInitialize({ currentButtons: {}, ...options })).toBe(false); + }); + + test("accepts reused Shorts controls only after their canonical identity changes to the destination", () => { + const reused = reactionControls(); + + expect( + pendingNavigationControlsAreReady({ + currentControls: reused, + destinationVideoId: "BBBBBBBBBBB", + previousControls: reused, + shortsControlsVideoId: "BBBBBBBBBBB", + }), + ).toBe(true); + }); + + test.each(["AAAAAAAAAAA", null])( + "does not bind reused outgoing Shorts controls when their identity is %p", + (shortsControlsVideoId) => { + const reused = reactionControls(); + + expect( + pendingNavigationControlsAreReady({ + currentControls: reused, + destinationVideoId: "BBBBBBBBBBB", + previousControls: reused, + shortsControlsVideoId, + }), + ).toBe(false); + }, + ); + + test("accepts a replaced reaction-control tuple without Shorts ownership fallback", () => { + const previousControls = reactionControls(); + const currentControls = { ...previousControls, dislikeButton: {} }; + + expect( + pendingNavigationControlsAreReady({ + currentControls, + destinationVideoId: "BBBBBBBBBBB", + previousControls, + }), + ).toBe(true); + }); +}); + +describe("reaction-control initialization readiness", () => { + test("allows stable Shorts controls before the video element reports loaded", () => { + expect( + reactionControlsCanInitialize({ + hasRenderedButtons: true, + isShortsRoute: true, + isVideoLoaded: false, + }), + ).toBe(true); + }); + + test.each([ + ["an unloaded Watch video", { hasRenderedButtons: true, isShortsRoute: false, isVideoLoaded: false }], + ["hidden Watch controls", { hasRenderedButtons: false, isShortsRoute: false, isVideoLoaded: true }], + ])("rejects %s", (_label, options) => { + expect(reactionControlsCanInitialize(options)).toBe(false); + }); +}); diff --git a/Extensions/combined/src/menu-fixer.spec.js b/Extensions/combined/src/menu-fixer.spec.js new file mode 100644 index 0000000..7d29d3f --- /dev/null +++ b/Extensions/combined/src/menu-fixer.spec.js @@ -0,0 +1,745 @@ +/** @jest-environment jsdom */ + +const fs = require("fs"); +const path = require("path"); + +const { + HIDE_CLUTTER_ATTRIBUTE, + SHORTS_HYDRATION_ATTRIBUTE, + SHORTS_HYDRATION_REQUEST_EVENT, + containsReactionButtonModel, + convertTopLevelButtonToFlexibleItem, + createClutterMenuData, + createMenuUpdateGuard, + fixYtdMenuRenderer, + getMenuLayoutKey, + installMenuFixer, + keepAtMostFlexibleButtons, + menuNeedsMoreOverflow, +} = require("../menu-fixer"); + +function makeController(optionalButtons = ["share", "save", "thanks"]) { + return { + flexAsTopLevelButtons: [...optionalButtons], + maybeUpdateFlexibleMenuImpl: jest.fn(), + }; +} + +function attachRenderer(controller, id = "menu") { + const renderer = document.createElement("ytd-menu-renderer"); + renderer.id = id; + Object.defineProperty(renderer, "polymerController", { configurable: true, value: controller }); + document.body.append(renderer); + return renderer; +} + +function makeCurrentMenuData() { + const shareCommand = { serialCommand: { commands: [{ innertubeCommand: { shareEntityServiceEndpoint: {} } }] } }; + const reaction = { + segmentedLikeDislikeButtonViewModel: { + likeButtonViewModel: { accessibilityId: "id.video.like.button" }, + dislikeButtonViewModel: { accessibilityId: "id.video.dislike.button" }, + }, + }; + const share = { + buttonViewModel: { + accessibilityId: "id.video.share.button", + accessibilityText: "Share", + iconName: "SHARE", + onTap: shareCommand, + title: "Share", + trackingParams: "share-tracking", + }, + }; + const save = { menuFlexibleItemRenderer: { menuItem: { menuServiceItemRenderer: { text: "Save" } } } }; + return { + data: { + items: [{ menuServiceItemRenderer: { text: "Report" } }], + topLevelButtons: [reaction, share], + flexibleItems: [save], + }, + reaction, + save, + share, + shareCommand, + }; +} + +describe("menu fixer", () => { + afterEach(() => { + document.documentElement.removeAttribute(HIDE_CLUTTER_ATTRIBUTE); + document.body.replaceChildren(); + }); + + test("keeps all optional actions when they already fit", () => { + const controller = makeController(); + const guarded = createMenuUpdateGuard(controller.maybeUpdateFlexibleMenuImpl, controller, { + now: () => 10, + }); + + guarded(); + guarded(); + guarded(); + + expect(controller.maybeUpdateFlexibleMenuImpl).toHaveBeenCalledTimes(2); + expect(controller.flexAsTopLevelButtons).toEqual(["share", "save", "thanks"]); + }); + + test("reflects page-world Shorts action-bar hydration synchronously", () => { + const actionBar = document.createElement("reel-action-bar-view-model"); + document.body.append(actionBar); + const installation = installMenuFixer(); + + actionBar.dispatchEvent(new Event(SHORTS_HYDRATION_REQUEST_EVENT)); + expect(actionBar.getAttribute(SHORTS_HYDRATION_ATTRIBUTE)).toBe("true"); + + actionBar.data = null; + actionBar.dispatchEvent(new Event(SHORTS_HYDRATION_REQUEST_EVENT)); + expect(actionBar.getAttribute(SHORTS_HYDRATION_ATTRIBUTE)).toBe("false"); + + actionBar.data = { videoId: "PoolVid0001" }; + actionBar.dispatchEvent(new Event(SHORTS_HYDRATION_REQUEST_EVENT)); + expect(actionBar.getAttribute(SHORTS_HYDRATION_ATTRIBUTE)).toBe("true"); + + installation.stop(); + actionBar.removeAttribute(SHORTS_HYDRATION_ATTRIBUTE); + actionBar.data = null; + actionBar.dispatchEvent(new Event(SHORTS_HYDRATION_REQUEST_EVENT)); + expect(actionBar.hasAttribute(SHORTS_HYDRATION_ATTRIBUTE)).toBe(false); + }); + + test("does not mistake YouTube's empty pre-initialization array for a fitted layout", () => { + const controller = makeController([]); + const original = jest.fn(() => { + controller.flexAsTopLevelButtons = ["share", "save", "thanks"]; + }); + const guarded = createMenuUpdateGuard(original, controller, { now: () => 10 }); + + guarded(); + guarded(); + + expect(controller.flexAsTopLevelButtons).toEqual(["share", "save", "thanks"]); + }); + + test("excludes the initial empty state when stabilizing a later layout cycle", () => { + const controller = makeController([]); + const original = jest + .fn() + .mockImplementationOnce(() => { + controller.flexAsTopLevelButtons = ["share", "save", "thanks", "download"]; + }) + .mockImplementationOnce(() => { + controller.flexAsTopLevelButtons = ["share", "save", "thanks"]; + }) + .mockImplementationOnce(() => { + controller.flexAsTopLevelButtons = ["share", "save", "thanks", "download"]; + }); + const guarded = createMenuUpdateGuard(original, controller, { now: () => 10 }); + + guarded(); + guarded(); + guarded(); + guarded(); + + expect(controller.flexAsTopLevelButtons).toEqual(["share", "save", "thanks"]); + }); + + test("stabilizes an oscillation at the narrowest layout selected by YouTube", () => { + const controller = makeController(); + const nativeLayouts = [ + ["share", "save"], + ["share", "save", "thanks"], + ]; + const original = jest.fn(() => { + controller.flexAsTopLevelButtons = nativeLayouts.shift(); + }); + const guarded = createMenuUpdateGuard(original, controller, { now: () => 10 }); + + guarded(); + guarded(); + guarded(); + + expect(original).toHaveBeenCalledTimes(2); + expect(controller.flexAsTopLevelButtons).toEqual(["share", "save"]); + }); + + test("uses current button models when a renderer is reused", () => { + const oldSave = { id: "old-save" }; + const newSave = { id: "new-save" }; + const newThanks = { id: "new-thanks" }; + const controller = makeController([{ id: "old-share" }, oldSave]); + const original = jest + .fn() + .mockImplementationOnce(() => { + controller.flexAsTopLevelButtons = [oldSave]; + }) + .mockImplementationOnce(() => { + controller.flexAsTopLevelButtons = [newSave, newThanks]; + }); + const guarded = createMenuUpdateGuard(original, controller, { now: () => 10 }); + + guarded(); + guarded(); + guarded(); + + expect(controller.flexAsTopLevelButtons).toEqual([newSave]); + expect(controller.flexAsTopLevelButtons).not.toContain(oldSave); + }); + + test("allows YouTube to measure again after the rapid-update window", () => { + const controller = makeController(); + let currentTime = 10; + const guarded = createMenuUpdateGuard(controller.maybeUpdateFlexibleMenuImpl, controller, { + now: () => currentTime, + }); + + guarded(); + guarded(); + guarded(); + currentTime = 111; + guarded(); + + expect(controller.maybeUpdateFlexibleMenuImpl).toHaveBeenCalledTimes(3); + }); + + test("allows every monotonic native reduction needed to fit a narrow action row", () => { + const controller = makeController(["share", "save", "thanks", "download"]); + let guarded; + const original = jest.fn(() => { + if (controller.flexAsTopLevelButtons.length > 1) { + controller.flexAsTopLevelButtons = controller.flexAsTopLevelButtons.slice(0, -1); + return guarded(); + } + return "fits"; + }); + guarded = createMenuUpdateGuard(original, controller, { now: () => 10 }); + + expect(guarded()).toBe("fits"); + + expect(original).toHaveBeenCalledTimes(4); + expect(controller.flexAsTopLevelButtons).toEqual(["share"]); + }); + + test("restores flexible actions before recalculating a wider layout in a later burst", () => { + const allButtons = ["share", "save", "thanks", "download"]; + const controller = makeController(allButtons); + let fittingButtonCount = 1; + let currentTime = 10; + let layoutKey = "490:42"; + let guarded; + const original = jest.fn(() => { + if (controller.flexAsTopLevelButtons.length > fittingButtonCount) { + controller.flexAsTopLevelButtons = controller.flexAsTopLevelButtons.slice(0, -1); + return guarded(); + } + return "fits"; + }); + guarded = createMenuUpdateGuard(original, controller, { + getLayoutKey: () => layoutKey, + now: () => currentTime, + }); + + expect(guarded()).toBe("fits"); + expect(controller.flexAsTopLevelButtons).toEqual(["share"]); + + fittingButtonCount = 3; + currentTime = 200; + layoutKey = "888:42"; + expect(guarded()).toBe("fits"); + + expect(controller.flexAsTopLevelButtons).toEqual(["share", "save", "thanks"]); + }); + + test("restores flexible actions after same-video menu data identity churn", () => { + const allButtons = ["save", "thanks", "download"]; + const controller = { ...makeController(allButtons), data: { revision: 1 } }; + let fittingButtonCount = 0; + let currentTime = 10; + let guarded; + const original = jest.fn(() => { + if (controller.flexAsTopLevelButtons.length > fittingButtonCount) { + controller.flexAsTopLevelButtons = controller.flexAsTopLevelButtons.slice(0, -1); + return guarded(); + } + return "fits"; + }); + guarded = createMenuUpdateGuard(original, controller, { + getContextKey: () => "watch:video-a", + now: () => currentTime, + }); + + expect(guarded()).toBe("fits"); + expect(controller.flexAsTopLevelButtons).toEqual([]); + + controller.data = { revision: 2 }; + fittingButtonCount = 2; + currentTime = 200; + expect(guarded()).toBe("fits"); + + expect(controller.flexAsTopLevelButtons).toEqual(["save", "thanks"]); + }); + + test("starts a fresh fit immediately when the rendered action width changes during a continuous update burst", () => { + const allButtons = ["save", "thanks", "download"]; + const controller = makeController(allButtons); + let fittingButtonCount = 0; + let layoutKey = "490:42"; + let guarded; + const original = jest.fn(() => { + if (controller.flexAsTopLevelButtons.length > fittingButtonCount) { + controller.flexAsTopLevelButtons = controller.flexAsTopLevelButtons.slice(0, -1); + return guarded(); + } + return "fits"; + }); + guarded = createMenuUpdateGuard(original, controller, { + getContextKey: () => "watch:video-a", + getLayoutKey: () => layoutKey, + now: () => 10, + }); + + expect(guarded()).toBe("fits"); + expect(controller.flexAsTopLevelButtons).toEqual([]); + + fittingButtonCount = 2; + layoutKey = "888:40"; + expect(guarded()).toBe("fits"); + + expect(controller.flexAsTopLevelButtons).toEqual(["save", "thanks"]); + }); + + test("does not restore flexible actions retained for a different video", () => { + const controller = { ...makeController(["old-save", "old-thanks"]), data: { revision: 1 } }; + let contextKey = "watch:video-a"; + let currentTime = 10; + const original = jest.fn(); + const guarded = createMenuUpdateGuard(original, controller, { + getContextKey: () => contextKey, + now: () => currentTime, + }); + + guarded(); + controller.flexAsTopLevelButtons = []; + controller.data = { revision: 2 }; + contextKey = "watch:video-b"; + currentTime = 200; + guarded(); + + expect(controller.flexAsTopLevelButtons).toEqual([]); + }); + + test("does not restore stale flexible actions after YouTube replaces the menu model", () => { + const firstData = { videoId: "first" }; + const secondData = { videoId: "second" }; + const controller = { ...makeController(["old-save", "old-thanks"]), data: firstData }; + let currentTime = 10; + const original = jest.fn(); + const guarded = createMenuUpdateGuard(original, controller, { now: () => currentTime }); + + guarded(); + controller.flexAsTopLevelButtons = ["old-save"]; + + controller.data = secondData; + controller.flexAsTopLevelButtons = ["new-save"]; + currentTime = 200; + guarded(); + + expect(controller.flexAsTopLevelButtons).toEqual(["new-save"]); + expect(controller.flexAsTopLevelButtons).not.toContain("old-thanks"); + }); + + test("allows one same-state retry to react to a new width measurement", () => { + const controller = makeController(["share", "save", "thanks", "download"]); + let guarded; + const original = jest + .fn() + .mockImplementationOnce(() => undefined) + .mockImplementationOnce(() => { + controller.flexAsTopLevelButtons = ["share", "save", "thanks"]; + return guarded(); + }) + .mockImplementationOnce(() => "fits"); + guarded = createMenuUpdateGuard(original, controller, { now: () => 10 }); + + guarded(); + expect(guarded()).toBe("fits"); + + expect(original).toHaveBeenCalledTimes(3); + expect(controller.flexAsTopLevelButtons).toEqual(["share", "save", "thanks"]); + }); + + test("does not restore a safe prefix for a delayed same-layout refresh but restores it after expansion", () => { + const controller = makeController(["share", "save"]); + let currentTime = 10; + let layoutKey = "388.3:42.4"; + let nativeFitEnabled = true; + let guarded; + const original = jest.fn(() => { + if (nativeFitEnabled && controller.flexAsTopLevelButtons.length > 0) { + controller.flexAsTopLevelButtons = controller.flexAsTopLevelButtons.slice(0, -1); + return guarded(); + } + return "fits"; + }); + guarded = createMenuUpdateGuard(original, controller, { + getLayoutKey: () => layoutKey, + now: () => currentTime, + }); + + expect(guarded()).toBe("fits"); + expect(controller.flexAsTopLevelButtons).toEqual([]); + + nativeFitEnabled = false; + currentTime = 250; + expect(guarded()).toBe("fits"); + expect(controller.flexAsTopLevelButtons).toEqual([]); + + layoutKey = "600:42.4"; + expect(guarded()).toBe("fits"); + expect(controller.flexAsTopLevelButtons).toEqual(["share", "save"]); + }); + + test("overflows one additional flexible action per unsafe rendered layout", () => { + const controller = makeController(["share", "save"]); + let needsMoreOverflow = true; + const guarded = createMenuUpdateGuard( + jest.fn(() => "fits"), + controller, + { + needsMoreOverflow: () => needsMoreOverflow, + now: () => 10, + }, + ); + + expect(guarded()).toBe("fits"); + expect(controller.flexAsTopLevelButtons).toEqual(["share"]); + expect(guarded.enforceLayout()).toBe(true); + expect(controller.flexAsTopLevelButtons).toEqual([]); + expect(guarded.enforceLayout()).toBe(false); + + needsMoreOverflow = false; + expect(guarded.enforceLayout()).toBe(false); + expect(controller.flexAsTopLevelButtons).toEqual([]); + }); + + test("waits for updated geometry when the native fitter already removed an action", () => { + const controller = makeController(["share", "save"]); + let renderedLayoutNeedsMoreOverflow = true; + const guarded = createMenuUpdateGuard( + jest.fn(() => { + controller.flexAsTopLevelButtons = controller.flexAsTopLevelButtons.slice(0, -1); + renderedLayoutNeedsMoreOverflow = false; + return "fits"; + }), + controller, + { + needsMoreOverflow: () => renderedLayoutNeedsMoreOverflow, + now: () => 10, + }, + ); + + expect(guarded()).toBe("fits"); + expect(controller.flexAsTopLevelButtons).toEqual(["share"]); + expect(guarded.enforceLayout()).toBe(false); + }); + + test("preserves return values and rejections from native updates", async () => { + const controller = makeController(); + const expectedError = new Error("layout failed"); + const resolved = createMenuUpdateGuard(jest.fn().mockResolvedValue("done"), controller, { now: () => 10 }); + const rejected = createMenuUpdateGuard(jest.fn().mockRejectedValue(expectedError), controller, { + now: () => 200, + }); + + await expect(resolved()).resolves.toBe("done"); + await expect(rejected()).rejects.toBe(expectedError); + }); + + test("hide-clutter mode moves every optional action into overflow without calling native layout", () => { + const { data, reaction, save, share, shareCommand } = makeCurrentMenuData(); + const controller = { ...makeController(), data }; + const original = jest.fn(); + const guarded = createMenuUpdateGuard(original, controller, { + isHideClutterEnabled: () => true, + now: () => 10, + }); + + guarded(); + + expect(original).not.toHaveBeenCalled(); + expect(controller.flexAsTopLevelButtons).toEqual([]); + expect(controller.data).not.toBe(data); + expect(controller.data.topLevelButtons).toEqual([reaction]); + expect(controller.data.flexibleItems).toHaveLength(2); + expect(controller.data.flexibleItems[0]).toEqual({ + menuFlexibleItemRenderer: { + menuItem: { + menuServiceItemRenderer: { + icon: { iconType: "SHARE" }, + serviceEndpoint: shareCommand, + text: { runs: [{ text: "Share" }] }, + trackingParams: "share-tracking", + }, + }, + topLevelButton: share, + }, + }); + expect(controller.data.flexibleItems[1]).toBe(save); + }); + + test("recognizes current and legacy reaction models without treating Share as a reaction", () => { + expect(containsReactionButtonModel({ segmentedLikeDislikeButtonViewModel: {} })).toBe(true); + expect(containsReactionButtonModel({ toggleButtonRenderer: { defaultIcon: { iconType: "DISLIKE" } } })).toBe(true); + expect(containsReactionButtonModel({ buttonViewModel: { accessibilityId: "id.video.like.button" } })).toBe(true); + expect(containsReactionButtonModel({ buttonViewModel: { iconName: "SHARE" } })).toBe(false); + }); + + test("does not hide a fixed button when it cannot be represented losslessly in overflow", () => { + const reaction = { segmentedLikeDislikeButtonViewModel: {} }; + const unknown = { buttonViewModel: { title: "Unknown", iconName: "UNKNOWN" } }; + const data = { topLevelButtons: [reaction, unknown], flexibleItems: [] }; + + expect(createClutterMenuData(data)).toMatchObject({ + topLevelButtons: [reaction, unknown], + flexibleItems: [], + }); + expect(convertTopLevelButtonToFlexibleItem(unknown)).toBeNull(); + }); + + test("keeps fixed optional actions flexible when hide-clutter is disabled", () => { + const { data, reaction, share } = makeCurrentMenuData(); + const controller = { ...makeController(), data }; + let hideClutter = true; + const original = jest.fn(); + const guarded = createMenuUpdateGuard(original, controller, { + isHideClutterEnabled: () => hideClutter, + now: () => 10, + }); + + guarded(); + expect(controller.data).not.toBe(data); + hideClutter = false; + guarded.applyPreference(); + + expect(controller.data).not.toBe(data); + expect(controller.data.topLevelButtons).toEqual([reaction]); + expect(controller.data.flexibleItems[0].menuFlexibleItemRenderer.topLevelButton).toBe(share); + expect(controller.flexAsTopLevelButtons).toEqual(["share", "save", "thanks"]); + expect(original).toHaveBeenCalledTimes(1); + }); + + test("lets native fitting overflow a Share button that YouTube supplied as fixed", () => { + const { data, reaction, save, share } = makeCurrentMenuData(); + const shareElement = { id: "share" }; + const saveElement = { id: "save" }; + let currentData = data; + let flexibleButtons = [saveElement]; + const controller = { maybeUpdateFlexibleMenuImpl: jest.fn() }; + Object.defineProperty(controller, "data", { + configurable: true, + get: () => currentData, + set: (value) => { + currentData = value; + flexibleButtons = value.flexibleItems.map((item) => + item?.menuFlexibleItemRenderer?.topLevelButton === share ? shareElement : saveElement, + ); + }, + }); + Object.defineProperty(controller, "flexAsTopLevelButtons", { + configurable: true, + get: () => flexibleButtons, + set: (value) => { + flexibleButtons = [...value]; + }, + }); + const nativeFit = jest.fn(() => { + controller.flexAsTopLevelButtons = []; + }); + const guarded = createMenuUpdateGuard(nativeFit, controller, { + isHideClutterEnabled: () => false, + now: () => 10, + }); + + guarded(); + + expect(controller.data.topLevelButtons).toEqual([reaction]); + expect(controller.data.flexibleItems).toHaveLength(2); + expect(controller.data.flexibleItems[0].menuFlexibleItemRenderer.topLevelButton).toBe(share); + expect(controller.data.flexibleItems[1]).toBe(save); + expect(nativeFit).toHaveBeenCalledTimes(1); + expect(controller.flexAsTopLevelButtons).toEqual([]); + }); + + test("does not restore a stale video model while normalizing replacement data", () => { + const first = makeCurrentMenuData().data; + const secondFixture = makeCurrentMenuData(); + const second = secondFixture.data; + second.videoId = "second-video"; + const controller = { ...makeController(), data: first }; + let hideClutter = true; + const guarded = createMenuUpdateGuard(jest.fn(), controller, { + isHideClutterEnabled: () => hideClutter, + now: () => 10, + }); + + guarded(); + controller.data = second; + hideClutter = false; + guarded.applyPreference(); + + expect(controller.data).not.toBe(first); + expect(controller.data).not.toBe(second); + expect(controller.data.videoId).toBe("second-video"); + expect(controller.data.topLevelButtons).toEqual([secondFixture.reaction]); + expect(controller.data.flexibleItems[0].menuFlexibleItemRenderer.topLevelButton).toBe(secondFixture.share); + }); + + test("reapplies hide-clutter to replacement menu data during SPA navigation", async () => { + document.documentElement.setAttribute(HIDE_CLUTTER_ATTRIBUTE, "true"); + const first = makeCurrentMenuData().data; + const secondFixture = makeCurrentMenuData(); + secondFixture.data.videoId = "second-video"; + const controller = { ...makeController(), data: first }; + const renderer = attachRenderer(controller); + const installation = installMenuFixer(); + + controller.data = secondFixture.data; + renderer.append(document.createElement("span")); + await Promise.resolve(); + + expect(controller.data).not.toBe(secondFixture.data); + expect(controller.data.videoId).toBe("second-video"); + expect(controller.data.topLevelButtons).toEqual([secondFixture.reaction]); + expect(controller.data.flexibleItems[0].menuFlexibleItemRenderer.topLevelButton).toBe(secondFixture.share); + + installation.stop(); + }); + + test("only truncates flexible actions when the requested limit is smaller", () => { + const controller = makeController(); + const originalArray = controller.flexAsTopLevelButtons; + + keepAtMostFlexibleButtons(controller, 4); + expect(controller.flexAsTopLevelButtons).toBe(originalArray); + + keepAtMostFlexibleButtons(controller, 2); + expect(controller.flexAsTopLevelButtons).toEqual(["share", "save"]); + }); + + test("patches each initialized renderer once and retries renderers whose controller is not ready", () => { + const controller = makeController(); + const renderer = attachRenderer(controller); + const original = controller.maybeUpdateFlexibleMenuImpl; + + expect(fixYtdMenuRenderer(renderer)).toBe(true); + const firstGuard = controller.maybeUpdateFlexibleMenuImpl; + expect(firstGuard).not.toBe(original); + expect(fixYtdMenuRenderer(renderer)).toBe(true); + expect(controller.maybeUpdateFlexibleMenuImpl).toBe(firstGuard); + + const pendingRenderer = attachRenderer(undefined, "pending-menu"); + expect(fixYtdMenuRenderer(pendingRenderer)).toBe(false); + Object.defineProperty(pendingRenderer, "polymerController", { configurable: true, value: makeController() }); + expect(fixYtdMenuRenderer(pendingRenderer)).toBe(true); + }); + + test("uses the outer action allocation as the layout key", () => { + const actions = document.createElement("div"); + actions.id = "actions"; + actions.getBoundingClientRect = () => ({ height: 42, width: 490 }); + const renderer = document.createElement("ytd-menu-renderer"); + actions.append(renderer); + document.body.append(actions); + + expect(getMenuLayoutKey(renderer)).toBe("490:42"); + }); + + test("detects clipped and wrapped action layouts without treating the menu shell as an action", () => { + const renderer = document.createElement("ytd-menu-renderer"); + renderer.innerHTML = ` +
+
+ `; + document.body.append(renderer); + const surface = renderer.querySelector("#top-level-buttons-computed"); + const flexibleHost = renderer.querySelector("#flexible-item-buttons > *"); + const flexibleButton = flexibleHost.querySelector("button"); + const moreButton = renderer.querySelector("yt-button-shape button"); + const setBox = (element, { height = 40, width, x, y }) => { + element.getBoundingClientRect = () => ({ + bottom: y + height, + height, + left: x, + right: x + width, + top: y, + width, + x, + y, + }); + }; + setBox(renderer, { height: 44, width: 404, x: 0, y: 100 }); + setBox(surface, { width: 220, x: 100, y: 100 }); + setBox(surface.querySelector("button"), { width: 220, x: 100, y: 100 }); + setBox(flexibleHost, { width: 120, x: 328, y: 144 }); + setBox(flexibleButton, { width: 120, x: 328, y: 144 }); + setBox(moreButton, { width: 40, x: 456, y: 144 }); + + expect(menuNeedsMoreOverflow(renderer, { viewportWidth: 800 })).toBe(true); + + setBox(flexibleHost, { width: 120, x: 328, y: 100 }); + setBox(flexibleButton, { width: 120, x: 328, y: 100 }); + setBox(moreButton, { width: 40, x: 456, y: 100 }); + expect(menuNeedsMoreOverflow(renderer, { viewportWidth: 800 })).toBe(false); + + setBox(surface, { width: 220, x: -25, y: 100 }); + expect(menuNeedsMoreOverflow(renderer, { viewportWidth: 390 })).toBe(true); + }); + + test("recenters the narrow Watch action allocation inside the observed oversized mobile row", () => { + const stylesheet = fs.readFileSync(path.join(__dirname, "../content-style.css"), "utf8"); + const narrowRule = stylesheet.match(/@media \(max-width: 480px\) \{\s*([^{}]+)\{([^{}]+)\}\s*\}/); + expect(narrowRule).not.toBeNull(); + + const [, selectors, declarations] = narrowRule; + expect(selectors).toContain( + ":is(ytd-watch-flexy, ytd-watch-grid)[video-id] #top-row ytd-menu-renderer.ytd-watch-metadata", + ); + expect(selectors).toContain(":is(ytd-watch-flexy, ytd-watch-grid)[video-id] #top-row #actions"); + expect(selectors).toContain(":is(ytd-watch-flexy, ytd-watch-grid)[video-id] #top-row #actions-inner"); + expect(declarations).toContain("box-sizing: border-box"); + expect(declarations).toContain("margin-inline: auto !important"); + expect(declarations).toContain("min-width: 0 !important"); + expect(declarations).toContain("width: 100% !important"); + + const horizontalGutter = Number(declarations.match(/max-width: calc\(100vw - (\d+)px\) !important/)?.[1]); + expect(horizontalGutter).toBe(24); + + // Exact geometry from the authenticated 390px failure. Auto inline margins + // center the capped allocation within YouTube's oversized native row. + const viewportWidth = 390; + const nativeTopRow = { width: 426.663, x: -25.725 }; + const constrainedWidth = viewportWidth - horizontalGutter; + const constrainedX = nativeTopRow.x + (nativeTopRow.width - constrainedWidth) / 2; + expect(constrainedX).toBeGreaterThanOrEqual(0); + expect(constrainedX + constrainedWidth).toBeLessThanOrEqual(viewportWidth); + }); + + test("observes hide-clutter preference changes and restores native fitting when disabled", async () => { + const controller = makeController(); + const original = controller.maybeUpdateFlexibleMenuImpl; + attachRenderer(controller); + const installation = installMenuFixer(); + expect(original).toHaveBeenCalledTimes(1); + + document.documentElement.setAttribute(HIDE_CLUTTER_ATTRIBUTE, "true"); + await Promise.resolve(); + expect(controller.flexAsTopLevelButtons).toEqual([]); + + document.documentElement.setAttribute(HIDE_CLUTTER_ATTRIBUTE, "false"); + await Promise.resolve(); + expect(original).toHaveBeenCalledTimes(2); + expect(controller.flexAsTopLevelButtons).toEqual(["share", "save", "thanks"]); + + installation.stop(); + }); +}); diff --git a/Extensions/combined/src/patreon.js b/Extensions/combined/src/patreon.js index 989db30..027f439 100644 --- a/Extensions/combined/src/patreon.js +++ b/Extensions/combined/src/patreon.js @@ -1,9 +1,10 @@ import { initPremiumAnalytics, teardownPremiumAnalytics, updatePremiumSession } from "./premiumAnalytics"; +import { initPremiumTeaser, setTeaserSuppressed, TEASER_SUPPRESSION_REASON_PREMIUM } from "./premiumAnalytics/teaser"; import { - initPremiumTeaser, - setTeaserSuppressed, - TEASER_SUPPRESSION_REASON_PREMIUM, -} from "./premiumAnalytics/teaser"; + hasAuthenticationDataPermission, + onAuthenticationDataPermissionRemoved, + usesFirefoxDataCollectionConsent, +} from "./data-collection-permissions"; let patreonState = { authenticated: false, @@ -13,41 +14,83 @@ let patreonState = { function initPatreonFeatures() { initPremiumTeaser(); + let sessionGeneration = 0; + const initialGeneration = sessionGeneration; - chrome.storage.sync.get(["patreonAuthenticated", "patreonUser", "patreonSessionToken"], (data) => { - if (data.patreonAuthenticated && data.patreonUser) { - patreonState.authenticated = true; - patreonState.user = data.patreonUser; - patreonState.sessionToken = data.patreonSessionToken; - updatePremiumSession({ - token: patreonState.sessionToken, - active: patreonState.user?.hasActiveMembership, - membershipTier: patreonState.user?.membershipTier, - }); - enablePremiumFeatures(); + const loadCachedSession = (granted) => { + if (initialGeneration !== sessionGeneration) return; + if (!granted) { + clearPatreonState(); + return; } - }); - chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { - if (request.message === "patreon_status_changed") { - patreonState.authenticated = request.authenticated; - patreonState.user = request.user || null; - - if (request.authenticated) { - patreonState.sessionToken = request.sessionToken ?? patreonState.sessionToken; + chrome.storage.sync.get(["patreonAuthenticated", "patreonUser", "patreonSessionToken"], (data) => { + if (initialGeneration !== sessionGeneration) return; + if (data.patreonAuthenticated && data.patreonUser && data.patreonSessionToken) { + patreonState.authenticated = true; + patreonState.user = data.patreonUser; + patreonState.sessionToken = data.patreonSessionToken; updatePremiumSession({ token: patreonState.sessionToken, active: patreonState.user?.hasActiveMembership, membershipTier: patreonState.user?.membershipTier, }); enablePremiumFeatures(); + } + }); + }; + + if (usesFirefoxDataCollectionConsent()) { + hasAuthenticationDataPermission().then(loadCachedSession); + } else { + loadCachedSession(true); + } + + chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { + if (request.message === "patreon_status_changed") { + const generation = ++sessionGeneration; + if (!request.authenticated) { + clearPatreonState(); + return; + } + const applyStatus = (granted) => { + if (generation !== sessionGeneration) return; + if (!granted || !request.user || !request.sessionToken) { + clearPatreonState(); + return; + } + + patreonState.authenticated = true; + patreonState.user = request.user || null; + patreonState.sessionToken = request.sessionToken ?? null; + updatePremiumSession({ + token: patreonState.sessionToken, + active: patreonState.user?.hasActiveMembership, + membershipTier: patreonState.user?.membershipTier, + }); + enablePremiumFeatures(); + }; + + if (usesFirefoxDataCollectionConsent()) { + hasAuthenticationDataPermission().then(applyStatus); } else { - patreonState.sessionToken = null; - updatePremiumSession({ token: null, active: false }); - disablePremiumFeatures(); + applyStatus(true); } } }); + + onAuthenticationDataPermissionRemoved(() => { + sessionGeneration++; + clearPatreonState(); + }); +} + +function clearPatreonState() { + patreonState.authenticated = false; + patreonState.user = null; + patreonState.sessionToken = null; + updatePremiumSession({ token: null, active: false }); + disablePremiumFeatures(); } function enablePremiumFeatures() { diff --git a/Extensions/combined/src/patreon.spec.js b/Extensions/combined/src/patreon.spec.js new file mode 100644 index 0000000..e19a35d --- /dev/null +++ b/Extensions/combined/src/patreon.spec.js @@ -0,0 +1,150 @@ +/** + * @jest-environment jsdom + */ + +jest.mock("./premiumAnalytics", () => ({ + initPremiumAnalytics: jest.fn(), + teardownPremiumAnalytics: jest.fn(), + updatePremiumSession: jest.fn(), +})); + +jest.mock("./premiumAnalytics/teaser", () => ({ + initPremiumTeaser: jest.fn(), + setTeaserSuppressed: jest.fn(), + TEASER_SUPPRESSION_REASON_PREMIUM: "premium", +})); + +jest.mock("./data-collection-permissions", () => ({ + hasAuthenticationDataPermission: jest.fn(), + onAuthenticationDataPermissionRemoved: jest.fn(), + usesFirefoxDataCollectionConsent: jest.fn(), +})); + +import { initPatreonFeatures, patreonState } from "./patreon"; + +const analyticsMocks = jest.requireMock("./premiumAnalytics"); +const consentMocks = jest.requireMock("./data-collection-permissions"); + +const flushPromises = () => new Promise((resolve) => setTimeout(resolve, 0)); + +describe("Patreon authentication consent", () => { + let messageListener; + let permissionRemovedListener; + + beforeEach(() => { + jest.clearAllMocks(); + document.body.innerHTML = '
'; + patreonState.authenticated = false; + patreonState.user = null; + patreonState.sessionToken = null; + + global.chrome = { + runtime: { + onMessage: { + addListener: jest.fn((listener) => { + messageListener = listener; + }), + }, + }, + storage: { + sync: { + get: jest.fn(), + }, + }, + }; + + consentMocks.hasAuthenticationDataPermission.mockResolvedValue(false); + consentMocks.usesFirefoxDataCollectionConsent.mockReturnValue(true); + consentMocks.onAuthenticationDataPermissionRemoved.mockImplementation((listener) => { + permissionRemovedListener = listener; + return () => {}; + }); + }); + + afterEach(() => { + delete global.chrome; + }); + + it("does not read a cached token without authentication consent", async () => { + initPatreonFeatures(); + await flushPromises(); + + expect(chrome.storage.sync.get).not.toHaveBeenCalled(); + expect(patreonState.sessionToken).toBeNull(); + expect(analyticsMocks.updatePremiumSession).toHaveBeenCalledWith({ token: null, active: false }); + }); + + it("rejects a session-token broadcast when consent is absent", async () => { + initPatreonFeatures(); + await flushPromises(); + + messageListener({ + message: "patreon_status_changed", + authenticated: true, + user: { hasActiveMembership: true, membershipTier: "premium" }, + sessionToken: "secret-token", + }); + await flushPromises(); + + expect(patreonState.sessionToken).toBeNull(); + expect(analyticsMocks.updatePremiumSession).not.toHaveBeenCalledWith( + expect.objectContaining({ token: "secret-token" }), + ); + }); + + it("tears down premium state when authentication consent is removed", async () => { + consentMocks.hasAuthenticationDataPermission.mockResolvedValue(true); + chrome.storage.sync.get.mockImplementation((keys, callback) => { + callback({ + patreonAuthenticated: true, + patreonUser: { hasActiveMembership: true, membershipTier: "premium" }, + patreonSessionToken: "secret-token", + }); + }); + + initPatreonFeatures(); + await flushPromises(); + permissionRemovedListener(); + + expect(patreonState.authenticated).toBe(false); + expect(patreonState.sessionToken).toBeNull(); + expect(analyticsMocks.updatePremiumSession).toHaveBeenLastCalledWith({ token: null, active: false }); + expect(analyticsMocks.teardownPremiumAnalytics).toHaveBeenCalled(); + }); + + it("ignores a cached session read that completes after revocation", async () => { + let completeRead; + consentMocks.hasAuthenticationDataPermission.mockResolvedValue(true); + chrome.storage.sync.get.mockImplementation((keys, callback) => (completeRead = callback)); + initPatreonFeatures(); + await flushPromises(); + permissionRemovedListener(); + completeRead({ + patreonAuthenticated: true, + patreonUser: { hasActiveMembership: true, membershipTier: "premium" }, + patreonSessionToken: "old-token", + }); + expect(patreonState.sessionToken).toBeNull(); + expect(analyticsMocks.initPremiumAnalytics).not.toHaveBeenCalled(); + }); + + it("does not apply a delayed authenticated broadcast after logout", async () => { + initPatreonFeatures(); + await flushPromises(); + let completeConsent; + consentMocks.hasAuthenticationDataPermission.mockReturnValueOnce( + new Promise((resolve) => (completeConsent = resolve)), + ); + messageListener({ + message: "patreon_status_changed", + authenticated: true, + user: { hasActiveMembership: true, membershipTier: "premium" }, + sessionToken: "old-token", + }); + messageListener({ message: "patreon_status_changed", authenticated: false }); + completeConsent(true); + await flushPromises(); + expect(patreonState.sessionToken).toBeNull(); + expect(analyticsMocks.initPremiumAnalytics).not.toHaveBeenCalled(); + }); +}); diff --git a/Extensions/combined/src/popup-clutter-setting.spec.js b/Extensions/combined/src/popup-clutter-setting.spec.js new file mode 100644 index 0000000..62d4347 --- /dev/null +++ b/Extensions/combined/src/popup-clutter-setting.spec.js @@ -0,0 +1,63 @@ +/** + * @jest-environment jsdom + */ + +const fs = require("fs"); +const path = require("path"); + +const extensionRoot = path.resolve(__dirname, ".."); + +beforeEach(() => { + document.documentElement.innerHTML = fs.readFileSync(path.join(extensionRoot, "popup.html"), "utf8"); +}); + +test("popup exposes an off-by-default hide-clutter checkbox", () => { + const checkbox = document.getElementById("hide_clutter_buttons"); + + expect(checkbox).toBeInstanceOf(HTMLInputElement); + expect(checkbox.type).toBe("checkbox"); + expect(checkbox.checked).toBe(false); + expect(document.querySelector('label[for="hide_clutter_buttons"]')).not.toBeNull(); +}); + +test("question-mark help is keyboard focusable and describes the rich tooltip", () => { + const help = document.getElementById("hide_clutter_buttons_help"); + const tooltip = document.getElementById("hide_clutter_buttons_tooltip"); + + expect(help.tagName).toBe("BUTTON"); + expect(help.type).toBe("button"); + expect(help.textContent.trim()).toBe("?"); + expect(help.getAttribute("aria-describedby")).toBe(tooltip.id); + expect(tooltip.getAttribute("role")).toBe("tooltip"); +}); + +test("tooltip contains stable before and after image hooks", () => { + const before = document.querySelector('[data-hide-clutter-example="before"]'); + const after = document.querySelector('[data-hide-clutter-example="after"]'); + + expect(before.getAttribute("src")).toBe("images/hide-clutter-before.png"); + expect(after.getAttribute("src")).toBe("images/hide-clutter-after.png"); + expect(before.getAttribute("alt")).toBe("__MSG_hideClutterButtonsBefore__"); + expect(after.getAttribute("alt")).toBe("__MSG_hideClutterButtonsAfter__"); +}); + +test("every shipped locale provides non-empty hide-clutter copy", () => { + const messageKeys = [ + "hideClutterButtons", + "hideClutterButtonsHelpLabel", + "hideClutterButtonsDescription", + "hideClutterButtonsBefore", + "hideClutterButtonsAfter", + ]; + const localeRoot = path.join(extensionRoot, "_locales"); + const localeDirectories = fs.readdirSync(localeRoot, { withFileTypes: true }).filter((entry) => entry.isDirectory()); + + expect(localeDirectories).toHaveLength(22); + for (const locale of localeDirectories) { + const messages = JSON.parse(fs.readFileSync(path.join(localeRoot, locale.name, "messages.json"), "utf8")); + for (const key of messageKeys) { + expect(messages[key]?.message).toEqual(expect.any(String)); + expect(messages[key].message.trim()).not.toBe(""); + } + } +}); diff --git a/Extensions/combined/src/premiumAnalytics/index.spec.js b/Extensions/combined/src/premiumAnalytics/index.spec.js index a034a19..5bbc615 100644 --- a/Extensions/combined/src/premiumAnalytics/index.spec.js +++ b/Extensions/combined/src/premiumAnalytics/index.spec.js @@ -7,6 +7,11 @@ jest.mock("../config", () => ({ getApiEndpoint: jest.fn((path) => `https://api.example${path}`), })); +jest.mock("../data-collection-permissions", () => ({ + hasAuthenticationDataPermission: jest.fn(), + usesFirefoxDataCollectionConsent: jest.fn(), +})); + jest.mock("./panel", () => ({ configurePanelCallbacks: jest.fn(), ensurePanel: jest.fn(() => ({})), @@ -76,6 +81,12 @@ const listsMocks = jest.requireMock("./lists"); const utilsMocks = jest.requireMock("./utils"); const loggingMocks = jest.requireMock("./logging"); const configMocks = jest.requireMock("../config"); +const { hasAuthenticationDataPermission: mockHasAuthenticationDataPermission } = jest.requireMock( + "../data-collection-permissions", +); +const { usesFirefoxDataCollectionConsent: mockUsesFirefoxDataCollectionConsent } = jest.requireMock( + "../data-collection-permissions", +); const { configurePanelCallbacks: mockConfigurePanelCallbacks, @@ -109,10 +120,8 @@ const { debounce: mockDebounce, safeJson: mockSafeJson, toEpoch: mockToEpoch } = const { logFetchRequest: mockLogFetchRequest } = loggingMocks; const { getApiEndpoint: mockGetApiEndpoint } = configMocks; -const { - setTeaserSuppressed: mockSetTeaserSuppressed, - TEASER_SUPPRESSION_REASON_PREMIUM, -} = jest.requireMock("./teaser"); +const { setTeaserSuppressed: mockSetTeaserSuppressed, TEASER_SUPPRESSION_REASON_PREMIUM } = + jest.requireMock("./teaser"); const { showTierNotice: mockShowTierNotice, hideTierNotice: mockHideTierNotice } = jest.requireMock("./tierNotice"); jest.mock("../utils", () => { @@ -124,12 +133,7 @@ jest.mock("../utils", () => { }); import { analyticsState, resetSessionState, resetStateForVideo } from "./state"; -import { - initPremiumAnalytics, - requestAnalytics, - teardownPremiumAnalytics, - updatePremiumSession, -} from "./index"; +import { initPremiumAnalytics, requestAnalytics, teardownPremiumAnalytics, updatePremiumSession } from "./index"; const premiumAnalyticsModule = require("./index"); const flushPromises = () => new Promise((resolve) => setTimeout(resolve, 0)); @@ -156,6 +160,8 @@ function getMessage(key, substitutions) { describe("premiumAnalytics", () => { beforeEach(() => { jest.clearAllMocks(); + mockHasAuthenticationDataPermission.mockResolvedValue(true); + mockUsesFirefoxDataCollectionConsent.mockReturnValue(false); resetStateForVideo(); resetSessionState(); analyticsState.sessionToken = "token"; @@ -238,6 +244,44 @@ describe("premiumAnalytics", () => { expect(mockToEpoch).toHaveBeenCalledWith("2025-01-07T00:00:00Z"); }); + it("does not use a cached analytics token after authentication consent is removed", async () => { + mockUsesFirefoxDataCollectionConsent.mockReturnValueOnce(true); + mockHasAuthenticationDataPermission.mockResolvedValueOnce(false); + + await requestAnalytics(); + + expect(fetch).not.toHaveBeenCalled(); + expect(analyticsState.sessionToken).toBeNull(); + expect(analyticsState.sessionActive).toBe(false); + }); + + it("ignores an analytics response from a session that was signed out", async () => { + let completeRequest; + const response = await fetch(); + fetch.mockClear(); + fetch.mockReturnValueOnce(new Promise((resolve) => (completeRequest = resolve))); + requestAnalytics(); + updatePremiumSession({ token: null, active: false }); + completeRequest(response); + await flushPromises(); + expect(mockRenderActivityChart).not.toHaveBeenCalled(); + expect(mockRenderSummary).not.toHaveBeenCalled(); + }); + + it("does not render a previous account response after a new account requests the same range", async () => { + let completeOldRequest; + const response = await fetch(); + fetch.mockClear(); + fetch.mockReturnValueOnce(new Promise((resolve) => (completeOldRequest = resolve))); + requestAnalytics(); + updatePremiumSession({ token: "new-account-token", active: true, membershipTier: "premium" }); + await flushPromises(); + const renderCount = mockRenderSummary.mock.calls.length; + completeOldRequest(response); + await flushPromises(); + expect(mockRenderSummary).toHaveBeenCalledTimes(renderCount); + }); + it("handles error responses gracefully", async () => { global.fetch = jest.fn(() => Promise.resolve({ diff --git a/Extensions/combined/src/premiumAnalytics/lists/index.js b/Extensions/combined/src/premiumAnalytics/lists/index.js index 8a5461f..658e67d 100644 --- a/Extensions/combined/src/premiumAnalytics/lists/index.js +++ b/Extensions/combined/src/premiumAnalytics/lists/index.js @@ -2,7 +2,10 @@ import { sanitizeCount } from "../utils"; import { localize } from "../../utils"; function createPlaceholder() { - return `
  • ${localize("premiumAnalytics_noData")}
  • `; + const placeholder = document.createElement("li"); + placeholder.className = "ryd-analytics__placeholder"; + placeholder.textContent = localize("premiumAnalytics_noData"); + return placeholder; } function renderEntry({ countryCode, countryName, likes, dislikes }, type) { @@ -10,17 +13,25 @@ function renderEntry({ countryCode, countryName, likes, dislikes }, type) { const safeValue = sanitizeCount(value); const name = countryName || countryCode || localize("premiumAnalytics_unknownRegion"); const codeSuffix = countryCode ? ` (${countryCode})` : ""; - return `
  • ${name}${codeSuffix}${safeValue.toLocaleString()}
  • `; + const row = document.createElement("li"); + const country = document.createElement("span"); + country.className = "ryd-analytics__country"; + country.textContent = `${name}${codeSuffix}`; + const count = document.createElement("span"); + count.className = "ryd-analytics__value"; + count.textContent = safeValue.toLocaleString(); + row.append(country, count); + return row; } function updateCountryList(container, entries, type) { if (!container) return; if (!entries?.length) { - container.innerHTML = createPlaceholder(); + container.replaceChildren(createPlaceholder()); return; } - container.innerHTML = entries.map((entry) => renderEntry(entry, type)).join(""); + container.replaceChildren(...entries.map((entry) => renderEntry(entry, type))); } export { updateCountryList }; diff --git a/Extensions/combined/src/premiumAnalytics/lists/index.spec.js b/Extensions/combined/src/premiumAnalytics/lists/index.spec.js new file mode 100644 index 0000000..81ef7ab --- /dev/null +++ b/Extensions/combined/src/premiumAnalytics/lists/index.spec.js @@ -0,0 +1,26 @@ +/** @jest-environment jsdom */ +jest.mock("../../utils", () => ({ localize: (key) => key })); + +import { updateCountryList } from "./index"; + +describe("premium country list", () => { + test("renders remote names and codes as text, including HTML and event-handler payloads", () => { + const container = document.createElement("ul"); + const countryName = ''; + const countryCode = ''; + updateCountryList(container, [{ countryName, countryCode, likes: 1200, dislikes: 4 }], "likes"); + expect(container.querySelector(".ryd-analytics__country").textContent).toBe(`${countryName} (${countryCode})`); + expect(container.querySelector("img, svg, script")).toBeNull(); + expect(container.querySelector(".ryd-analytics__value").textContent).toBe((1200).toLocaleString()); + }); + + test("replaces previous entries with the empty placeholder and sanitizes counts", () => { + const container = document.createElement("ul"); + updateCountryList(container, [{ countryCode: "US", dislikes: '' }], "dislikes"); + expect(container.querySelector(".ryd-analytics__value").textContent).toBe("0"); + updateCountryList(container, [], "dislikes"); + expect(container.children).toHaveLength(1); + expect(container.firstElementChild.className).toBe("ryd-analytics__placeholder"); + expect(container.textContent).toBe("premiumAnalytics_noData"); + }); +}); diff --git a/Extensions/combined/src/premiumAnalytics/map/index.js b/Extensions/combined/src/premiumAnalytics/map/index.js index 442c293..f706526 100644 --- a/Extensions/combined/src/premiumAnalytics/map/index.js +++ b/Extensions/combined/src/premiumAnalytics/map/index.js @@ -496,10 +496,15 @@ function resolveVisualMapConfig(values, mode) { }; } +function escapeTooltipText(value) { + const entities = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }; + return String(value ?? "").replace(/[&<>"']/g, (character) => entities[character]); +} + function formatMapTooltip(params) { const data = params.data; if (!data) { - return `${params.name}`; + return escapeTooltipText(params.name); } const name = data.displayName || params.name; @@ -511,7 +516,7 @@ function formatMapTooltip(params) { const dislikesText = translateMessage("premiumAnalytics_tooltipDislikes", [dislikes.toLocaleString()]); const ratioText = translateMessage("premiumAnalytics_tooltipRatio", [`${percent}%`]); - return `${name}${code}
    ${likesText}
    ${dislikesText}
    ${ratioText}`; + return [`${name}${code}`, likesText, dislikesText, ratioText].map(escapeTooltipText).join("
    "); } function resolveMapRegionName(countryCode, fallbackName) { diff --git a/Extensions/combined/src/premiumAnalytics/map/index.spec.js b/Extensions/combined/src/premiumAnalytics/map/index.spec.js index 9f22a5d..2543de1 100644 --- a/Extensions/combined/src/premiumAnalytics/map/index.spec.js +++ b/Extensions/combined/src/premiumAnalytics/map/index.spec.js @@ -234,6 +234,25 @@ describe("premiumAnalytics.map", () => { expect(option.series[0].data[0].name).toBe("United States of America"); }); + it("renders remote tooltip names and region codes as literal text", () => { + const chart = createChartStub(); + analyticsState.mapChart = chart; + const name = '&'; + const code = ''; + renderMap([{ countryCode: "US", countryName: name, likes: 75, dislikes: 25 }]); + const option = chart.setOption.mock.calls[0][0]; + const data = { ...option.series[0].data[0], code }; + const tooltip = document.createElement("div"); + tooltip.innerHTML = option.tooltip.formatter({ name: "United States", data }); + expect(tooltip.querySelector("img, svg, script")).toBeNull(); + expect(tooltip.textContent).toContain(`${name} (${code})`); + expect(tooltip.querySelectorAll("br")).toHaveLength(3); + + tooltip.innerHTML = option.tooltip.formatter({ name }); + expect(tooltip.children).toHaveLength(0); + expect(tooltip.textContent).toBe(name); + }); + it("falls back to synonyms when only country name present", () => { const chart = createChartStub(); analyticsState.mapChart = chart; diff --git a/Extensions/combined/src/premiumAnalytics/requests.js b/Extensions/combined/src/premiumAnalytics/requests.js index f4140fd..e60eec2 100644 --- a/Extensions/combined/src/premiumAnalytics/requests.js +++ b/Extensions/combined/src/premiumAnalytics/requests.js @@ -4,6 +4,7 @@ import { ensurePanel, updateRangeButtons, updateRangeAnchorButtons, setFooterMes import { debounce, safeJson } from "./utils"; import { logFetchRequest } from "./logging"; import { localize } from "../utils"; +import { hasAuthenticationDataPermission, usesFirefoxDataCollectionConsent } from "../data-collection-permissions"; import { MS_PER_DAY } from "./constants"; import { renderAnalytics } from "./render"; @@ -11,8 +12,9 @@ import { renderAnalytics } from "./render"; const HOURLY_THRESHOLD_DAYS = 7; const HOURLY_THRESHOLD_MS = HOURLY_THRESHOLD_DAYS * MS_PER_DAY; const MS_PER_HOUR = 60 * 60 * 1000; +let requestSequence = 0; -function requestAnalytics({ selection } = {}) { +function requestAnalytics(options = {}) { const state = analyticsState; if (!state.currentVideoId || !state.sessionToken || !state.sessionActive) { @@ -23,6 +25,28 @@ function requestAnalytics({ selection } = {}) { return; } + if (!usesFirefoxDataCollectionConsent()) return requestAnalyticsWithConsent(options); + + const sessionToken = state.sessionToken; + return hasAuthenticationDataPermission().then((granted) => { + if (state.sessionToken !== sessionToken) return; + if (!granted) { + state.sessionToken = null; + state.sessionActive = false; + return; + } + + return requestAnalyticsWithConsent(options); + }); +} + +function requestAnalyticsWithConsent({ selection } = {}) { + const state = analyticsState; + + if (!state.currentVideoId || !state.sessionToken || !state.sessionActive || state.membershipTier !== "premium") { + return; + } + ensurePanel(); setFooterMessage("Loading insights…"); setLoadingState(true); @@ -66,40 +90,47 @@ function requestAnalytics({ selection } = {}) { } state.pendingSelection = effectiveSelection || null; + requestKey = `${requestKey}:${++requestSequence}`; state.activeRequestKey = requestKey; state.latestBucketMs = bucket === "hour" ? MS_PER_HOUR : MS_PER_DAY; logFetchRequest(state.currentVideoId, params); const url = getApiEndpoint(`/api/patreon/analytics/video/${state.currentVideoId}?${params.toString()}`); + const sessionToken = state.sessionToken; + const isCurrentRequest = () => + analyticsState.activeRequestKey === requestKey && + analyticsState.sessionToken === sessionToken && + analyticsState.sessionActive; fetch(url, { headers: { - Authorization: `Bearer ${state.sessionToken}`, + Authorization: `Bearer ${sessionToken}`, "Content-Type": "application/json", }, credentials: "omit", }) .then(async (response) => { + if (!isCurrentRequest()) return null; if (!response.ok) { const payload = await safeJson(response); - handleError(response.status, payload?.error); + if (isCurrentRequest()) handleError(response.status, payload?.error); return null; } return response.json(); }) .then((data) => { - if (!data || analyticsState.activeRequestKey !== requestKey) return; + if (!data || !isCurrentRequest()) return; renderAnalytics(data); }) .catch((error) => { console.error("Premium analytics failed", error); - if (analyticsState.activeRequestKey === requestKey) { + if (isCurrentRequest()) { handleError(0, "network_error"); } }) .finally(() => { - if (analyticsState.activeRequestKey === requestKey) { + if (isCurrentRequest()) { setLoadingState(false); } }); diff --git a/Extensions/combined/src/premiumAnalytics/teaser/index.js b/Extensions/combined/src/premiumAnalytics/teaser/index.js index 96ca9c2..1cf2e8e 100644 --- a/Extensions/combined/src/premiumAnalytics/teaser/index.js +++ b/Extensions/combined/src/premiumAnalytics/teaser/index.js @@ -1,6 +1,7 @@ -import { getApiEndpoint, getChangelogUrl } from "../../config"; +import { getChangelogUrl } from "../../config"; import { getBrowser, getVideoId, localize } from "../../utils"; import { extConfig } from "../../state"; +import { requestVoteData } from "../../vote-data-request"; const PATREON_JOIN_URL = "https://www.patreon.com/join/returnyoutubedislike/checkout?rid=8008649"; const CHANGELOG_URL = getChangelogUrl(); @@ -150,7 +151,7 @@ function resolveVideoId() { } function fetchAndRender(videoId) { - if (teaserState.suppressed) { + if (teaserState.suppressed || teaserState.currentVideoId !== videoId || resolveVideoId() !== videoId) { return; } @@ -174,28 +175,16 @@ function fetchAndRender(videoId) { const controller = new AbortController(); teaserState.abortController = controller; - const url = getApiEndpoint(`/votes?videoId=${encodeURIComponent(videoId)}`); - - fetch(url, { - method: "GET", - headers: { Accept: "application/json" }, - signal: controller.signal, - }) - .then((response) => { - if (!response.ok) { - throw new Error(`Unexpected response: ${response.status}`); - } - return response.json(); - }) + requestVoteData(videoId) .then((payload) => { - if (teaserState.currentVideoId !== videoId || teaserState.suppressed) { + if (controller.signal.aborted || teaserState.currentVideoId !== videoId || teaserState.suppressed) { return; } updateCounts(payload); setLoading(false); }) .catch((error) => { - if (error?.name === "AbortError") { + if (controller.signal.aborted || error?.name === "AbortError") { return; } if (teaserState.suppressed) { diff --git a/Extensions/combined/src/premiumAnalytics/teaser/index.spec.js b/Extensions/combined/src/premiumAnalytics/teaser/index.spec.js index 8fbcf6d..d1e1214 100644 --- a/Extensions/combined/src/premiumAnalytics/teaser/index.spec.js +++ b/Extensions/combined/src/premiumAnalytics/teaser/index.spec.js @@ -40,6 +40,7 @@ describe("premiumAnalytics.teaser", () => { let getVideoId; let TEASER_SUPPRESSION_REASON_SETTINGS; let TEASER_SUPPRESSION_REASON_PREMIUM; + let requestVoteData; let storageGetMock; let storageSetMock; let storageOnChangedAddListener; @@ -85,6 +86,7 @@ describe("premiumAnalytics.teaser", () => { }; ({ getVideoId } = require("../../utils")); + ({ requestVoteData } = require("../../vote-data-request")); ({ initPremiumTeaser, setTeaserSuppressed, @@ -136,6 +138,83 @@ describe("premiumAnalytics.teaser", () => { expect(details?.hasAttribute("hidden")).toBe(true); }); + it("shares aggregate vote data with the main renderer but still refreshes for a real Like count", async () => { + getVideoId.mockReturnValue("abcdefghijk"); + + await initPremiumTeaser(); + await flushPromises(); + + await requestVoteData("abcdefghijk", { likeCount: false }); + expect(global.fetch).toHaveBeenCalledTimes(1); + expect(global.fetch).toHaveBeenCalledWith("https://api.test/votes?videoId=abcdefghijk", expect.any(Object)); + + global.fetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ dislikes: 124, likes: 790 }), + }); + await requestVoteData("abcdefghijk", { likeCount: 790 }); + + expect(global.fetch).toHaveBeenCalledTimes(2); + expect(global.fetch).toHaveBeenLastCalledWith( + "https://api.test/votes?videoId=abcdefghijk&likeCount=790", + expect.any(Object), + ); + }); + + it.each([true, false])( + "does not restart an outgoing request when the panel mounts late (navigation event: %s)", + async (navigationEventDelivered) => { + jest.useFakeTimers(); + const navigationListenerSpy = jest.spyOn(document, "addEventListener"); + let resolveResponse; + const destinationResponse = { + ok: true, + json: async () => ({ dislikes: 35, likes: 65 }), + }; + + try { + document.body.innerHTML = ""; + getVideoId.mockReturnValue("abcdefghijk"); + await initPremiumTeaser(); + expect(global.fetch).not.toHaveBeenCalled(); + + getVideoId.mockReturnValue("zyxwvutsrqp"); + if (navigationEventDelivered) { + const navigationListener = navigationListenerSpy.mock.calls.find( + ([name]) => name === "yt-navigate-finish", + )[1]; + navigationListener(); + } + + global.fetch.mockReturnValueOnce( + new Promise((resolve) => { + resolveResponse = resolve; + }), + ); + const destinationRequest = requestVoteData("zyxwvutsrqp", { likeCount: 65 }); + destinationRequest.catch(() => {}); + + mountSecondary(); + jest.advanceTimersByTime(500); + + expect(global.fetch).toHaveBeenCalledTimes(1); + expect(global.fetch).toHaveBeenCalledWith( + "https://api.test/votes?videoId=zyxwvutsrqp&likeCount=65", + expect.any(Object), + ); + expect(requestVoteData("zyxwvutsrqp", { likeCount: 65 })).toBe(destinationRequest); + + resolveResponse(destinationResponse); + await expect(destinationRequest).resolves.toEqual({ dislikes: 35, likes: 65 }); + } finally { + resolveResponse?.(destinationResponse); + navigationListenerSpy.mockRestore(); + jest.clearAllTimers(); + jest.useRealTimers(); + } + }, + ); + it("toggles the expanded state when the toggle is clicked", async () => { getVideoId.mockReturnValue("togglevideo"); @@ -252,5 +331,4 @@ describe("premiumAnalytics.teaser", () => { expect(storageSetMock).toHaveBeenCalledWith({ hidePremiumTeaser: true }); expect(document.querySelector(".ryd-premium-teaser")).toBeNull(); }); - }); diff --git a/Extensions/combined/src/state.js b/Extensions/combined/src/state.js index dafcd5d..e7c32f4 100644 --- a/Extensions/combined/src/state.js +++ b/Extensions/combined/src/state.js @@ -1,4 +1,19 @@ -import { getLikeButton, getDislikeButton, getButtons, getLikeTextContainer, getDislikeTextContainer } from "./buttons"; +import { + getButtonControls, + getLikeButton, + getDislikeButton, + getButtons, + getLikeTextContainer, + getDislikeTextContainer, + isSyntheticShortsDislike, + setSyntheticShortsDislikeEnabled, + setSyntheticShortsDislikePressed, +} from "./buttons"; +import { + HIDE_CLUTTER_BUTTONS_STORAGE_KEY, + normalizeHideClutterButtons, + publishHideClutterButtons, +} from "./clutter-button-setting"; import { createRateBar } from "./bar"; import { getBrowser, @@ -12,6 +27,8 @@ import { } from "./utils"; import { config, getApiEndpoint, DEV_API_URL, PROD_API_URL, isDevelopment } from "./config"; import { LIKED_STATE, DISLIKED_STATE, NEUTRAL_STATE } from "../../common/vote-transition"; +import { createBrowserSyntheticDislikeStore } from "./vote-client-adapter"; +import { requestVoteData } from "./vote-data-request"; const DEFAULT_SELECTORS = { dislikeTextContainer: [ @@ -94,6 +111,7 @@ const DEFAULT_SELECTORS = { menuContainer: ["#menu-container"], roundedDesign: ["#segmented-like-button", "like-button-view-model"], }; +const SELECTOR_REQUEST_TIMEOUT_MS = 1500; function cloneConfig(value) { if (value === undefined) return undefined; @@ -131,6 +149,7 @@ let extConfig = { tooltipPercentageMode: "dash_like", numberDisplayReformatLikes: false, hidePremiumTeaser: false, + hideClutterButtons: false, selectors: cloneConfig(DEFAULT_SELECTORS), }; @@ -158,8 +177,10 @@ function isRoundedDesign() { } let shortsObserver = null; +let syntheticDislikeStore = null; -if (isShorts() && !shortsObserver) { +function getShortsObserver() { + if (shortsObserver) return shortsObserver; console.log("Initializing shorts mutation observer"); shortsObserver = createObserver( { @@ -167,34 +188,56 @@ if (isShorts() && !shortsObserver) { }, (mutationList) => { mutationList.forEach((mutation) => { - if ( - mutation.type === "attributes" && - mutation.target.nodeName === "TP-YT-PAPER-BUTTON" && - mutation.target.id === "button" - ) { - // console.log('Short thumb button status changed'); + if (mutation.type === "attributes" && mutation.target.matches?.("button, tp-yt-paper-button#button")) { if (mutation.target.getAttribute("aria-pressed") === "true") { - mutation.target.style.color = - mutation.target.parentElement.parentElement.id === "like-button" - ? getColorFromTheme(true) - : getColorFromTheme(false); + const isLike = mutation.target.closest("like-button-view-model, #like-button") !== null; + mutation.target.style.color = getColorFromTheme(isLike); } else { mutation.target.style.color = "unset"; } - return; } - console.log("Unexpected mutation observer event: " + mutation.target + mutation.type); }); }, ); + return shortsObserver; } -function isLikesDisabled() { +function getSyntheticDislikeStore() { + if (syntheticDislikeStore) return syntheticDislikeStore; + const browserApi = getBrowser(); + if (!browserApi?.storage?.local) return null; + syntheticDislikeStore = createBrowserSyntheticDislikeStore( + browserApi.storage.local, + () => browserApi.runtime?.lastError, + ); + return syntheticDislikeStore; +} + +async function readSyntheticShortsDislikeState(videoId) { + const store = getSyntheticDislikeStore(); + if (!store) return false; + try { + return await store.isDisliked(videoId); + } catch (error) { + console.debug("Could not restore the synthetic Shorts Dislike state.", error?.message ?? error); + return false; + } +} + +async function persistSyntheticShortsDislikeState(videoId, disliked) { + const store = getSyntheticDislikeStore(); + if (!store) return; + await store.setDisliked(videoId, disliked); +} + +function isLikesDisabled(controls = getButtonControls()) { // return true if the like button's text doesn't contain any number if (isMobile()) { - return /^\D*$/.test(querySelector(extConfig.selectors.buttons.mobileText, getButtons().children[0]).innerText); + const mobileLikeText = querySelector(extConfig.selectors.buttons.mobileText, controls.likeButton); + return mobileLikeText ? /^\D*$/.test(mobileLikeText.innerText) : true; } - return /^\D*$/.test(getLikeTextContainer().innerText); + const likeTextContainer = getLikeTextContainer(controls.likeButton); + return likeTextContainer ? /^\D*$/.test(likeTextContainer.innerText) : true; } function isVideoLiked() { @@ -232,35 +275,32 @@ function getState(storedData) { //--- Sets The Likes And Dislikes Values ---// function setLikes(likesCount) { console.log(`SET likes ${likesCount}`); - getLikeTextContainer().innerText = likesCount; + const likeTextContainer = getLikeTextContainer(); + if (!likeTextContainer) return false; + likeTextContainer.innerText = likesCount; + return true; } function setDislikes(dislikesCount) { console.log(`SET dislikes ${dislikesCount}`); - const _container = getDislikeTextContainer(); + const controls = getButtonControls(); + const _container = getDislikeTextContainer(controls); + if (!_container) return false; _container?.removeAttribute("is-empty"); let _dislikeText; - if (!isLikesDisabled()) { - if (isMobile()) { - querySelector(extConfig.selectors.buttons.mobileText, getButtons().children[1]).innerText = dislikesCount; - return; - } + if (!isLikesDisabled(controls)) { _dislikeText = dislikesCount; } else { console.log("likes count disabled by creator"); - if (isMobile()) { - querySelector(extConfig.selectors.buttons.mobileText, getButtons().children[1]).innerText = - localize("TextLikesDisabled"); - return; - } _dislikeText = localize("TextLikesDisabled"); } if (_dislikeText != null && _container?.innerText !== _dislikeText) { _container.innerText = _dislikeText; } + return true; } function getLikeCountFromButton() { @@ -282,29 +322,32 @@ function getLikeCountFromButton() { function processResponse(response, storedData) { const formattedDislike = numberFormat(response.dislikes); - setDislikes(formattedDislike); + if (!setDislikes(formattedDislike)) return false; if (extConfig.numberDisplayReformatLikes === true) { const nativeLikes = getLikeCountFromButton(); if (nativeLikes !== false) { setLikes(numberFormat(nativeLikes)); } } - storedData.dislikes = parseInt(response.dislikes); - storedData.likes = getLikeCountFromButton() || parseInt(response.likes); createRateBar(storedData.likes, storedData.dislikes); if (extConfig.coloredThumbs === true) { if (isShorts()) { // for shorts, leave deactivated buttons in default color - let shortLikeButton = querySelector(extConfig.selectors.buttons.shortsToggleButton, getLikeButton()); - let shortDislikeButton = querySelector(extConfig.selectors.buttons.shortsToggleButton, getDislikeButton()); - if (shortLikeButton.getAttribute("aria-pressed") === "true") { + const shortLikeButton = + querySelector(extConfig.selectors.buttons.shortsToggleButton, getLikeButton()) ?? + getLikeButton()?.querySelector("button"); + const shortDislikeButton = + querySelector(extConfig.selectors.buttons.shortsToggleButton, getDislikeButton()) ?? + getDislikeButton()?.querySelector("button"); + if (shortLikeButton?.getAttribute("aria-pressed") === "true") { shortLikeButton.style.color = getColorFromTheme(true); } - if (shortDislikeButton.getAttribute("aria-pressed") === "true") { + if (shortDislikeButton?.getAttribute("aria-pressed") === "true") { shortDislikeButton.style.color = getColorFromTheme(false); } - shortsObserver.observe(shortLikeButton); - shortsObserver.observe(shortDislikeButton); + const observer = getShortsObserver(); + if (shortLikeButton) observer.observe(shortLikeButton); + if (shortDislikeButton) observer.observe(shortDislikeButton); } else { getLikeButton().style.color = getColorFromTheme(true); getDislikeButton().style.color = getColorFromTheme(false); @@ -313,6 +356,7 @@ function processResponse(response, storedData) { //Temporary disabling this - it breaks all places where getButtons()[1] is used // createStarRating(response.rating, isMobile()); + return true; } // Tells the user if the API is down @@ -320,32 +364,36 @@ function displayError(error, videoId = getVideoId(window.location.href)) { if (getVideoId(window.location.href) !== videoId) { return; } - getDislikeTextContainer().innerText = localize("textTempUnavailable"); + const dislikeTextContainer = getDislikeTextContainer(); + if (!dislikeTextContainer) return false; + dislikeTextContainer.innerText = localize("textTempUnavailable"); + return true; } async function setState(storedData) { if (typeof window !== "undefined") { window.__rydSetStateCalls = (window.__rydSetStateCalls || 0) + 1; } - storedData.previousState = isVideoDisliked() ? DISLIKED_STATE : isVideoLiked() ? LIKED_STATE : NEUTRAL_STATE; + const videoId = getVideoId(window.location.href); + const dislikeButton = getDislikeButton(); + if (isSyntheticShortsDislike(dislikeButton)) { + const storedDisliked = await readSyntheticShortsDislikeState(videoId); + if (getVideoId(window.location.href) !== videoId) return; + const liked = isVideoLiked(); + setSyntheticShortsDislikePressed(!liked && storedDisliked, dislikeButton); + storedData.previousState = liked ? LIKED_STATE : storedDisliked ? DISLIKED_STATE : NEUTRAL_STATE; + if (liked && storedDisliked) void persistSyntheticShortsDislikeState(videoId, false); + } else { + storedData.previousState = isVideoDisliked() ? DISLIKED_STATE : isVideoLiked() ? LIKED_STATE : NEUTRAL_STATE; + } console.log("Video is loaded. Adding buttons..."); - const videoId = getVideoId(window.location.href); const likeCount = getLikeCountFromButton() || null; let response; try { - const request = await fetch(getApiEndpoint(`/votes?videoId=${videoId}&likeCount=${likeCount || ""}`), { - method: "GET", - headers: { - Accept: "application/json", - }, - }); - if (!request.ok) { - displayError(request.error, videoId); - return; - } - response = await request.json(); + response = await requestVoteData(videoId, { likeCount }); } catch (error) { + if (error?.name === "AbortError") return false; displayError(error, videoId); return; } @@ -358,12 +406,31 @@ async function setState(storedData) { displayError(response, videoId); return; } - processResponse(response, storedData); + // Native reactions can change while counts load, before vote handling is + // enabled. Use their current state as the next activation's starting point. + const currentControls = getButtonControls(); + if (currentControls.ready) { + const liked = isVideoLiked(); + if (isSyntheticShortsDislike(currentControls.dislikeButton)) { + const disliked = storedData.previousState === DISLIKED_STATE; + setSyntheticShortsDislikePressed(!liked && disliked, currentControls.dislikeButton); + storedData.previousState = liked ? LIKED_STATE : disliked ? DISLIKED_STATE : NEUTRAL_STATE; + if (liked && disliked) void persistSyntheticShortsDislikeState(videoId, false); + } else { + storedData.previousState = isVideoDisliked() ? DISLIKED_STATE : liked ? LIKED_STATE : NEUTRAL_STATE; + } + } + // Keep a valid destination response even if YouTube replaces its controls + // while the request is in flight. The next initialization attempt can then + // render the cached state into the hydrated controls without refetching. + storedData.dislikes = parseInt(response.dislikes); + storedData.likes = parseInt(response.likes); storedData.videoId = videoId; + return processResponse(response, storedData); } async function setInitialState() { - await setState(storedData); + return setState(storedData); } function hasLoadedStateForVideo(videoId) { @@ -371,9 +438,31 @@ function hasLoadedStateForVideo(videoId) { } function restoreCurrentState() { - storedData.previousState = isVideoDisliked() ? DISLIKED_STATE : isVideoLiked() ? LIKED_STATE : NEUTRAL_STATE; - setDislikes(numberFormat(storedData.dislikes)); + const dislikeButton = getDislikeButton(); + if (isSyntheticShortsDislike(dislikeButton)) { + setSyntheticShortsDislikePressed(storedData.previousState === DISLIKED_STATE, dislikeButton); + } else { + storedData.previousState = isVideoDisliked() ? DISLIKED_STATE : isVideoLiked() ? LIKED_STATE : NEUTRAL_STATE; + } + if (!setDislikes(numberFormat(storedData.dislikes))) return false; createRateBar(storedData.likes, storedData.dislikes); + return true; +} + +function clearRenderedVoteState(controls = getButtonControls()) { + if (!controls) return; + + if (controls.dislikeTextContainer) { + controls.dislikeTextContainer.innerText = ""; + } + if (isSyntheticShortsDislike(controls.dislikeButton)) { + setSyntheticShortsDislikeEnabled(false, controls.dislikeButton); + setSyntheticShortsDislikePressed(false, controls.dislikeButton); + } + + for (const wrapper of controls.buttons?.querySelectorAll?.(".ryd-tooltip") ?? []) { + wrapper.remove(); + } } async function initExtConfig() { @@ -387,22 +476,35 @@ async function initExtConfig() { initializeTooltipPercentageMode(); initializeNumberDisplayReformatLikes(); initializeHidePremiumTeaser(); + await initializeHideClutterButtons(); await initializeSelectors(); } async function initializeSelectors() { - let result = await fetch(getApiEndpoint("/configs/selectors"), { - method: "GET", - headers: { - Accept: "application/json", - }, - }) - .then((response) => response.json()) - .catch((error) => { - console.error("Error fetching selectors:", error); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), SELECTOR_REQUEST_TIMEOUT_MS); + try { + const response = await fetch(getApiEndpoint("/configs/selectors"), { + method: "GET", + headers: { + Accept: "application/json", + }, + signal: controller.signal, }); - extConfig.selectors = mergeConfig(DEFAULT_SELECTORS, result); - console.log(result); + if (!response.ok) { + throw new Error(`Selector request failed with HTTP ${response.status}`); + } + const result = await response.json(); + extConfig.selectors = mergeConfig(DEFAULT_SELECTORS, result); + console.log(result); + return true; + } catch (error) { + extConfig.selectors = cloneConfig(DEFAULT_SELECTORS); + console.debug("Remote selectors unavailable; using bundled selectors.", error?.name ?? error); + return false; + } finally { + clearTimeout(timeout); + } } function initializeDisableVoteSubmission() { @@ -509,6 +611,26 @@ function initializeHidePremiumTeaser() { }); } +function initializeHideClutterButtons() { + const storage = getBrowser()?.storage?.sync; + if (!storage) { + extConfig.hideClutterButtons = publishHideClutterButtons(false); + return Promise.resolve(false); + } + + return new Promise((resolve) => { + storage.get([HIDE_CLUTTER_BUTTONS_STORAGE_KEY], (res = {}) => { + const storedValue = res[HIDE_CLUTTER_BUTTONS_STORAGE_KEY]; + const normalized = normalizeHideClutterButtons(storedValue); + extConfig.hideClutterButtons = publishHideClutterButtons(normalized); + if (storedValue === undefined) { + storage.set({ [HIDE_CLUTTER_BUTTONS_STORAGE_KEY]: false }); + } + resolve(normalized); + }); + }); +} + export { isMobile, isShorts, @@ -527,8 +649,12 @@ export { NEUTRAL_STATE, extConfig, initExtConfig, + initializeSelectors, storedData, isLikesDisabled, hasLoadedStateForVideo, restoreCurrentState, + clearRenderedVoteState, + persistSyntheticShortsDislikeState, + initializeHideClutterButtons, }; diff --git a/Extensions/combined/src/state.spec.js b/Extensions/combined/src/state.spec.js index d0cd226..68ec012 100644 --- a/Extensions/combined/src/state.spec.js +++ b/Extensions/combined/src/state.spec.js @@ -1,5 +1,6 @@ /** * @jest-environment jsdom + * @jest-environment-options {"url":"https://m.youtube.com/"} */ jest.mock("./buttons", () => { @@ -9,18 +10,42 @@ jest.mock("./buttons", () => { }; const likeButton = { classList: { contains: () => false }, - native: { getAttribute: (name) => (name === "aria-label" ? "35 likes" : "false") }, + native: { + getAttribute(name) { + return name === "aria-label" ? "35 likes" : String(this.pressed); + }, + innerText: "35", + pressed: false, + }, }; const dislikeButton = { classList: { contains: () => false }, - native: { getAttribute: () => "false" }, + native: { + getAttribute(name) { + return name === "aria-pressed" ? String(this.pressed) : "Dislike this video"; + }, + pressed: false, + }, }; return { + checkForSignInButton: () => false, + getButtonControls: () => ({ + buttons: { children: [] }, + dislikeButton, + likeButton, + ready: !globalThis.__mockControlsUnavailable, + }), getButtons: () => ({ children: [] }), getDislikeButton: () => dislikeButton, - getDislikeTextContainer: () => dislikeTextContainer, + getDislikeTextContainer: () => { + if (globalThis.__mockControlsUnavailable) return undefined; + return dislikeTextContainer; + }, getLikeButton: () => likeButton, getLikeTextContainer: () => ({ innerText: "35" }), + isSyntheticShortsDislike: (button) => button?.synthetic === true, + setSyntheticShortsDislikeEnabled: jest.fn(), + setSyntheticShortsDislikePressed: jest.fn(), }; }); @@ -38,7 +63,7 @@ jest.mock("./config", () => ({ jest.mock("./utils", () => ({ createObserver: () => ({ observe: jest.fn() }), - getBrowser: () => undefined, + getBrowser: () => globalThis.__mockBrowser, getColorFromTheme: () => undefined, getVideoId: (url) => new URL(url).searchParams.get("v"), initializeLogging: () => undefined, @@ -48,8 +73,19 @@ jest.mock("./utils", () => ({ })); import { createRateBar } from "./bar"; -import { getDislikeTextContainer } from "./buttons"; -import { setState } from "./state"; +import { getDislikeButton, getDislikeTextContainer, getLikeButton, setSyntheticShortsDislikePressed } from "./buttons"; +import { dislikeClicked, likeClicked } from "./events"; +import { clearVoteDataRequestCache } from "./vote-data-request"; +import { + clearRenderedVoteState, + extConfig, + initializeHideClutterButtons, + initializeSelectors, + setDislikes, + setState, + storedData, + __RewireAPI__ as stateRewire, +} from "./state"; const dislikeTextContainer = getDislikeTextContainer(); @@ -64,6 +100,14 @@ function deferred() { } beforeEach(() => { + clearVoteDataRequestCache(); + globalThis.__mockBrowser = undefined; + globalThis.__mockControlsUnavailable = false; + const likeButton = getLikeButton(); + likeButton.native.innerText = "35"; + likeButton.native.pressed = false; + getDislikeButton().native.pressed = false; + getDislikeButton().synthetic = false; dislikeTextContainer.innerText = "native dislike"; dislikeTextContainer.removeAttribute.mockClear(); createRateBar.mockClear(); @@ -71,8 +115,51 @@ beforeEach(() => { window.history.replaceState(null, "", "/watch?v=abcdefghijk"); }); +afterEach(() => { + stateRewire.__ResetDependency__("isMobile"); + stateRewire.__ResetDependency__("syntheticDislikeStore"); +}); + afterAll(() => { delete global.fetch; + delete globalThis.__mockBrowser; + delete globalThis.__mockControlsUnavailable; +}); + +test("hide-clutter initialization persists and publishes the default-off value", async () => { + const set = jest.fn(); + globalThis.__mockBrowser = { + storage: { + sync: { + get: jest.fn((_keys, callback) => callback({})), + set, + }, + }, + }; + + await expect(initializeHideClutterButtons()).resolves.toBe(false); + + expect(set).toHaveBeenCalledWith({ hideClutterButtons: false }); + expect(extConfig.hideClutterButtons).toBe(false); + expect(document.documentElement.getAttribute("data-ryd-hide-clutter-buttons")).toBe("false"); +}); + +test("hide-clutter initialization reuses and publishes a stored enabled value", async () => { + const set = jest.fn(); + globalThis.__mockBrowser = { + storage: { + sync: { + get: jest.fn((_keys, callback) => callback({ hideClutterButtons: true })), + set, + }, + }, + }; + + await expect(initializeHideClutterButtons()).resolves.toBe(true); + + expect(set).not.toHaveBeenCalled(); + expect(extConfig.hideClutterButtons).toBe(true); + expect(document.documentElement.getAttribute("data-ryd-hide-clutter-buttons")).toBe("true"); }); test("a delayed outgoing request failure cannot overwrite the destination before its successful initialization", async () => { @@ -128,3 +215,171 @@ test.each([ expect(dislikeTextContainer.innerText).toBe("native dislike"); expect(createRateBar).not.toHaveBeenCalled(); }); + +test("keeps a valid response when destination controls disappear before rendering", async () => { + fetch.mockResolvedValueOnce({ + ok: true, + json: jest.fn(async () => ({ dislikes: 65, likes: 35, rating: 3.5 })), + }); + const testState = { dislikes: 0, likes: 0, previousState: "neutral", videoId: null }; + globalThis.__mockControlsUnavailable = true; + + await expect(setState(testState)).resolves.toBe(false); + + expect(testState).toMatchObject({ dislikes: 65, likes: 35, videoId: "abcdefghijk" }); + expect(createRateBar).not.toHaveBeenCalled(); + expect(fetch).toHaveBeenCalledTimes(1); +}); + +test.each([ + ["like", false, "LIKED_STATE", 0], + ["dislike", false, "DISLIKED_STATE", 0], + ["like", true, "NEUTRAL_STATE", 1], + ["dislike", true, "NEUTRAL_STATE", -1], +])( + "resamples an early native %s activation from pressed=%s before the next vote", + async (action, initiallyPressed, expectedState, expectedVote) => { + stateRewire.__set__("isMobile", () => false); + const pending = deferred(); + const sendMessage = jest.fn(); + globalThis.__mockBrowser = { runtime: { sendMessage } }; + Object.assign(storedData, { dislikes: 0, likes: 0, previousState: "NEUTRAL_STATE", videoId: null }); + const button = action === "like" ? getLikeButton() : getDislikeButton(); + const activate = action === "like" ? likeClicked : dislikeClicked; + button.native.pressed = initiallyPressed; + fetch.mockReturnValueOnce(pending.promise); + + const initialization = setState(storedData); + expect(fetch).toHaveBeenCalledTimes(1); + activate(); + button.native.pressed = !initiallyPressed; + expect(sendMessage).not.toHaveBeenCalled(); + + pending.resolve({ ok: true, json: async () => ({ dislikes: 65, likes: 35 }) }); + await expect(initialization).resolves.toBe(true); + expect(storedData.previousState).toBe(expectedState); + + activate(); + expect(sendMessage).toHaveBeenCalledTimes(1); + expect(sendMessage).toHaveBeenCalledWith({ + message: "send_vote", + videoId: "abcdefghijk", + vote: expectedVote, + }); + }, +); + +test.each([ + [true, false, false, "DISLIKED_STATE"], + [true, false, true, "LIKED_STATE"], + [false, true, false, "NEUTRAL_STATE"], +])( + "resamples native Like on synthetic Shorts with saved Dislike=%s and Like %s -> %s", + async (savedDisliked, initialLiked, finalLiked, expectedState) => { + stateRewire.__set__("isMobile", () => false); + const store = { + isDisliked: jest.fn(async () => savedDisliked), + setDisliked: jest.fn(async () => undefined), + }; + stateRewire.__set__("syntheticDislikeStore", store); + const pending = deferred(); + const requestStarted = deferred(); + getDislikeButton().synthetic = true; + getLikeButton().native.pressed = initialLiked; + fetch.mockImplementationOnce(() => { + requestStarted.resolve(); + return pending.promise; + }); + const testState = { dislikes: 0, likes: 0, previousState: "NEUTRAL_STATE", videoId: null }; + + const initialization = setState(testState); + await requestStarted.promise; + getLikeButton().native.pressed = finalLiked; + pending.resolve({ ok: true, json: async () => ({ dislikes: 65, likes: 35 }) }); + await expect(initialization).resolves.toBe(true); + + expect(testState.previousState).toBe(expectedState); + expect(setSyntheticShortsDislikePressed).toHaveBeenLastCalledWith( + expectedState === "DISLIKED_STATE", + getDislikeButton(), + ); + if (savedDisliked && finalLiked) { + expect(store.setDisliked).toHaveBeenCalledWith("abcdefghijk", false); + } else { + expect(store.setDisliked).not.toHaveBeenCalled(); + } + }, +); + +test("navigation cancellation does not display an API error or complete initialization", async () => { + fetch.mockRejectedValueOnce(new DOMException("Navigation changed", "AbortError")); + const testState = { dislikes: 0, likes: 0, previousState: "NEUTRAL_STATE", videoId: null }; + + await expect(setState(testState)).resolves.toBe(false); + + expect(dislikeTextContainer.innerText).toBe("native dislike"); + expect(testState.videoId).toBeNull(); + expect(createRateBar).not.toHaveBeenCalled(); + expect(fetch).toHaveBeenCalledTimes(1); +}); + +test("clears a reused action surface while the destination video is loading", () => { + const buttons = document.createElement("div"); + const wrapper = document.createElement("div"); + const count = document.createElement("span"); + wrapper.className = "ryd-tooltip"; + count.innerText = "100"; + buttons.append(wrapper); + + clearRenderedVoteState({ buttons, dislikeTextContainer: count }); + + expect(count.innerText).toBe(""); + expect(buttons.querySelector(".ryd-tooltip")).toBeNull(); +}); + +test("mobile dislike rendering writes only to the semantic Dislike text container", () => { + const likeButton = getLikeButton(); + + expect(setDislikes("11")).toBe(true); + + expect(likeButton.native.innerText).toBe("35"); + expect(dislikeTextContainer.innerText).toBe("11"); +}); + +test("uses destination API likes for the rate bar when reused native Like text is stale", async () => { + fetch.mockResolvedValueOnce({ + ok: true, + json: jest.fn(async () => ({ dislikes: 65, likes: 100, rating: 3.5 })), + }); + const testState = { dislikes: 0, likes: 0, previousState: "neutral", videoId: null }; + + await expect(setState(testState)).resolves.toBe(true); + + expect(fetch).toHaveBeenCalledWith("https://example.test/votes?videoId=abcdefghijk&likeCount=35", expect.any(Object)); + expect(testState).toMatchObject({ dislikes: 65, likes: 100, videoId: "abcdefghijk" }); + expect(createRateBar).toHaveBeenCalledWith(100, 65); +}); + +test("falls back to bundled selectors when the remote selector request never settles", async () => { + jest.useFakeTimers(); + fetch.mockImplementationOnce((_url, { signal }) => { + return new Promise((_resolve, reject) => { + signal.addEventListener("abort", () => reject(new DOMException("aborted", "AbortError")), { once: true }); + }); + }); + + try { + const initialization = initializeSelectors(); + jest.advanceTimersByTime(1500); + + await expect(initialization).resolves.toBe(false); + expect(fetch).toHaveBeenCalledWith( + "https://example.test/configs/selectors", + expect.objectContaining({ method: "GET", signal: expect.any(AbortSignal) }), + ); + expect(extConfig.selectors.buttons.regular.desktopNoMenu).toEqual(["#top-level-buttons-computed"]); + expect(extConfig.selectors.rateBar.newDesignActions).toEqual(["#top-level-buttons-computed"]); + } finally { + jest.useRealTimers(); + } +}); diff --git a/Extensions/combined/src/utils.js b/Extensions/combined/src/utils.js index d7ff953..ec1a949 100644 --- a/Extensions/combined/src/utils.js +++ b/Extensions/combined/src/utils.js @@ -4,6 +4,7 @@ const DEFAULT_SHORTS_LOADED_SELECTORS = { containers: [".reel-video-in-sequence-new"], thumbnail: [".reel-video-in-sequence-thumbnail"], renderer: ["ytd-reel-video-renderer"], + mobileRenderer: ["ytm-reel-video-renderer"], overlay: ["#experiment-overlay"], }; @@ -123,11 +124,79 @@ function isInViewport(element) { ); } +function getShortsRendererVideoId(renderer) { + const rendererVideoId = renderer?.getAttribute("video-id"); + if (rendererVideoId) return rendererVideoId; + + const href = renderer?.querySelector("a[href*='/shorts/']")?.getAttribute("href"); + if (!href) return null; + try { + return getVideoId(new URL(href, window.location.href).href); + } catch { + return null; + } +} + +function isRenderedInViewport(element) { + if (!element || element.closest("[hidden], [aria-hidden='true'], [inert]")) return false; + + for (let current = element; current; current = current.parentElement) { + const style = window.getComputedStyle(current); + if ( + style.display === "none" || + style.visibility === "hidden" || + style.visibility === "collapse" || + Number.parseFloat(style.opacity || "1") === 0 + ) { + return false; + } + } + + const rect = element.getBoundingClientRect(); + const height = innerHeight || document.documentElement.clientHeight; + const width = innerWidth || document.documentElement.clientWidth; + return ( + rect.width > 0 && rect.height > 0 && rect.bottom > 0 && rect.right > 0 && rect.top < height && rect.left < width + ); +} + function isShortsLoaded(videoId) { if (!videoId) return false; const selectors = extConfig.selectors.shortsLoaded ?? DEFAULT_SHORTS_LOADED_SELECTORS; + // Mobile Shorts does not use the desktop sequence/thumbnail/experiment-overlay + // readiness tree. Its active renderer is the stable identity and visibility + // boundary; button readiness is validated separately before initialization. + for (const renderer of querySelectorAll(selectors.mobileRenderer ?? DEFAULT_SHORTS_LOADED_SELECTORS.mobileRenderer)) { + if (!renderer.hasAttribute("is-active")) continue; + if (getShortsRendererVideoId(renderer) !== videoId) continue; + if (renderer.closest("[hidden], [aria-hidden='true'], [inert]")) continue; + + const style = window.getComputedStyle(renderer); + const rect = renderer.getBoundingClientRect(); + if ( + style.display !== "none" && + style.visibility !== "hidden" && + style.visibility !== "collapse" && + Number.parseFloat(style.opacity || "1") !== 0 && + rect.width > 0 && + rect.height > 0 + ) { + return true; + } + } + + // On desktop the active reel can be partially clipped after YouTube scrolls + // its action controls into view. Current YouTube variants can also omit both + // `is-active` and `video-id`; in that topology the renderer's canonical + // /shorts/ link is the only stable identity. Full viewport containment and + // the active attribute are therefore not valid readiness requirements. + for (const renderer of querySelectorAll(selectors.renderer ?? DEFAULT_SHORTS_LOADED_SELECTORS.renderer)) { + if (getShortsRendererVideoId(renderer) !== videoId) continue; + if (isRenderedInViewport(renderer)) return true; + } + // Find all reel containers const reelContainers = querySelectorAll(selectors.containers); diff --git a/Extensions/combined/src/utils.spec.js b/Extensions/combined/src/utils.spec.js index a1c5484..a5354ae 100644 --- a/Extensions/combined/src/utils.spec.js +++ b/Extensions/combined/src/utils.spec.js @@ -182,9 +182,7 @@ describe("utils", () => { const options = formatter.resolvedOptions(); expect(options.locale.toLowerCase()).toContain("en"); - expect(consoleSpy).toHaveBeenCalledWith( - "Cannot find browser locale. Use en as default for number formatting.", - ); + expect(consoleSpy).toHaveBeenCalledWith("Cannot find browser locale. Use en as default for number formatting."); querySpy.mockRestore(); }); }); @@ -289,22 +287,14 @@ describe("utils", () => { }); it("checks secondary selectors when the grid is absent", () => { - document.querySelector = jest - .fn() - .mockReturnValueOnce(null) - .mockReturnValueOnce({}) - .mockReturnValue(null); + document.querySelector = jest.fn().mockReturnValueOnce(null).mockReturnValueOnce({}).mockReturnValue(null); expect(isVideoLoaded()).toBe(true); expect(document.querySelector).toHaveBeenNthCalledWith(2, "ytd-watch-flexy[video-id='testid']"); }); it("falls back to player detection when previous lookups fail", () => { - document.querySelector = jest - .fn() - .mockReturnValueOnce(null) - .mockReturnValueOnce(null) - .mockReturnValueOnce({}); + document.querySelector = jest.fn().mockReturnValueOnce(null).mockReturnValueOnce(null).mockReturnValueOnce({}); expect(isVideoLoaded()).toBe(true); expect(document.querySelector).toHaveBeenNthCalledWith(3, '#player[loading="false"]:not([hidden])'); @@ -334,6 +324,126 @@ describe("utils", () => { expect(isVideoLoaded()).toBe(true); }); + + it("recognizes the matching active desktop Short when it is partially clipped by the viewport", () => { + isShorts.mockReturnValue(true); + const origin = window.location.origin; + window.history.replaceState(null, "", `${origin}/shorts/desktop123`); + + const renderer = document.createElement("ytd-reel-video-renderer"); + renderer.setAttribute("is-active", ""); + renderer.setAttribute("video-id", "desktop123"); + renderer.getBoundingClientRect = jest.fn(() => ({ + bottom: 566, + height: 640, + left: 0, + right: 420, + top: -74, + width: 420, + })); + document.body.appendChild(renderer); + + expect(isVideoLoaded()).toBe(true); + }); + + it("recognizes a visible desktop Short identified only by its canonical link", () => { + isShorts.mockReturnValue(true); + const origin = window.location.origin; + window.history.replaceState(null, "", `${origin}/shorts/desktop123`); + + const renderer = document.createElement("ytd-reel-video-renderer"); + const link = document.createElement("a"); + link.setAttribute("href", "/shorts/desktop123"); + renderer.appendChild(link); + renderer.getBoundingClientRect = jest.fn(() => ({ + bottom: 640, + height: 640, + left: 0, + right: 420, + top: 0, + width: 420, + })); + document.body.appendChild(renderer); + + expect(renderer.hasAttribute("is-active")).toBe(false); + expect(renderer.hasAttribute("video-id")).toBe(false); + expect(isVideoLoaded()).toBe(true); + }); + + it("does not treat a mismatched active desktop Short as loaded", () => { + isShorts.mockReturnValue(true); + const origin = window.location.origin; + window.history.replaceState(null, "", `${origin}/shorts/desktop123`); + + const renderer = document.createElement("ytd-reel-video-renderer"); + renderer.setAttribute("is-active", ""); + renderer.setAttribute("video-id", "another123"); + renderer.getBoundingClientRect = jest.fn(() => ({ + bottom: 566, + height: 640, + left: 0, + right: 420, + top: -74, + width: 420, + })); + document.body.appendChild(renderer); + + expect(isVideoLoaded()).toBe(false); + }); + + it("recognizes the visible active mobile Shorts renderer for the current video", () => { + isShorts.mockReturnValue(true); + const origin = window.location.origin; + window.history.replaceState(null, "", `${origin}/shorts/mobile123`); + + const renderer = document.createElement("ytm-reel-video-renderer"); + renderer.setAttribute("is-active", ""); + renderer.setAttribute("video-id", "mobile123"); + renderer.getBoundingClientRect = jest.fn(() => ({ + bottom: 640, + height: 640, + left: 0, + right: 390, + top: 0, + width: 390, + })); + document.body.appendChild(renderer); + + expect(isVideoLoaded()).toBe(true); + }); + + it("does not treat an inactive or mismatched mobile Shorts renderer as loaded", () => { + isShorts.mockReturnValue(true); + const origin = window.location.origin; + window.history.replaceState(null, "", `${origin}/shorts/mobile123`); + + const inactiveMatch = document.createElement("ytm-reel-video-renderer"); + inactiveMatch.setAttribute("video-id", "mobile123"); + inactiveMatch.getBoundingClientRect = jest.fn(() => ({ + bottom: 640, + height: 640, + left: 0, + right: 390, + top: 0, + width: 390, + })); + document.body.appendChild(inactiveMatch); + + const activeMismatch = document.createElement("ytm-reel-video-renderer"); + activeMismatch.setAttribute("is-active", ""); + activeMismatch.setAttribute("video-id", "another123"); + activeMismatch.getBoundingClientRect = jest.fn(() => ({ + bottom: 640, + height: 640, + left: 0, + right: 390, + top: 0, + width: 390, + })); + document.body.appendChild(activeMismatch); + + expect(isVideoLoaded()).toBe(false); + }); }); describe("initializeLogging", () => { diff --git a/Extensions/combined/src/vote-client-adapter.js b/Extensions/combined/src/vote-client-adapter.js index b261515..2a69db6 100644 --- a/Extensions/combined/src/vote-client-adapter.js +++ b/Extensions/combined/src/vote-client-adapter.js @@ -65,4 +65,40 @@ function createBrowserCredentialStore(storageArea, getLastError) { }; } -export { createBrowserCredentialStore }; +const SYNTHETIC_DISLIKE_KEY_PREFIX = "rydSyntheticDislikedShort:"; + +function syntheticDislikeKey(videoId) { + if (typeof videoId !== "string" || videoId.length === 0) { + throw new TypeError("videoId must be a non-empty string"); + } + return `${SYNTHETIC_DISLIKE_KEY_PREFIX}${videoId}`; +} + +function createBrowserSyntheticDislikeStore(storageArea, getLastError) { + if (!storageArea?.get || !storageArea?.set) { + throw new TypeError("A browser storage area is required"); + } + + const call = createStorageCaller(storageArea, getLastError); + return { + async isDisliked(videoId) { + const key = syntheticDislikeKey(videoId); + const result = await call("get", [key]); + return result?.[key] === true; + }, + + async setDisliked(videoId, disliked) { + const key = syntheticDislikeKey(videoId); + if (typeof disliked !== "boolean") { + throw new TypeError("disliked must be a boolean"); + } + if (disliked || typeof storageArea.remove !== "function") { + await call("set", { [key]: disliked }); + } else { + await call("remove", [key]); + } + }, + }; +} + +export { SYNTHETIC_DISLIKE_KEY_PREFIX, createBrowserCredentialStore, createBrowserSyntheticDislikeStore }; diff --git a/Extensions/combined/src/vote-client-adapter.spec.js b/Extensions/combined/src/vote-client-adapter.spec.js index e0816db..b4c371a 100644 --- a/Extensions/combined/src/vote-client-adapter.spec.js +++ b/Extensions/combined/src/vote-client-adapter.spec.js @@ -1,4 +1,8 @@ -import { createBrowserCredentialStore } from "./vote-client-adapter"; +import { + SYNTHETIC_DISLIKE_KEY_PREFIX, + createBrowserCredentialStore, + createBrowserSyntheticDislikeStore, +} from "./vote-client-adapter"; describe("createBrowserCredentialStore", () => { it("implements the credential contract with callback-based Chrome storage", async () => { @@ -78,3 +82,66 @@ describe("createBrowserCredentialStore", () => { expect(() => createBrowserCredentialStore({ get() {} })).toThrow(TypeError); }); }); + +describe("createBrowserSyntheticDislikeStore", () => { + it("persists independent per-video states with callback-based storage", async () => { + const values = {}; + const storageArea = { + get: jest.fn((keys, callback) => { + callback(Object.fromEntries(keys.filter((key) => key in values).map((key) => [key, values[key]]))); + }), + set: jest.fn((nextValues, callback) => { + Object.assign(values, nextValues); + callback(); + }), + remove: jest.fn((keys, callback) => { + keys.forEach((key) => delete values[key]); + callback(); + }), + }; + const store = createBrowserSyntheticDislikeStore(storageArea, () => undefined); + + expect(await store.isDisliked("short-a")).toBe(false); + await store.setDisliked("short-a", true); + await store.setDisliked("short-b", true); + expect(await store.isDisliked("short-a")).toBe(true); + expect(await store.isDisliked("short-b")).toBe(true); + + await store.setDisliked("short-a", false); + expect(await store.isDisliked("short-a")).toBe(false); + expect(await store.isDisliked("short-b")).toBe(true); + expect(values).toEqual({ [`${SYNTHETIC_DISLIKE_KEY_PREFIX}short-b`]: true }); + }); + + it("uses the same asynchronous contract with Promise-based storage", async () => { + const values = {}; + const storageArea = { + get: jest.fn(async (keys) => Object.fromEntries(keys.map((key) => [key, values[key]]))), + set: jest.fn(async (nextValues) => Object.assign(values, nextValues)), + remove: jest.fn(async (keys) => keys.forEach((key) => delete values[key])), + }; + const store = createBrowserSyntheticDislikeStore(storageArea); + + await store.setDisliked("short-a", true); + expect(await store.isDisliked("short-a")).toBe(true); + await store.setDisliked("short-a", false); + expect(await store.isDisliked("short-a")).toBe(false); + }); + + it("validates state and falls back to an explicit false value without remove", async () => { + const storageArea = { + get: jest.fn(async () => ({})), + set: jest.fn(async () => undefined), + }; + const store = createBrowserSyntheticDislikeStore(storageArea); + + await expect(store.isDisliked(1)).rejects.toThrow(TypeError); + await expect(store.setDisliked("", true)).rejects.toThrow(TypeError); + await expect(store.setDisliked("short-a", "true")).rejects.toThrow(TypeError); + await store.setDisliked("short-a", false); + expect(storageArea.set).toHaveBeenCalledWith( + { [`${SYNTHETIC_DISLIKE_KEY_PREFIX}short-a`]: false }, + expect.any(Function), + ); + }); +}); diff --git a/Extensions/combined/src/vote-data-request.js b/Extensions/combined/src/vote-data-request.js new file mode 100644 index 0000000..5de6117 --- /dev/null +++ b/Extensions/combined/src/vote-data-request.js @@ -0,0 +1,149 @@ +import { getApiEndpoint } from "./config"; + +const WITHOUT_LIKE_COUNT = "without-like-count"; +const EXTENSION_ID_PATTERN = /^[a-p]{32}$/; +const LIVE_BUILD_ID_PATTERN = /^[a-f0-9]{32}$/; +const LIVE_ACCEPT_MEDIA_TYPE = "application/vnd.ryd-live+json"; + +const liveTestBuild = typeof __RYD_LIVE_TEST_BUILD__ !== "undefined" && __RYD_LIVE_TEST_BUILD__ === true; +const compiledLiveBuildId = typeof __RYD_LIVE_BUILD_ID__ === "undefined" ? "" : __RYD_LIVE_BUILD_ID__; + +let activeVideoId = null; +let activeRequestController = null; +let requestsByLikeCount = new Map(); +let latestRequest = null; + +function currentExtensionId() { + return globalThis.chrome?.runtime?.id ?? globalThis.browser?.runtime?.id ?? ""; +} + +function createVoteDataAcceptHeader({ + buildId = compiledLiveBuildId, + extensionId = currentExtensionId(), + isLiveTestBuild = liveTestBuild, +} = {}) { + if (!isLiveTestBuild) return "application/json"; + if (!EXTENSION_ID_PATTERN.test(extensionId)) { + throw new Error("A live-test vote-data request requires a valid extension ID."); + } + if (!LIVE_BUILD_ID_PATTERN.test(buildId)) { + throw new Error("A live-test vote-data request requires a valid build ID."); + } + const header = `application/json, ${LIVE_ACCEPT_MEDIA_TYPE}; id=${extensionId}; build=${buildId}`; + if (header.length > 128) throw new Error("The live-test vote-data fingerprint exceeds the CORS safelist limit."); + return header; +} + +function normalizeLikeCount(likeCount) { + if (likeCount === null || likeCount === undefined || likeCount === false || likeCount === "") { + return null; + } + + const parsed = typeof likeCount === "number" ? likeCount : Number(likeCount); + return Number.isSafeInteger(parsed) && parsed >= 0 ? String(parsed) : null; +} + +function resetForVideo(videoId) { + if (activeVideoId === videoId) return; + clearVoteDataRequestCache(); + activeVideoId = videoId; + activeRequestController = new AbortController(); +} + +function removeFailedRequest(record) { + if (requestsByLikeCount.get(record.key) === record) { + requestsByLikeCount.delete(record.key); + } + if (latestRequest === record) { + const remainingRequests = Array.from(requestsByLikeCount.values()); + latestRequest = remainingRequests[remainingRequests.length - 1] ?? null; + } +} + +function requestVoteData(videoId, { fetchImpl = globalThis.fetch, likeCount = null } = {}) { + if (typeof videoId !== "string" || videoId.length === 0) { + return Promise.reject(new TypeError("A video ID is required to request vote data.")); + } + if (typeof fetchImpl !== "function") { + return Promise.reject(new TypeError("A fetch implementation is required to request vote data.")); + } + + resetForVideo(videoId); + + const normalizedLikeCount = normalizeLikeCount(likeCount); + // A consumer that only needs aggregate counts can reuse a request carrying a + // native Like count. That response is at least as informative as the base + // request and avoids the premium teaser duplicating the main renderer's GET. + if (normalizedLikeCount === null && latestRequest) { + return latestRequest.promise; + } + + const key = normalizedLikeCount ?? WITHOUT_LIKE_COUNT; + const existing = requestsByLikeCount.get(key); + if (existing) { + return existing.promise; + } + + const likeCountQuery = normalizedLikeCount === null ? "" : `&likeCount=${encodeURIComponent(normalizedLikeCount)}`; + const url = getApiEndpoint(`/votes?videoId=${encodeURIComponent(videoId)}${likeCountQuery}`); + const record = { key, promise: null }; + const { signal } = activeRequestController; + + let fetchResult; + try { + fetchResult = fetchImpl(url, { + method: "GET", + headers: { + Accept: createVoteDataAcceptHeader(), + }, + signal, + }); + } catch (error) { + fetchResult = Promise.reject(error); + } + + record.promise = Promise.resolve(fetchResult) + .then((response) => { + if (!response?.ok) { + throw new Error(`Vote data request failed with HTTP ${response?.status ?? "unknown"}.`); + } + return response.json(); + }) + .then((payload) => { + if (signal.aborted) { + throw new DOMException("Vote data request cancelled by navigation.", "AbortError"); + } + return payload; + }) + .catch((error) => { + removeFailedRequest(record); + if (signal.aborted && error?.name !== "AbortError") { + throw new DOMException("Vote data request cancelled by navigation.", "AbortError"); + } + throw error; + }); + + requestsByLikeCount.set(key, record); + latestRequest = record; + return record.promise; +} + +function clearVoteDataRequestCache() { + activeRequestController?.abort(); + activeRequestController = null; + activeVideoId = null; + requestsByLikeCount = new Map(); + latestRequest = null; +} + +function cancelObsoleteVoteDataRequests(videoId) { + if (activeVideoId !== videoId) clearVoteDataRequestCache(); +} + +export { + cancelObsoleteVoteDataRequests, + clearVoteDataRequestCache, + createVoteDataAcceptHeader, + normalizeLikeCount, + requestVoteData, +}; diff --git a/Extensions/combined/src/vote-data-request.spec.js b/Extensions/combined/src/vote-data-request.spec.js new file mode 100644 index 0000000..5ecb23c --- /dev/null +++ b/Extensions/combined/src/vote-data-request.spec.js @@ -0,0 +1,250 @@ +jest.mock("./config", () => ({ + getApiEndpoint: (pathname) => `https://api.test${pathname}`, +})); + +import { + cancelObsoleteVoteDataRequests, + clearVoteDataRequestCache, + createVoteDataAcceptHeader, + normalizeLikeCount, + requestVoteData, +} from "./vote-data-request"; + +const VIDEO_A = "abcdefghijk"; +const VIDEO_B = "zyxwvutsrqp"; +const EXTENSION_ID = "abcdefghijklmnopabcdefghijklmnop"; +const BUILD_ID = "0123456789abcdef0123456789abcdef"; + +function response(payload = { dislikes: 12, likes: 34 }) { + return { + json: jest.fn(async () => payload), + ok: true, + status: 200, + }; +} + +beforeEach(() => { + clearVoteDataRequestCache(); + global.fetch = jest.fn().mockResolvedValue(response()); +}); + +afterAll(() => { + delete global.fetch; +}); + +test.each([ + [null, null], + [undefined, null], + [false, null], + ["", null], + ["not-a-number", null], + [-1, null], + [1.5, null], + [0, "0"], + [35, "35"], + ["35", "35"], +])("normalizes the Like count %p to %p", (input, expected) => { + expect(normalizeLikeCount(input)).toBe(expected); +}); + +describe("live-build request fingerprint", () => { + test("keeps the ordinary production Accept header outside a live-test build", () => { + expect( + createVoteDataAcceptHeader({ + buildId: BUILD_ID, + extensionId: EXTENSION_ID, + isLiveTestBuild: false, + }), + ).toBe("application/json"); + }); + + test("creates the exact CORS-safelisted live extension and build fingerprint", () => { + const header = createVoteDataAcceptHeader({ + buildId: BUILD_ID, + extensionId: EXTENSION_ID, + isLiveTestBuild: true, + }); + + expect(header).toBe(`application/json, application/vnd.ryd-live+json; id=${EXTENSION_ID}; build=${BUILD_ID}`); + expect(header.length).toBeLessThanOrEqual(128); + }); + + test.each([ + ["extension ID", { buildId: BUILD_ID, extensionId: "wrong", isLiveTestBuild: true }], + ["build ID", { buildId: "wrong", extensionId: EXTENSION_ID, isLiveTestBuild: true }], + ])("refuses a malformed live %s", (_label, options) => { + expect(() => createVoteDataAcceptHeader(options)).toThrow(/requires a valid/); + }); +}); + +test("coalesces concurrent and completed requests without a Like count", async () => { + const first = requestVoteData(VIDEO_A); + const concurrent = requestVoteData(VIDEO_A, { likeCount: false }); + + expect(first).toBe(concurrent); + await expect(first).resolves.toEqual({ dislikes: 12, likes: 34 }); + + const completed = requestVoteData(VIDEO_A, { likeCount: null }); + expect(completed).toBe(first); + await completed; + + expect(fetch).toHaveBeenCalledTimes(1); + expect(fetch).toHaveBeenCalledWith(`https://api.test/votes?videoId=${VIDEO_A}`, { + method: "GET", + headers: { Accept: "application/json" }, + signal: expect.any(AbortSignal), + }); +}); + +test("does not suppress the richer refresh when a real Like count becomes available", async () => { + const withoutLikeCount = requestVoteData(VIDEO_A); + await withoutLikeCount; + + fetch.mockResolvedValueOnce(response({ dislikes: 13, likes: 35 })); + const withLikeCount = requestVoteData(VIDEO_A, { likeCount: 35 }); + + expect(withLikeCount).not.toBe(withoutLikeCount); + await expect(withLikeCount).resolves.toEqual({ dislikes: 13, likes: 35 }); + expect(fetch).toHaveBeenCalledTimes(2); + expect(fetch).toHaveBeenLastCalledWith(`https://api.test/votes?videoId=${VIDEO_A}&likeCount=35`, { + method: "GET", + headers: { Accept: "application/json" }, + signal: expect.any(AbortSignal), + }); + + expect(requestVoteData(VIDEO_A, { likeCount: 35 })).toBe(withLikeCount); + expect(fetch).toHaveBeenCalledTimes(2); +}); + +test("lets a count-only consumer reuse an in-flight request carrying a real Like count", async () => { + const withLikeCount = requestVoteData(VIDEO_A, { likeCount: 35 }); + const aggregateOnly = requestVoteData(VIDEO_A); + + expect(aggregateOnly).toBe(withLikeCount); + await aggregateOnly; + expect(fetch).toHaveBeenCalledTimes(1); + expect(fetch).toHaveBeenCalledWith(`https://api.test/votes?videoId=${VIDEO_A}&likeCount=35`, expect.any(Object)); +}); + +test("starts a fresh request for a new navigation and does not reuse an older visit", async () => { + await requestVoteData(VIDEO_A); + await requestVoteData(VIDEO_B); + await requestVoteData(VIDEO_A); + + expect(fetch).toHaveBeenCalledTimes(3); + expect(fetch.mock.calls.map(([url]) => url)).toEqual([ + `https://api.test/votes?videoId=${VIDEO_A}`, + `https://api.test/votes?videoId=${VIDEO_B}`, + `https://api.test/votes?videoId=${VIDEO_A}`, + ]); +}); + +test("cancels every pending request variant when navigation clears the cache", async () => { + fetch.mockImplementation( + (_url, { signal }) => + new Promise((_resolve, reject) => { + signal.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError")), { once: true }); + }), + ); + const aggregate = requestVoteData(VIDEO_A); + const enriched = requestVoteData(VIDEO_A, { likeCount: 35 }); + const aggregateAborted = expect(aggregate).rejects.toMatchObject({ name: "AbortError" }); + const enrichedAborted = expect(enriched).rejects.toMatchObject({ name: "AbortError" }); + + clearVoteDataRequestCache(); + + await Promise.all([aggregateAborted, enrichedAborted]); + expect(fetch.mock.calls.every(([, options]) => options.signal.aborted)).toBe(true); + expect(fetch).toHaveBeenCalledTimes(2); +}); + +test("route observation preserves a pending request for the current video", async () => { + let resolveResponse; + fetch.mockReturnValueOnce(new Promise((resolve) => (resolveResponse = resolve))); + const pending = requestVoteData(VIDEO_B); + const signal = fetch.mock.calls[0][1].signal; + + cancelObsoleteVoteDataRequests(VIDEO_B); + + expect(signal.aborted).toBe(false); + expect(requestVoteData(VIDEO_B)).toBe(pending); + resolveResponse(response()); + await expect(pending).resolves.toEqual({ dislikes: 12, likes: 34 }); + expect(fetch).toHaveBeenCalledTimes(1); +}); + +test.each(["resolve", "reject"])( + "rejects a cancelled response body that later %ss without evicting the destination", + async (settlement) => { + let resolveBody; + let rejectBody; + const body = new Promise((resolve, reject) => { + resolveBody = resolve; + rejectBody = reject; + }); + let notifyBodyRequested; + const bodyRequested = new Promise((resolve) => (notifyBodyRequested = resolve)); + const outgoingResponse = { + ok: true, + status: 200, + json: jest.fn(() => { + notifyBodyRequested(); + return body; + }), + }; + fetch.mockResolvedValueOnce(outgoingResponse); + const outgoing = requestVoteData(VIDEO_A); + const aborted = expect(outgoing).rejects.toMatchObject({ name: "AbortError" }); + await bodyRequested; + expect(outgoingResponse.json).toHaveBeenCalledTimes(1); + + cancelObsoleteVoteDataRequests(VIDEO_B); + const destination = requestVoteData(VIDEO_B); + await expect(destination).resolves.toEqual({ dislikes: 12, likes: 34 }); + if (settlement === "resolve") resolveBody({ dislikes: 999, likes: 1 }); + else rejectBody(new SyntaxError("Late invalid JSON")); + + await aborted; + expect(requestVoteData(VIDEO_B)).toBe(destination); + expect(fetch).toHaveBeenCalledTimes(2); + }, +); + +test("a new video request cancels the previous scope even without an explicit cache clear", async () => { + let resolveOutgoing; + fetch.mockReturnValueOnce(new Promise((resolve) => (resolveOutgoing = resolve))); + const outgoing = requestVoteData(VIDEO_A); + const aborted = expect(outgoing).rejects.toMatchObject({ name: "AbortError" }); + const outgoingSignal = fetch.mock.calls[0][1].signal; + + await requestVoteData(VIDEO_B); + expect(outgoingSignal.aborted).toBe(true); + const revisited = requestVoteData(VIDEO_A); + resolveOutgoing(response({ dislikes: 999, likes: 1 })); + + await aborted; + await expect(revisited).resolves.toEqual({ dislikes: 12, likes: 34 }); + expect(requestVoteData(VIDEO_A)).toBe(revisited); + expect(fetch).toHaveBeenCalledTimes(3); +}); + +test.each([ + ["network failure", () => Promise.reject(new TypeError("offline"))], + ["non-2xx response", () => Promise.resolve({ ok: false, status: 503 })], + [ + "malformed JSON", + () => + Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.reject(new SyntaxError("invalid JSON")), + }), + ], +])("evicts a failed %s so an explicit later request can retry", async (_name, failedResult) => { + fetch.mockImplementationOnce(failedResult).mockResolvedValueOnce(response()); + + await expect(requestVoteData(VIDEO_A)).rejects.toThrow(); + await expect(requestVoteData(VIDEO_A)).resolves.toEqual({ dislikes: 12, likes: 34 }); + + expect(fetch).toHaveBeenCalledTimes(2); +}); diff --git a/Extensions/common/delegated-vote-activation.js b/Extensions/common/delegated-vote-activation.js new file mode 100644 index 0000000..ae1bc0e --- /dev/null +++ b/Extensions/common/delegated-vote-activation.js @@ -0,0 +1,73 @@ +function defaultGetActivationTarget(control) { + if (control?.matches?.("button, tp-yt-paper-button#button")) { + return control; + } + return control?.querySelector?.("button, tp-yt-paper-button#button") ?? control ?? null; +} + +function isHiddenWithinButtons(activationTarget, buttons) { + let current = activationTarget; + 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; + } + + for (const [action, control] of [ + ["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; +} + +export { resolveDelegatedVoteActivation }; diff --git a/Extensions/common/delegated-vote-activation.spec.js b/Extensions/common/delegated-vote-activation.spec.js new file mode 100644 index 0000000..5ccc71b --- /dev/null +++ b/Extensions/common/delegated-vote-activation.spec.js @@ -0,0 +1,99 @@ +/** @jest-environment jsdom */ + +import { resolveDelegatedVoteActivation } from "./delegated-vote-activation"; + +function createReactionTree() { + const buttons = document.createElement("div"); + const likeButton = document.createElement("like-button-view-model"); + const dislikeButton = document.createElement("dislike-button-view-model"); + likeButton.innerHTML = ''; + dislikeButton.innerHTML = ''; + buttons.append(likeButton, dislikeButton); + document.body.append(buttons); + return { buttons, dislikeButton, likeButton }; +} + +function clickEventFor(target) { + return { + composedPath: () => [target, target.parentElement], + target, + }; +} + +describe("delegated vote activation", () => { + test.each([ + ["like", "likeButton"], + ["dislike", "dislikeButton"], + ])("resolves the current %s activation target", (action, controlName) => { + const tree = createReactionTree(); + const activationTarget = tree[controlName].querySelector("button"); + + expect(resolveDelegatedVoteActivation({ ...tree, event: clickEventFor(activationTarget) })).toEqual({ + action, + activationTarget, + }); + }); + + test("accepts a synchronously cloned current control without prior binding", () => { + const tree = createReactionTree(); + const replacement = tree.dislikeButton.cloneNode(true); + tree.dislikeButton.replaceWith(replacement); + tree.dislikeButton = replacement; + const activationTarget = replacement.querySelector("button"); + + expect(resolveDelegatedVoteActivation({ ...tree, event: clickEventFor(activationTarget) })).toEqual({ + action: "dislike", + activationTarget, + }); + }); + + test.each([ + ["like", "likeButton", "dislikeButton"], + ["dislike", "dislikeButton", "likeButton"], + ])("accepts the current %s while its counterpart is temporarily absent", (action, controlName, missingName) => { + const tree = createReactionTree(); + tree[missingName].remove(); + tree[missingName] = null; + const activationTarget = tree[controlName].querySelector("button"); + + expect(resolveDelegatedVoteActivation({ ...tree, event: clickEventFor(activationTarget) })).toEqual({ + action, + activationTarget, + }); + }); + + test("ignores an activation from a detached stale control", () => { + const tree = createReactionTree(); + const staleDislike = tree.dislikeButton; + const replacement = staleDislike.cloneNode(true); + staleDislike.replaceWith(replacement); + tree.dislikeButton = replacement; + + expect( + resolveDelegatedVoteActivation({ + ...tree, + event: clickEventFor(staleDislike.querySelector("button")), + }), + ).toBeNull(); + }); + + test.each([ + ["hidden attribute", (control) => (control.hidden = true)], + ["aria-hidden", (control) => control.setAttribute("aria-hidden", "true")], + ["display none", (control) => (control.style.display = "none")], + ])("ignores a current control hidden by %s", (_label, hide) => { + const tree = createReactionTree(); + hide(tree.dislikeButton); + const activationTarget = tree.dislikeButton.querySelector("button"); + + expect(resolveDelegatedVoteActivation({ ...tree, event: clickEventFor(activationTarget) })).toBeNull(); + }); + + test("ignores a current control whose nested activation target is hidden", () => { + const tree = createReactionTree(); + const activationTarget = tree.dislikeButton.querySelector("button"); + activationTarget.hidden = true; + + expect(resolveDelegatedVoteActivation({ ...tree, event: clickEventFor(activationTarget) })).toBeNull(); + }); +}); diff --git a/Extensions/common/shorts-control-readiness.js b/Extensions/common/shorts-control-readiness.js new file mode 100644 index 0000000..c47e11c --- /dev/null +++ b/Extensions/common/shorts-control-readiness.js @@ -0,0 +1,163 @@ +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 { + activationTargets, + likeButton, + nativeChildren, + nativeLikeButton, + nativeLikeGraphic: nativeLikeIcon.graphic, + nativeLikeSvg: nativeLikeIcon.svg, + }; +} + +function shortsNativeControlInventoryMatches(left, right) { + 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))); + }); +} + +export { + SHORTS_HYDRATED_SURFACE_STABILITY_MS, + SHORTS_UNHYDRATED_SURFACE_STABILITY_MS, + actionBarHasHydratedData, + captureShortsNativeControlInventory, + getShortsControlSurfaceStabilityMs, + getShortsIdentityLinkVideoIds, + isShortsControlSurfaceReadyForMutation, + shortsNativeControlInventoryIsReadyForFallback, + shortsNativeControlInventoryMatches, +}; diff --git a/Extensions/common/shorts-control-readiness.spec.js b/Extensions/common/shorts-control-readiness.spec.js new file mode 100644 index 0000000..19305cd --- /dev/null +++ b/Extensions/common/shorts-control-readiness.spec.js @@ -0,0 +1,290 @@ +/** @jest-environment jsdom */ + +import { + SHORTS_HYDRATED_SURFACE_STABILITY_MS, + SHORTS_UNHYDRATED_SURFACE_STABILITY_MS, + actionBarHasHydratedData, + captureShortsNativeControlInventory, + getShortsControlSurfaceStabilityMs, + getShortsIdentityLinkVideoIds, + isShortsControlSurfaceReadyForMutation, + shortsNativeControlInventoryIsReadyForFallback, + shortsNativeControlInventoryMatches, +} from "./shorts-control-readiness"; + +const readySurface = { + candidateVideoIds: ["BBBBBBBBBBB"], + currentVideoId: "BBBBBBBBBBB", + isConnected: true, + isHydrated: true, + isRendered: true, + isStable: true, + isViewportIntersecting: true, + visibleCandidateCount: 1, +}; + +function appendNativeLikeSvg(actionBar, { height = 24, path = "M3 12h18", width = 24 } = {}) { + const button = actionBar.querySelector("like-button-view-model button"); + const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + svg.setAttribute("viewBox", "0 0 24 24"); + svg.getBoundingClientRect = () => ({ bottom: height, height, left: 0, right: width, top: 0, width }); + if (path !== null) { + const graphic = document.createElementNS("http://www.w3.org/2000/svg", "path"); + graphic.setAttribute("d", path); + svg.appendChild(graphic); + } + button.appendChild(svg); + return svg; +} + +describe("Shorts control mutation readiness", () => { + describe("renderer link identity", () => { + test("prefers the hidden player-title permalink over a visible description cross-link", () => { + const renderer = document.createElement("ytd-reel-video-renderer"); + renderer.innerHTML = ` +
    + +
    + + This knot is very useful + `; + + expect(Array.from(getShortsIdentityLinkVideoIds(renderer, "https://www.youtube.com/shorts/BBBBBBBBBBB"))).toEqual( + ["BBBBBBBBBBB"], + ); + }); + + test("keeps conflicting canonical player and renderer identities ambiguous", () => { + const renderer = document.createElement("ytd-reel-video-renderer"); + renderer.setAttribute("video-id", "BBBBBBBBBBB"); + renderer.innerHTML = ''; + const identities = new Set([renderer.getAttribute("video-id"), ...getShortsIdentityLinkVideoIds(renderer)]); + + expect(Array.from(identities).sort()).toEqual(["AAAAAAAAAAA", "BBBBBBBBBBB"]); + }); + + test("keeps multiple canonical player identities ambiguous", () => { + const renderer = document.createElement("ytd-reel-video-renderer"); + renderer.innerHTML = ` + + + `; + + expect(Array.from(getShortsIdentityLinkVideoIds(renderer)).sort()).toEqual(["AAAAAAAAAAA", "BBBBBBBBBBB"]); + }); + + test("retains the generic exact-link fallback when no player-title permalink exists", () => { + const renderer = document.createElement("ytm-reel-video-renderer"); + renderer.innerHTML = ''; + + expect(Array.from(getShortsIdentityLinkVideoIds(renderer))).toEqual(["BBBBBBBBBBB"]); + }); + + test("does not fall through to description links when canonical player metadata is malformed", () => { + const renderer = document.createElement("ytd-reel-video-renderer"); + renderer.innerHTML = ` + + `; + + expect(Array.from(getShortsIdentityLinkVideoIds(renderer))).toEqual([]); + }); + }); + + test("uses the shared hydrated and persistent-unhydrated stability windows", () => { + expect(getShortsControlSurfaceStabilityMs(true)).toBe(SHORTS_HYDRATED_SURFACE_STABILITY_MS); + expect(getShortsControlSurfaceStabilityMs(false)).toBe(SHORTS_UNHYDRATED_SURFACE_STABILITY_MS); + expect(SHORTS_HYDRATED_SURFACE_STABILITY_MS).toBe(250); + expect(SHORTS_UNHYDRATED_SURFACE_STABILITY_MS).toBe(1500); + }); + + test("accepts the current rendered action surface only after it intersects the viewport", () => { + expect(isShortsControlSurfaceReadyForMutation(readySurface)).toBe(true); + expect( + isShortsControlSurfaceReadyForMutation({ + ...readySurface, + isViewportIntersecting: false, + }), + ).toBe(false); + }); + + test.each([ + ["detached", { isConnected: false }], + ["not hydrated", { isHydrated: false }], + ["not rendered", { isRendered: false }], + ["not stable", { isStable: false }], + ["owned by another video", { candidateVideoIds: ["AAAAAAAAAAA"] }], + ["missing route identity", { currentVideoId: null }], + ])("rejects a %s action surface", (_label, override) => { + expect(isShortsControlSurfaceReadyForMutation({ ...readySurface, ...override })).toBe(false); + }); + + test("accepts one visible current surface while YouTube is still hydrating its own identity", () => { + expect( + isShortsControlSurfaceReadyForMutation({ + ...readySurface, + candidateVideoIds: [], + }), + ).toBe(true); + }); + + test("accepts a data-null surface only through the explicit fallback without bypassing other guards", () => { + const unhydratedSurface = { ...readySurface, isHydrated: false }; + + expect(isShortsControlSurfaceReadyForMutation(unhydratedSurface)).toBe(false); + expect( + isShortsControlSurfaceReadyForMutation({ + ...unhydratedSurface, + allowUnhydratedFallback: true, + }), + ).toBe(true); + expect( + isShortsControlSurfaceReadyForMutation({ + ...unhydratedSurface, + allowUnhydratedFallback: true, + isStable: false, + }), + ).toBe(false); + }); + + test("rejects ambiguous and non-unique identity-less surfaces", () => { + expect( + isShortsControlSurfaceReadyForMutation({ + ...readySurface, + candidateVideoIds: ["BBBBBBBBBBB", "AAAAAAAAAAA"], + }), + ).toBe(false); + expect( + isShortsControlSurfaceReadyForMutation({ + ...readySurface, + candidateVideoIds: [], + visibleCandidateCount: 2, + }), + ).toBe(false); + }); + + test("requires truthy action-bar data only when YouTube exposes the property", () => { + const actionBar = document.createElement("reel-action-bar-view-model"); + expect(actionBarHasHydratedData(actionBar)).toBe(true); + + actionBar.data = null; + expect(actionBarHasHydratedData(actionBar)).toBe(false); + + actionBar.data = { likeButtonViewModel: {} }; + expect(actionBarHasHydratedData(actionBar)).toBe(true); + }); + + test("requires a complete interactive inventory and detects native reconciliation", () => { + const actionBar = document.createElement("reel-action-bar-view-model"); + actionBar.innerHTML = ` + + `; + document.body.appendChild(actionBar); + expect(captureShortsNativeControlInventory(actionBar)).toBeNull(); + + actionBar.insertAdjacentHTML( + "beforeend", + "" + + '', + ); + appendNativeLikeSvg(actionBar); + const before = captureShortsNativeControlInventory(actionBar); + expect(before).not.toBeNull(); + expect(before.activationTargets.at(-1)).toBe(actionBar.querySelector("pivot-button-view-model > a")); + expect(shortsNativeControlInventoryMatches(before, captureShortsNativeControlInventory(actionBar))).toBe(true); + + actionBar.children[1].replaceWith(actionBar.children[1].cloneNode(true)); + expect(shortsNativeControlInventoryMatches(before, captureShortsNativeControlInventory(actionBar))).toBe(false); + }); + + test("requires every native activation target to be rendered, meaningfully visible, and topmost for fallback", () => { + const actionBar = document.createElement("reel-action-bar-view-model"); + actionBar.innerHTML = ` + + + + `; + document.body.appendChild(actionBar); + appendNativeLikeSvg(actionBar); + const inventory = captureShortsNativeControlInventory(actionBar); + const targets = inventory.activationTargets; + targets.forEach((target, index) => { + target.getBoundingClientRect = () => ({ height: 40, left: index * 50, top: 10, width: 40 }); + }); + const getTopmostElement = jest.fn((x) => targets[Math.floor(x / 50)]); + const readyOptions = { + getTopmostElement, + isMeaningfullyInViewport: () => true, + isRendered: () => true, + }; + + expect(shortsNativeControlInventoryIsReadyForFallback(inventory, readyOptions)).toBe(true); + expect(getTopmostElement).toHaveBeenCalledTimes(targets.length); + expect( + shortsNativeControlInventoryIsReadyForFallback(inventory, { + ...readyOptions, + isRendered: (target) => target !== targets[1], + }), + ).toBe(false); + expect( + shortsNativeControlInventoryIsReadyForFallback(inventory, { + ...readyOptions, + isMeaningfullyInViewport: (target) => target !== targets[2], + }), + ).toBe(false); + expect( + shortsNativeControlInventoryIsReadyForFallback(inventory, { + ...readyOptions, + getTopmostElement: () => document.body, + }), + ).toBe(false); + }); + + test("accepts only the real no-href Pivot anchor button topology", () => { + const actionBar = document.createElement("reel-action-bar-view-model"); + actionBar.innerHTML = ` + + + + `; + document.body.appendChild(actionBar); + appendNativeLikeSvg(actionBar); + + expect(captureShortsNativeControlInventory(actionBar)).toBeNull(); + actionBar.querySelector("pivot-button-view-model > a").setAttribute("tabindex", "0"); + expect(captureShortsNativeControlInventory(actionBar)).not.toBeNull(); + }); + + test("waits for the exact native Like icon to progress from shell-only through empty SVG to painted", () => { + const actionBar = document.createElement("reel-action-bar-view-model"); + actionBar.innerHTML = ` + + + + `; + document.body.appendChild(actionBar); + + expect(captureShortsNativeControlInventory(actionBar)).toBeNull(); + + const svg = appendNativeLikeSvg(actionBar, { path: null }); + expect(captureShortsNativeControlInventory(actionBar)).toBeNull(); + + const graphic = document.createElementNS("http://www.w3.org/2000/svg", "path"); + graphic.setAttribute("d", ""); + svg.appendChild(graphic); + expect(captureShortsNativeControlInventory(actionBar)).toBeNull(); + + graphic.setAttribute("d", "M3 12h18"); + svg.getBoundingClientRect = () => ({ bottom: 0, height: 0, left: 0, right: 24, top: 0, width: 24 }); + expect(captureShortsNativeControlInventory(actionBar)).toBeNull(); + + svg.getBoundingClientRect = () => ({ bottom: 24, height: 24, left: 0, right: 24, top: 0, width: 24 }); + const painted = captureShortsNativeControlInventory(actionBar); + expect(painted).not.toBeNull(); + expect(painted.nativeLikeButton).toBe(actionBar.querySelector("like-button-view-model button")); + expect(painted.nativeLikeSvg).toBe(svg); + expect(painted.nativeLikeGraphic).toBe(graphic); + + graphic.replaceWith(graphic.cloneNode(true)); + expect(shortsNativeControlInventoryMatches(painted, captureShortsNativeControlInventory(actionBar))).toBe(false); + }); +}); diff --git a/Extensions/common/vote-transition-core.js b/Extensions/common/vote-transition-core.js new file mode 100644 index 0000000..2c4b0cb --- /dev/null +++ b/Extensions/common/vote-transition-core.js @@ -0,0 +1,57 @@ +const LIKED_STATE = "LIKED_STATE"; +const DISLIKED_STATE = "DISLIKED_STATE"; +const NEUTRAL_STATE = "NEUTRAL_STATE"; + +const LIKE_ACTION = "like"; +const DISLIKE_ACTION = "dislike"; + +const TRANSITIONS = { + [NEUTRAL_STATE]: { + [LIKE_ACTION]: { nextState: LIKED_STATE, value: 1, likesDelta: 1, dislikesDelta: 0 }, + [DISLIKE_ACTION]: { nextState: DISLIKED_STATE, value: -1, likesDelta: 0, dislikesDelta: 1 }, + }, + [LIKED_STATE]: { + [LIKE_ACTION]: { nextState: NEUTRAL_STATE, value: 0, likesDelta: -1, dislikesDelta: 0 }, + [DISLIKE_ACTION]: { nextState: DISLIKED_STATE, value: -1, likesDelta: -1, dislikesDelta: 1 }, + }, + [DISLIKED_STATE]: { + [LIKE_ACTION]: { nextState: LIKED_STATE, value: 1, likesDelta: 1, dislikesDelta: -1 }, + [DISLIKE_ACTION]: { nextState: NEUTRAL_STATE, value: 0, likesDelta: 0, dislikesDelta: -1 }, + }, +}; + +function resolveVoteTransition(previousState, action) { + const transition = TRANSITIONS[previousState]?.[action]; + if (!transition) { + throw new TypeError(`Unsupported vote transition: ${previousState} -> ${action}`); + } + return { ...transition }; +} + +function applyVoteTransitionCounts(likes, dislikes, transition) { + if (!transition || !Number.isFinite(transition.likesDelta) || !Number.isFinite(transition.dislikesDelta)) { + throw new TypeError("A valid vote transition is required"); + } + + const normalizedLikes = Number.isFinite(likes) ? likes : 0; + const normalizedDislikes = Number.isFinite(dislikes) ? dislikes : 0; + return { + likes: Math.max(0, normalizedLikes + transition.likesDelta), + dislikes: Math.max(0, normalizedDislikes + transition.dislikesDelta), + }; +} + +function shouldSubmitVote({ disableVoteSubmission = false, signedOut = false } = {}) { + return disableVoteSubmission !== true && signedOut !== true; +} + +module.exports = { + DISLIKED_STATE, + DISLIKE_ACTION, + LIKED_STATE, + LIKE_ACTION, + NEUTRAL_STATE, + applyVoteTransitionCounts, + resolveVoteTransition, + shouldSubmitVote, +}; diff --git a/Extensions/common/vote-transition.js b/Extensions/common/vote-transition.js index e32e2a4..4635274 100644 --- a/Extensions/common/vote-transition.js +++ b/Extensions/common/vote-transition.js @@ -1,49 +1,13 @@ -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; -} +const { + DISLIKED_STATE, + DISLIKE_ACTION, + LIKED_STATE, + LIKE_ACTION, + NEUTRAL_STATE, + applyVoteTransitionCounts, + resolveVoteTransition, + shouldSubmitVote, +} = require("./vote-transition-core"); export { LIKED_STATE, diff --git a/Extensions/e2e/README.md b/Extensions/e2e/README.md index 5f0592d..c9e1641 100644 --- a/Extensions/e2e/README.md +++ b/Extensions/e2e/README.md @@ -23,13 +23,19 @@ extension-specific stale-response race executable against the generated MV3 arti - `extension-watch-spa-delayed-outgoing-failure` holds A's `/votes` response, navigates to B, releases A as malformed, and requires B to initialize exactly once without ever showing A's error or data. This proves that navigation queued during an in-flight initialization is not swallowed and that stale failures are as harmless as stale successes. +- `extension-startup-resilience.e2e.js` leaves `/configs/selectors` pending forever and requires the manifest-injected + content script to initialize Watch from its bundled selectors within the startup budget. This prevents an optional + remote selector refresh from blocking every listener, count, and ratio bar in the extension. ```powershell node Extensions/e2e/hermetic-artifact-smoke.js ``` The userscript is injected into an isolated context with GM shims. The extension is loaded as a real unpacked MV3 -artifact in an isolated persistent Chromium profile, and the smoke requires its service worker to start. +artifact in an isolated persistent Chromium profile, and the smoke requires its service worker to start. The mobile +extension coverage launches that profile with an Android user agent, mobile metrics, and touch enabled; its Shorts +vote uses a real touchscreen tap and holds the exact one-vote/one-confirmation invariant after both touch and synthetic +click events have fired. ## Extension API safety @@ -44,8 +50,25 @@ left byte-for-byte intact: Chromium blocks HTTPS-page content scripts from reach Access, so a BrowserContext route fulfills its normal API-origin requests instead. That route is installed before any YouTube page is created or navigated. The temporary background bundle also replaces the exact first-install changelog listener with a no-op so a fresh profile cannot open the changelog and request its remote font before the catch-all is -ready. A missing exact listener fails preparation. The tracked build output is never modified. Both the derived -artifact and its browser profile are removed after the run. +ready. A launch-time host resolver rule also denies every non-loopback hostname, closing the worker-startup window +before Playwright routing exists. A missing exact listener fails preparation. The tracked build output is never +modified. Both the derived artifact and its browser profile are removed after the run. + +The derived background bundle begins with a test-only runtime probe before any production bundle code. It records +worker exceptions, unhandled rejections, failed console assertions, errors, and warnings both in the worker and at the +loopback server. Every extension scenario checks this shared page-and-worker signal collector, including startup +signals that a listener attached after service-worker discovery would miss. A negative-control browser test rejects a +Promise inside the real MV3 worker and requires the guard to detect it. Both fake API transports validate CORS +preflights against the declared endpoint/method contract; an OPTIONS request to any other path is recorded and failed. + +The extension build emits a root-only receipt with its mode and a deterministic hash of production inputs. The artifact +verifier requires a fresh production receipt, rejects inline development source maps, and SHA-256 compares each browser +bundle mirror with Webpack's root output. The direct extension Playwright command runs this verifier first, preventing +the suite from silently exercising a stale or watch-generated `dist/chrome` artifact. + +Setting `RYD_EXTENSION_ARTIFACT` to another directory is rejected by the Playwright global setup unless +`RYD_E2E_ALLOW_CUSTOM_EXTENSION_ARTIFACT=1` is also set. Use that explicit bypass only for an intentional custom or +negative-control fixture; the selected directory still has to pass standalone production MV3 validation. A future dedicated Webpack test build can replace this derivation with the following equivalent hook: @@ -60,12 +83,37 @@ The artifact smoke is part of `npm run test:e2e:systematic` and `npm run test:al shared catalog, runtime binding, capability validation, loopback guard, derived-artifact transformation, signal collection, and cleanup behavior. The browser run rejects stale/current ownership mistakes, missed initialization, duplicate activation, unexpected traffic, console errors, page errors, unhandled rejections, and a missing auxiliary -extension script. +extension script. Both Playwright runtime suites also include a negative render oracle that substitutes a no-op runtime +artifact and requires the owned Dislike count and ratio bar assertions to fail, preventing the fixtures themselves from +creating a false green when either generated runtime stops executing. ## Authenticated live-build identity -The authenticated Brave suite is intentionally separate from this hermetic runner. `build:live:userscript` and +The authenticated Chromium suite is intentionally separate from this hermetic runner. `build:live:userscript` and `build:live:extension` generate a fresh 32-character build ID, compile it into the page marker, and write the same ID to the runtime's `live-build.json`. The live suite reads that file and requires an exact marker match before every shared scenario. Rebuilding without reinstalling or reloading the runtime therefore fails even when the semantic version did -not change. See `Extensions/UserScript/e2e/live/README.md` for the complete preparation and safety contract. +not change. Its approved reaction phase additionally proves that a real playlist SPA transition from A to B produces +no interaction request by itself, then requires the reaction and cleanup handshakes to target B exactly, return 2xx, +confirm with literal `true`, and restore the initial reaction. See `Extensions/UserScript/e2e/live/README.md` for the +complete preparation and safety contract. + +## Firefox changelog lifecycle + +After a production build, run the native temporary-addon regression with the pinned Node runtime: + +```powershell +$env:RYD_FIREFOX_CHANGELOG_LIFECYCLE = "1" +$env:RYD_FIREFOX_CHANGELOG_EXPECT_IMMEDIATE = "1" +node Extensions/e2e/firefox-consent-smoke.js +``` + +This uses an owned Firefox profile and a loopback API. A probe added only to the derived test extension records actual +`runtime.onInstalled` events and background restarts. The regression checks fresh installation, removal/reinstallation, +temporary reload with an unseen or pending changelog, and suppression after the changelog has been shown. It preserves +the production artifact and writes event, storage, and tab evidence to `test-results/firefox-consent-*/result.json`. + +Unset both environment variables before running the packaged-install matrix. Omit only +`RYD_FIREFOX_CHANGELOG_EXPECT_IMMEDIATE` when recording the behavior of an older artifact: that mode records the +lifecycle outcome and does not require the temporary-reload fix. `ryd.background-changelog.spec.js` also covers normal +update deferral until browser startup and retry after tab creation fails. diff --git a/Extensions/e2e/action-overflow-contract.js b/Extensions/e2e/action-overflow-contract.js new file mode 100644 index 0000000..2cf2743 --- /dev/null +++ b/Extensions/e2e/action-overflow-contract.js @@ -0,0 +1,111 @@ +const REACTION_GROUP_ID = "reactions"; +const MORE_BUTTON_ID = "more"; +const ACTION_GAP_PX = 8; +const REACTION_GROUP_WIDTH_PX = 232; +const MORE_BUTTON_WIDTH_PX = 48; + +const OPTIONAL_ACTIONS = Object.freeze([ + Object.freeze({ id: "share", label: "Share", width: 104 }), + Object.freeze({ id: "save", label: "Save", width: 88 }), + Object.freeze({ id: "thanks", label: "Thanks", width: 104 }), + Object.freeze({ id: "download", label: "Download", width: 112 }), +]); + +const ACTION_OVERFLOW_SCENARIOS = Object.freeze([ + Object.freeze({ + availableWidth: 760, + expectedOverflow: Object.freeze([]), + expectedTopLevel: Object.freeze(["share", "save", "thanks", "download"]), + hideClutterButtons: false, + id: "roomy-keeps-every-optional-action", + }), + Object.freeze({ + availableWidth: 680, + expectedOverflow: Object.freeze(["download"]), + expectedTopLevel: Object.freeze(["share", "save", "thanks"]), + hideClutterButtons: false, + id: "one-button-overflow-hides-one", + }), + Object.freeze({ + availableWidth: 560, + expectedOverflow: Object.freeze(["thanks", "download"]), + expectedTopLevel: Object.freeze(["share", "save"]), + hideClutterButtons: false, + id: "two-button-overflow-hides-two", + }), + Object.freeze({ + availableWidth: 450, + expectedOverflow: Object.freeze(["save", "thanks", "download"]), + expectedTopLevel: Object.freeze(["share"]), + hideClutterButtons: false, + id: "three-button-overflow-hides-three", + }), + Object.freeze({ + availableWidth: 375, + expectedOverflow: Object.freeze(["share", "save", "thanks", "download"]), + expectedTopLevel: Object.freeze([]), + hideClutterButtons: false, + id: "narrow-action-allocation-overflows-every-optional-action", + }), + Object.freeze({ + availableWidth: 760, + expectedOverflow: Object.freeze(["share", "save", "thanks", "download"]), + expectedTopLevel: Object.freeze([]), + hideClutterButtons: true, + id: "hide-clutter-keeps-only-reactions", + }), +]); + +function requiredActionRowWidth(topLevelActionIds) { + const widths = topLevelActionIds.map((id) => { + const action = OPTIONAL_ACTIONS.find((candidate) => candidate.id === id); + if (!action) throw new TypeError(`Unknown optional action: ${id}`); + return action.width; + }); + const itemCount = 2 + topLevelActionIds.length; + return ( + REACTION_GROUP_WIDTH_PX + + MORE_BUTTON_WIDTH_PX + + widths.reduce((total, width) => total + width, 0) + + ACTION_GAP_PX * (itemCount - 1) + ); +} + +function assertActionOverflowScenario(snapshot, scenario, assert) { + assert.deepEqual(snapshot.topLevelActionIds, scenario.expectedTopLevel, `${scenario.id}: wrong top-level actions`); + assert.deepEqual(snapshot.overflowActionIds, scenario.expectedOverflow, `${scenario.id}: wrong overflow actions`); + assert.equal(snapshot.reactionGroupCount, 1, `${scenario.id}: the reaction group must remain top-level exactly once`); + assert.equal(snapshot.moreButtonCount, 1, `${scenario.id}: the More button must exist exactly once`); + assert.equal(snapshot.duplicateActionIds.length, 0, `${scenario.id}: an action was duplicated across the two menus`); + assert.deepEqual( + [...snapshot.topLevelActionIds, ...snapshot.overflowActionIds].sort(), + OPTIONAL_ACTIONS.map(({ id }) => id).sort(), + `${scenario.id}: an optional action disappeared`, + ); + + if (!scenario.hideClutterButtons) { + assert.ok( + requiredActionRowWidth(snapshot.topLevelActionIds) <= scenario.availableWidth, + `${scenario.id}: the selected top-level actions do not fit`, + ); + if (snapshot.overflowActionIds.length > 0) { + const nextHiddenAction = snapshot.overflowActionIds[0]; + assert.ok( + requiredActionRowWidth([...snapshot.topLevelActionIds, nextHiddenAction]) > scenario.availableWidth, + `${scenario.id}: more actions were hidden than necessary`, + ); + } + } +} + +module.exports = { + ACTION_GAP_PX, + ACTION_OVERFLOW_SCENARIOS, + MORE_BUTTON_ID, + MORE_BUTTON_WIDTH_PX, + OPTIONAL_ACTIONS, + REACTION_GROUP_ID, + REACTION_GROUP_WIDTH_PX, + assertActionOverflowScenario, + requiredActionRowWidth, +}; diff --git a/Extensions/e2e/action-overflow-contract.spec.js b/Extensions/e2e/action-overflow-contract.spec.js new file mode 100644 index 0000000..2b13b21 --- /dev/null +++ b/Extensions/e2e/action-overflow-contract.spec.js @@ -0,0 +1,50 @@ +const assert = require("node:assert/strict"); +const { + ACTION_OVERFLOW_SCENARIOS, + OPTIONAL_ACTIONS, + assertActionOverflowScenario, + requiredActionRowWidth, +} = require("./action-overflow-contract"); + +describe("action overflow E2E contract", () => { + test("locks explicit roomy, constrained, and opted-in clutter scenarios", () => { + expect(ACTION_OVERFLOW_SCENARIOS.map(({ id }) => id)).toEqual([ + "roomy-keeps-every-optional-action", + "one-button-overflow-hides-one", + "two-button-overflow-hides-two", + "three-button-overflow-hides-three", + "narrow-action-allocation-overflows-every-optional-action", + "hide-clutter-keeps-only-reactions", + ]); + }); + + test.each(ACTION_OVERFLOW_SCENARIOS.filter(({ hideClutterButtons }) => !hideClutterButtons))( + "$id is the maximal prefix that fits", + (scenario) => { + expect(requiredActionRowWidth(scenario.expectedTopLevel)).toBeLessThanOrEqual(scenario.availableWidth); + if (scenario.expectedOverflow.length > 0) { + expect(requiredActionRowWidth([...scenario.expectedTopLevel, scenario.expectedOverflow[0]])).toBeGreaterThan( + scenario.availableWidth, + ); + } + }, + ); + + test("the opt-in scenario moves every optional action under More", () => { + const scenario = ACTION_OVERFLOW_SCENARIOS.find(({ hideClutterButtons }) => hideClutterButtons); + expect(scenario.expectedTopLevel).toEqual([]); + expect(scenario.expectedOverflow).toEqual(OPTIONAL_ACTIONS.map(({ id }) => id)); + }); + + test("the oracle rejects a false-green snapshot that hides every button at a constrained width", () => { + const scenario = ACTION_OVERFLOW_SCENARIOS.find(({ id }) => id === "one-button-overflow-hides-one"); + const falseGreen = { + duplicateActionIds: [], + moreButtonCount: 1, + overflowActionIds: OPTIONAL_ACTIONS.map(({ id }) => id), + reactionGroupCount: 1, + topLevelActionIds: [], + }; + expect(() => assertActionOverflowScenario(falseGreen, scenario, assert)).toThrow("wrong top-level actions"); + }); +}); diff --git a/Extensions/e2e/extension/extension-action-overflow-fixture.js b/Extensions/e2e/extension/extension-action-overflow-fixture.js new file mode 100644 index 0000000..8e96124 --- /dev/null +++ b/Extensions/e2e/extension/extension-action-overflow-fixture.js @@ -0,0 +1,563 @@ +const { + ACTION_GAP_PX, + MORE_BUTTON_WIDTH_PX, + OPTIONAL_ACTIONS, + REACTION_GROUP_WIDTH_PX, + requiredActionRowWidth, +} = require("../action-overflow-contract"); + +const DEFAULT_VIDEO_ID = "overflow001"; + +function createExtensionActionOverflowFixture({ initialVideoId = DEFAULT_VIDEO_ID } = {}) { + const configuration = JSON.stringify({ + actionGap: ACTION_GAP_PX, + initialVideoId, + moreWidth: MORE_BUTTON_WIDTH_PX, + optionalActions: OPTIONAL_ACTIONS, + reactionWidth: REACTION_GROUP_WIDTH_PX, + }); + + return ` + + + + + Owned watch action overflow fixture + + + +
    + + +`; +} + +module.exports = { + DEFAULT_VIDEO_ID, + createExtensionActionOverflowFixture, + requiredActionRowWidth, +}; diff --git a/Extensions/e2e/extension/extension-action-overflow.e2e.js b/Extensions/e2e/extension/extension-action-overflow.e2e.js new file mode 100644 index 0000000..b908280 --- /dev/null +++ b/Extensions/e2e/extension/extension-action-overflow.e2e.js @@ -0,0 +1,247 @@ +const assert = require("node:assert/strict"); +const path = require("node:path"); +const { test, expect } = require("@playwright/test"); +const { + ACTION_OVERFLOW_SCENARIOS, + OPTIONAL_ACTIONS, + assertActionOverflowScenario, +} = require("../action-overflow-contract"); +const { HermeticExtensionArtifactAdapter, startHermeticApiServer } = require("../hermetic-artifact-smoke"); +const { DEFAULT_VIDEO_ID, createExtensionActionOverflowFixture } = require("./extension-action-overflow-fixture"); + +const REPOSITORY_ROOT = path.resolve(__dirname, "../../.."); +const EXTENSION_ARTIFACT = path.resolve( + process.env.RYD_EXTENSION_ARTIFACT || path.join(REPOSITORY_ROOT, "Extensions", "combined", "dist", "chrome"), +); +const DESTINATION_VIDEO_ID = "overflow002"; + +async function withOverflowFixture(testInfo, { hideClutterButtons = false } = {}, run) { + const apiServer = await startHermeticApiServer(); + const adapter = new HermeticExtensionArtifactAdapter({ + apiServer, + artifactDirectory: EXTENSION_ARTIFACT, + backendOptions: { countsByVideo: { [DEFAULT_VIDEO_ID]: { dislikes: 6600, likes: 501000 } } }, + }); + const unexpectedRequests = []; + let caughtError = null; + + try { + await adapter.start(); + await expect + .poll(() => + adapter.worker.evaluate(async () => (await chrome.storage.sync.get(["hideClutterButtons"])).hideClutterButtons), + ) + .toBe(false); + await adapter.worker.evaluate( + (enabled) => chrome.storage.sync.set({ hideClutterButtons: enabled }), + hideClutterButtons, + ); + await expect + .poll(() => + adapter.worker.evaluate(async () => (await chrome.storage.sync.get(["hideClutterButtons"])).hideClutterButtons), + ) + .toBe(hideClutterButtons); + await adapter.context.route("https://www.youtube.com/**", async (route) => { + const request = route.request(); + const url = new URL(request.url()); + if (request.resourceType() === "document" && url.pathname === "/watch") { + await route.fulfill({ + body: createExtensionActionOverflowFixture({ initialVideoId: url.searchParams.get("v") || DEFAULT_VIDEO_ID }), + contentType: "text/html; charset=utf-8", + status: 200, + }); + return; + } + unexpectedRequests.push({ method: request.method(), resourceType: request.resourceType(), url: request.url() }); + await route.abort("blockedbyclient"); + }); + + await adapter.page.goto(`https://www.youtube.com/watch?v=${DEFAULT_VIDEO_ID}&rydOverflowFixture=1`, { + waitUntil: "domcontentloaded", + }); + await adapter.page.waitForFunction(() => Boolean(globalThis.__actionOverflowFixture)); + await adapter.page.waitForTimeout(250); + await adapter.page.evaluate(() => globalThis.__actionOverflowFixture.mount()); + await adapter.page.waitForFunction(() => globalThis.__actionOverflowFixture.isPatched()); + await run({ adapter, page: adapter.page }); + expect(unexpectedRequests).toEqual([]); + await adapter.pageSignals.assertClean(testInfo.title); + } catch (error) { + caughtError = error; + throw error; + } finally { + if (caughtError && adapter.context) { + const screenshotPath = testInfo.outputPath("action-overflow-failure.png"); + await adapter.page.screenshot({ fullPage: true, path: screenshotPath }).catch(() => {}); + await testInfo.attach("action-overflow-page", { contentType: "image/png", path: screenshotPath }).catch(() => {}); + } + await adapter.close().catch(() => {}); + await apiServer.close().catch(() => {}); + } +} + +async function settleAndRead(page, scenario) { + await page.evaluate( + (availableWidth) => globalThis.__actionOverflowFixture.settleLayout(availableWidth), + scenario.availableWidth, + ); + await expect + .poll(() => page.evaluate(() => globalThis.__actionOverflowFixture.snapshot()), { + message: `${scenario.id} did not settle to the expected minimal overflow`, + }) + .toMatchObject({ + availableWidth: scenario.availableWidth, + duplicateActionIds: [], + globalActionCount: OPTIONAL_ACTIONS.length, + menuCount: 1, + moreButtonCount: 1, + overflowActionIds: [...scenario.expectedOverflow], + patched: true, + reactionGroupCount: 1, + topLevelActionIds: [...scenario.expectedTopLevel], + }); + await page.waitForTimeout(180); + const snapshot = await page.evaluate(() => globalThis.__actionOverflowFixture.snapshot()); + assertActionOverflowScenario(snapshot, scenario, assert); + return snapshot; +} + +test.describe("built extension watch action overflow", () => { + for (const scenario of ACTION_OVERFLOW_SCENARIOS.filter(({ hideClutterButtons }) => !hideClutterButtons)) { + test(`${scenario.id} hides exactly the minimum`, async ({}, testInfo) => { + await withOverflowFixture(testInfo, {}, async ({ page }) => { + await settleAndRead(page, scenario); + }); + }); + } + + test("hide-clutter preference keeps only the reaction group top-level", async ({}, testInfo) => { + const scenario = ACTION_OVERFLOW_SCENARIOS.find(({ hideClutterButtons }) => hideClutterButtons); + const roomyScenario = ACTION_OVERFLOW_SCENARIOS.find(({ id }) => id === "roomy-keeps-every-optional-action"); + await withOverflowFixture(testInfo, { hideClutterButtons: true }, async ({ adapter, page }) => { + await expect(page.locator("html")).toHaveAttribute("data-ryd-hide-clutter-buttons", "true"); + const snapshot = await settleAndRead(page, scenario); + expect(snapshot).toMatchObject({ + clutterShareCommandPreserved: true, + clutterShareModelPresent: true, + }); + await page.locator("[data-fixture-more]").click(); + const overflowMenu = page.locator("[data-fixture-overflow-menu]"); + await expect(overflowMenu).toBeVisible(); + await overflowMenu.locator('[data-fixture-action="share"] button').click(); + await expect + .poll(() => page.evaluate(() => globalThis.__actionOverflowFixture.snapshot().activationLog)) + .toEqual(["share"]); + + await adapter.worker.evaluate(() => chrome.storage.sync.set({ hideClutterButtons: false })); + await expect(page.locator("html")).toHaveAttribute("data-ryd-hide-clutter-buttons", "false"); + await settleAndRead(page, roomyScenario); + + await adapter.worker.evaluate(() => chrome.storage.sync.set({ hideClutterButtons: true })); + await expect(page.locator("html")).toHaveAttribute("data-ryd-hide-clutter-buttons", "true"); + await settleAndRead(page, scenario); + }); + }); + + test("resize and navigation restore actions, recompute minimal overflow, and never duplicate controls", async ({}, testInfo) => { + const scenarios = Object.fromEntries(ACTION_OVERFLOW_SCENARIOS.map((scenario) => [scenario.id, scenario])); + await withOverflowFixture(testInfo, {}, async ({ page }) => { + await settleAndRead(page, scenarios["two-button-overflow-hides-two"]); + await settleAndRead(page, scenarios["roomy-keeps-every-optional-action"]); + await settleAndRead(page, scenarios["three-button-overflow-hides-three"]); + + const destinationScenario = scenarios["one-button-overflow-hides-one"]; + await page.evaluate( + ({ availableWidth, videoId }) => globalThis.__actionOverflowFixture.navigate(videoId, availableWidth), + { availableWidth: destinationScenario.availableWidth, videoId: DESTINATION_VIDEO_ID }, + ); + await page.waitForFunction(() => globalThis.__actionOverflowFixture.isPatched()); + await settleAndRead(page, destinationScenario); + + const destination = await page.evaluate(() => globalThis.__actionOverflowFixture.snapshot()); + expect(destination).toMatchObject({ + globalActionCount: OPTIONAL_ACTIONS.length, + menuCount: 1, + navigationCount: 2, + videoId: DESTINATION_VIDEO_ID, + }); + await expect(page.locator("ytd-menu-renderer.ytd-watch-metadata")).toHaveCount(1); + await expect(page.locator("[data-fixture-action]")).toHaveCount(OPTIONAL_ACTIONS.length); + await expect(page.locator("segmented-like-dislike-button-view-model")).toHaveCount(1); + await expect(page.locator("[data-fixture-more]")).toHaveCount(1); + }); + }); + + test("widening restores flexible actions without a model reset", async ({}, testInfo) => { + const narrowScenario = ACTION_OVERFLOW_SCENARIOS.find(({ id }) => id === "three-button-overflow-hides-three"); + const widerScenario = ACTION_OVERFLOW_SCENARIOS.find(({ id }) => id === "one-button-overflow-hides-one"); + await withOverflowFixture(testInfo, {}, async ({ page }) => { + await settleAndRead(page, narrowScenario); + + await page.evaluate( + (availableWidth) => globalThis.__actionOverflowFixture.resizeLayout(availableWidth), + widerScenario.availableWidth, + ); + await expect + .poll(() => page.evaluate(() => globalThis.__actionOverflowFixture.snapshot()), { + message: "widening did not restore optional actions before native fitting", + }) + .toMatchObject({ + overflowActionIds: [...widerScenario.expectedOverflow], + topLevelActionIds: [...widerScenario.expectedTopLevel], + }); + + const snapshot = await page.evaluate(() => globalThis.__actionOverflowFixture.snapshot()); + assertActionOverflowScenario(snapshot, widerScenario, assert); + }); + }); + + test("widening restores flexible actions after same-video menu data identity churn", async ({}, testInfo) => { + const narrowScenario = ACTION_OVERFLOW_SCENARIOS.find(({ id }) => id === "three-button-overflow-hides-three"); + const widerScenario = ACTION_OVERFLOW_SCENARIOS.find(({ id }) => id === "one-button-overflow-hides-one"); + await withOverflowFixture(testInfo, {}, async ({ page }) => { + await settleAndRead(page, narrowScenario); + + await page.evaluate( + (availableWidth) => globalThis.__actionOverflowFixture.resizeLayoutAfterDataIdentityChurn(availableWidth), + widerScenario.availableWidth, + ); + await expect + .poll(() => page.evaluate(() => globalThis.__actionOverflowFixture.snapshot()), { + message: "same-video menu data churn discarded optional actions before the wider layout", + }) + .toMatchObject({ + overflowActionIds: [...widerScenario.expectedOverflow], + topLevelActionIds: [...widerScenario.expectedTopLevel], + }); + + const snapshot = await page.evaluate(() => globalThis.__actionOverflowFixture.snapshot()); + assertActionOverflowScenario(snapshot, widerScenario, assert); + }); + }); + + test("a ResizeObserver refits actions when YouTube does not promptly notify its controller", async ({}, testInfo) => { + const narrowScenario = ACTION_OVERFLOW_SCENARIOS.find(({ id }) => id === "three-button-overflow-hides-three"); + const widerScenario = ACTION_OVERFLOW_SCENARIOS.find(({ id }) => id === "one-button-overflow-hides-one"); + await withOverflowFixture(testInfo, {}, async ({ page }) => { + await settleAndRead(page, narrowScenario); + + await page.evaluate( + (availableWidth) => globalThis.__actionOverflowFixture.resizeLayoutWithoutNativeNotification(availableWidth), + widerScenario.availableWidth, + ); + await expect + .poll(() => page.evaluate(() => globalThis.__actionOverflowFixture.snapshot()), { + message: "the observed action allocation changed without triggering a fresh fit", + timeout: 2_000, + }) + .toMatchObject({ + overflowActionIds: [...widerScenario.expectedOverflow], + topLevelActionIds: [...widerScenario.expectedTopLevel], + }); + + const snapshot = await page.evaluate(() => globalThis.__actionOverflowFixture.snapshot()); + assertActionOverflowScenario(snapshot, widerScenario, assert); + }); + }); +}); diff --git a/Extensions/e2e/extension/extension-artifact-navigation.e2e.js b/Extensions/e2e/extension/extension-artifact-navigation.e2e.js new file mode 100644 index 0000000..19a0502 --- /dev/null +++ b/Extensions/e2e/extension/extension-artifact-navigation.e2e.js @@ -0,0 +1,950 @@ +const fs = require("node:fs"); +const path = require("node:path"); +const { test, expect } = require("@playwright/test"); +const { HermeticExtensionArtifactAdapter, startHermeticApiServer } = require("../hermetic-artifact-smoke"); +const { + EXTENSION_MATRIX_RUNTIME, + NAVIGATION_MATRIX, + installNavigationMatrixFixture, +} = require("../../UserScript/e2e/navigation-matrix"); +const { + createNavigationRuntimeContractAdapter, + registerNavigationRuntimeContractScenarios, + runNavigationRuntimeContract, +} = require("../navigation-runtime-contract"); +const { DEFAULT_VIDEO_IDS, createExtensionNavigationFixture } = require("./extension-navigation-fixture"); +const { expectExtensionShortsVisualContract, readExtensionShortsVisualContract } = require("../shorts-visual-contract"); +const { annotateVisualEvidence, captureOptionalVisualEvidence } = require("../visual-evidence"); +const { + SHORTS_PLACEHOLDER_POOL_COUNTS, + installShortsPlaceholderPoolRoute, + runShortsPlaceholderPoolContract, + shortsPlaceholderPoolUrl, +} = require("../shorts-placeholder-pool-contract"); + +const REPOSITORY_ROOT = path.resolve(__dirname, "../../.."); +const EXTENSION_ARTIFACT = path.resolve( + process.env.RYD_EXTENSION_ARTIFACT || path.join(REPOSITORY_ROOT, "Extensions", "combined", "dist", "chrome"), +); +const COUNTS = Object.freeze({ + [DEFAULT_VIDEO_IDS.A]: Object.freeze({ dislikes: 11, likes: 89 }), + [DEFAULT_VIDEO_IDS.B]: Object.freeze({ dislikes: 35, likes: 65 }), + [DEFAULT_VIDEO_IDS.C]: Object.freeze({ dislikes: 70, likes: 30 }), +}); +const INITIALIZATION_WARNING = "Initialization failed; retrying when the current controls are ready."; +const SHORTS_VISUAL_REVIEW_DIRECTORY = path.join(REPOSITORY_ROOT, "test-results", "visual-review", "extension"); +const SYNTHETIC_SHORTS_STORAGE_PREFIX = "rydSyntheticDislikedShort:"; +const SHORTS_TRANSITION_CASES = Object.freeze([ + Object.freeze({ + action: "like", + dislikesDelta: 0, + initialState: "neutral", + likesDelta: 1, + nextState: "liked", + value: 1, + }), + Object.freeze({ + action: "like", + dislikesDelta: 0, + initialState: "liked", + likesDelta: -1, + nextState: "neutral", + value: 0, + }), + Object.freeze({ + action: "like", + dislikesDelta: -1, + initialState: "disliked", + likesDelta: 1, + nextState: "liked", + value: 1, + }), + Object.freeze({ + action: "dislike", + dislikesDelta: 1, + initialState: "neutral", + likesDelta: 0, + nextState: "disliked", + value: -1, + }), + Object.freeze({ + action: "dislike", + dislikesDelta: -1, + initialState: "disliked", + likesDelta: 0, + nextState: "neutral", + value: 0, + }), + Object.freeze({ + action: "dislike", + dislikesDelta: 1, + initialState: "liked", + likesDelta: -1, + nextState: "disliked", + value: -1, + }), +]); + +async function captureShortsVisualReview(page, scenario) { + const outputPath = path.join(SHORTS_VISUAL_REVIEW_DIRECTORY, `${scenario.id}.png`); + return captureOptionalVisualEvidence({ + capture: async (screenshotPath) => { + fs.mkdirSync(SHORTS_VISUAL_REVIEW_DIRECTORY, { recursive: true }); + const renderer = page.locator(`ytd-reel-video-renderer[video-id="${scenario.destination.videoId}"][is-active]`); + await renderer.screenshot({ animations: "disabled", path: screenshotPath }); + }, + outputPath, + }); +} + +function videoCountsForScenario(scenario) { + return { + [scenario.origin.videoId]: scenario.origin.counts, + [scenario.destination.videoId]: scenario.destination.counts, + }; +} + +function testFailed(testInfo, caughtError) { + return Boolean(caughtError) || (testInfo.status && testInfo.status !== testInfo.expectedStatus); +} + +async function attachFailureEvidence(runtime, testInfo) { + const screenshotPath = testInfo.outputPath("extension-navigation-failure.png"); + try { + await runtime.page.screenshot({ fullPage: true, path: screenshotPath }); + await testInfo.attach("extension-navigation-page", { contentType: "image/png", path: screenshotPath }); + } catch { + // Preserve the original test failure if the page or context already closed. + } +} + +async function withExtensionArtifact(testInfo, { countsByVideo, installRoute } = {}, run) { + if (!fs.existsSync(path.join(EXTENSION_ARTIFACT, "manifest.json"))) { + throw new Error( + `The generated Chrome extension artifact is missing at ${EXTENSION_ARTIFACT}. ` + + "Run the extension production build first or set RYD_EXTENSION_ARTIFACT.", + ); + } + + const apiServer = await startHermeticApiServer(); + const adapter = new HermeticExtensionArtifactAdapter({ + apiServer, + artifactDirectory: EXTENSION_ARTIFACT, + backendOptions: { countsByVideo }, + }); + const runtime = { + adapter, + apiServer, + caughtError: null, + initializationWarnings: [], + unexpectedFixtureRequests: [], + workerErrors: [], + }; + + try { + await adapter.start(); + runtime.page = adapter.page; + runtime.page.on("console", (message) => { + if (message.type() === "warning" && message.text().includes(INITIALIZATION_WARNING)) { + runtime.initializationWarnings.push(message.text()); + } + }); + adapter.worker.on("console", (message) => { + if (["warning", "error", "assert"].includes(message.type())) runtime.workerErrors.push(message.text()); + }); + if (installRoute) await installRoute(runtime); + await run(runtime); + } catch (error) { + runtime.caughtError = error; + throw error; + } finally { + const failed = testFailed(testInfo, runtime.caughtError); + if (adapter.context && failed) await attachFailureEvidence(runtime, testInfo); + await adapter.close().catch(() => {}); + await apiServer.close().catch(() => {}); + } +} + +async function assertHermeticRuntime(runtime, scenarioId) { + await runtime.adapter.pageSignals.assertClean(scenarioId); + expect(runtime.initializationWarnings, "initialization must wait for usable controls instead of throwing").toEqual( + [], + ); + expect(runtime.workerErrors, "the MV3 background worker emitted a warning or error").toEqual([]); + expect(runtime.unexpectedFixtureRequests, "the owned fixture attempted unexpected YouTube requests").toEqual([]); + expect(runtime.adapter.backend.blockedRequests, "the content script escaped the hermetic route set").toEqual([]); + expect(runtime.apiServer.unexpectedRequests, "the MV3 background escaped the fake protocol").toEqual([]); +} + +async function installOwnedLifecycleRoute(runtime) { + await runtime.adapter.context.route("https://www.youtube.com/**", async (route) => { + const request = route.request(); + const url = new URL(request.url()); + if (request.resourceType() === "document" && url.pathname === "/watch") { + await route.fulfill({ + body: createExtensionNavigationFixture({ + initialVideoId: url.searchParams.get("v") || DEFAULT_VIDEO_IDS.A, + likesByVideo: Object.fromEntries(Object.entries(COUNTS).map(([videoId, counts]) => [videoId, counts.likes])), + }), + contentType: "text/html; charset=utf-8", + status: 200, + }); + return; + } + + runtime.unexpectedFixtureRequests.push({ + method: request.method(), + resourceType: request.resourceType(), + url: request.url(), + }); + await route.abort("blockedbyclient"); + }); +} + +async function installHrefIdentifiedShortsTopology(context) { + await context.addInitScript( + ({ videoA, videoB }) => { + if (location.hostname !== "www.youtube.com") return; + + document.addEventListener( + "DOMContentLoaded", + () => { + const params = new URL(location.href).searchParams; + if (params.get("rydHrefIdentifiedShorts") !== "1") return; + + const rendererFor = (videoId) => + Array.from(document.querySelectorAll("ytd-reel-video-renderer")).find((renderer) => { + const href = renderer.querySelector("a[href*='/shorts/']")?.getAttribute("href"); + return href && new URL(href, location.href).pathname === `/shorts/${videoId}`; + }); + + for (const renderer of document.querySelectorAll("ytd-reel-video-renderer")) { + renderer.removeAttribute("is-active"); + renderer.removeAttribute("video-id"); + renderer.querySelector("#experiment-overlay")?.remove(); + const sequence = renderer.parentElement; + sequence?.classList.remove("reel-video-in-sequence-new"); + sequence?.querySelector(".reel-video-in-sequence-thumbnail")?.remove(); + } + + const next = document.querySelector("#short-next"); + if (!next) throw new Error("The href-identified Shorts fixture has no next control."); + next.addEventListener( + "click", + (event) => { + event.preventDefault(); + event.stopImmediatePropagation(); + + const outgoing = rendererFor(videoA); + const destination = rendererFor(videoB); + if (!outgoing || !destination) { + throw new Error("The href-identified Shorts fixture is missing a renderer."); + } + + document.dispatchEvent(new Event("yt-navigate-start", { bubbles: true })); + history.pushState( + {}, + "", + `/shorts/${videoB}?rydNavigationFixture=1&rydRuntime=extension&rydHrefIdentifiedShorts=1`, + ); + outgoing.hidden = true; + destination.hidden = false; + const like = destination.querySelector('[data-fixture-role="like"]'); + like?.classList.remove("style-default-active"); + like?.classList.add("style-text"); + like?.querySelector("button")?.setAttribute("aria-pressed", "false"); + document.dispatchEvent(new Event("yt-navigate-finish", { bubbles: true })); + }, + true, + ); + }, + { once: true }, + ); + }, + { videoA: DEFAULT_VIDEO_IDS.A, videoB: DEFAULT_VIDEO_IDS.B }, + ); +} + +async function installShortsNativeLikeBehavior(context, initialState) { + await context.addInitScript((state) => { + document.addEventListener( + "DOMContentLoaded", + () => { + const like = document.querySelector( + "ytd-reel-video-renderer[is-active] reel-action-bar-view-model like-button-view-model", + ); + const button = like?.querySelector("button"); + const count = like?.querySelector("#text, [role='text']"); + if (!like || !button || !count) throw new Error("The Shorts transition fixture has no native Like action."); + + const setLiked = (liked) => { + like.classList.toggle("style-default-active", liked); + like.classList.toggle("style-text", !liked); + button.setAttribute("aria-pressed", String(liked)); + }; + setLiked(state === "liked"); + }, + { once: true }, + ); + }, initialState); +} + +async function installShortsPremiumTeaserContainer(context) { + await context.addInitScript(() => { + document.addEventListener( + "DOMContentLoaded", + () => { + const secondary = document.createElement("aside"); + secondary.id = "secondary"; + const inner = document.createElement("div"); + inner.id = "secondary-inner"; + secondary.append(inner); + document.body.append(secondary); + }, + { once: true }, + ); + }); +} + +async function setStoredSyntheticShortsState(runtime, videoId, disliked) { + const key = `${SYNTHETIC_SHORTS_STORAGE_PREFIX}${videoId}`; + await runtime.adapter.worker.evaluate( + async ({ disliked: nextDisliked, key: storageKey }) => { + if (nextDisliked) await chrome.storage.local.set({ [storageKey]: true }); + else await chrome.storage.local.remove([storageKey]); + }, + { disliked, key }, + ); +} + +async function readStoredSyntheticShortsState(runtime, videoId) { + const key = `${SYNTHETIC_SHORTS_STORAGE_PREFIX}${videoId}`; + return runtime.adapter.worker.evaluate( + async (storageKey) => (await chrome.storage.local.get([storageKey]))[storageKey] === true, + key, + ); +} + +async function openLifecycleFixture(runtime, { delayed = false } = {}) { + const initial = delayed ? "&initial=delayed" : ""; + await runtime.page.goto(`https://www.youtube.com/watch?v=${DEFAULT_VIDEO_IDS.A}&rydExtensionLifecycle=1${initial}`, { + waitUntil: "domcontentloaded", + }); + await runtime.page.waitForFunction(() => Boolean(globalThis.__extensionLifecycleFixture)); +} + +function statsRequests(runtime, videoId) { + return runtime.adapter.backend.requestsFor("GET", "/votes").filter((request) => request.query.videoId === videoId); +} + +async function collectMatrixFailureDiagnostics(runtime) { + return { + countRequestVideoIds: runtime.adapter.backend.requestsFor("GET", "/votes").map((request) => request.query.videoId), + initializationWarnings: [...runtime.initializationWarnings], + pageSignals: runtime.adapter.pageSignals.snapshot(), + probe: await runtime.page.evaluate(() => globalThis.__navigationMatrixProbe?.snapshot?.() ?? null), + shortsReadiness: await runtime.page.evaluate(() => { + const box = (element) => { + const rect = element?.getBoundingClientRect(); + return rect + ? { + bottom: rect.bottom, + height: rect.height, + left: rect.left, + right: rect.right, + top: rect.top, + width: rect.width, + } + : null; + }; + return [...document.querySelectorAll(".reel-video-in-sequence-new")].map((container) => { + const renderer = container.querySelector("ytd-reel-video-renderer"); + const overlay = renderer?.querySelector("#experiment-overlay"); + return { + container: box(container), + overlayChildren: overlay?.childNodes.length ?? null, + overlayDisplay: overlay ? getComputedStyle(overlay).display : null, + renderer: box(renderer), + rendererActive: renderer?.hasAttribute("is-active") ?? false, + rendererHidden: renderer?.hidden ?? null, + videoId: renderer?.getAttribute("video-id") ?? null, + viewport: { height: innerHeight, width: innerWidth }, + }; + }); + }), + }; +} + +async function readLifecycleSnapshot(page) { + return page.evaluate(() => globalThis.__extensionLifecycleFixture.snapshot()); +} + +async function expectNoSkeletonMisbind(runtime, videoId, { retainedBarCount = 0, statsRequestCount = 0 } = {}) { + await runtime.page.waitForTimeout(650); + const snapshot = await readLifecycleSnapshot(runtime.page); + expect(snapshot).toMatchObject({ + currentBarCount: 0, + currentButtonCount: 0, + currentCount: "", + currentCountContainerCount: 0, + currentCountCreatedByRuntime: false, + currentNativeDislikeTextCount: 0, + currentVideoId: videoId, + hiddenDuplicateBarCount: 0, + phase: "skeleton", + retainedBarCount, + unrelatedBarCount: 0, + unrelatedLabels: ["Share", "Download"], + urlVideoId: videoId, + }); + expect(snapshot.globalBarCount).toBe(retainedBarCount); + expect(snapshot.globalContainerCount).toBe(retainedBarCount); + expect(snapshot.globalWrapperCount).toBe(retainedBarCount); + await expect(runtime.page.locator("#ryd-bar:visible")).toHaveCount(0); + expect(statsRequests(runtime, videoId)).toHaveLength(statsRequestCount); + expect(runtime.initializationWarnings).toEqual([]); +} + +async function expectCurrentSurface(runtime, videoId, counts, { soakMs = 650 } = {}) { + const page = runtime.page; + const currentHost = page.locator('[data-fixture-current-actions="true"]'); + const likeButton = currentHost.locator('like-button-view-model [data-fixture-native-role="like"]'); + const dislikeButton = currentHost.locator('dislike-button-view-model [data-fixture-native-role="dislike"]'); + const currentCount = currentHost.locator( + 'dislike-button-view-model [data-fixture-native-role="dislike"] .ytSpecButtonShapeNextButtonTextContent', + ); + const currentCountText = currentCount.locator("#text"); + const currentContainer = currentHost.locator("#ryd-bar-container"); + const currentBar = currentHost.locator("#ryd-bar"); + + await expect(page).toHaveURL((url) => url.pathname === "/watch" && url.searchParams.get("v") === videoId); + await expect(currentHost).toHaveCount(1); + await expect(currentHost).toBeVisible(); + await expect( + currentHost.locator( + "segmented-like-dislike-button-view-model > yt-smartimation > [data-fixture-smartimation-content-shell] > [data-fixture-smartimation-content] > like-button-view-model", + ), + ).toHaveCount(1); + await expect( + currentHost.locator( + "segmented-like-dislike-button-view-model > yt-smartimation > [data-fixture-smartimation-content-shell] > [data-fixture-smartimation-content] > dislike-button-view-model", + ), + ).toHaveCount(1); + await expect(currentHost).toHaveAttribute("data-fixture-native-dislike-text-count", "0"); + await expect(currentCount).toHaveCount(1); + await expect(currentCountText).toHaveCount(1); + await expect(currentCountText).toHaveText(String(counts.dislikes)); + await expect(likeButton).toHaveCount(1); + await expect(likeButton).toBeVisible(); + await expect(dislikeButton).toHaveCount(1); + await expect(dislikeButton).toBeVisible(); + await expect(currentHost.locator("like-button-view-model [role='text']")).toHaveText(String(counts.likes)); + await expect(currentHost.locator(".ryd-tooltip")).toHaveCount(1); + await expect(currentContainer).toHaveCount(1); + await expect(currentContainer).toBeVisible(); + await expect(currentBar).toHaveCount(1); + await expect(currentBar).toBeVisible(); + await expect(currentHost.locator("#ryd-dislike-tooltip")).toContainText(`${counts.likes} / ${counts.dislikes}`); + + const geometry = await currentHost.evaluate((host) => { + const container = host.querySelector("#ryd-bar-container"); + const containerBox = container.getBoundingClientRect(); + return { + containerHeight: containerBox.height, + containerWidth: containerBox.width, + }; + }); + expect(geometry.containerHeight).toBeGreaterThanOrEqual(2); + expect(geometry.containerWidth).toBeGreaterThan(0); + await expect + .poll( + () => + currentHost.evaluate((host) => { + const containerBox = host.querySelector("#ryd-bar-container").getBoundingClientRect(); + const fillBox = host.querySelector("#ryd-bar").getBoundingClientRect(); + return containerBox.width > 0 ? fillBox.width / containerBox.width : null; + }), + { message: "the visible rate-bar fill must settle to the current video's counts" }, + ) + .toBeCloseTo(counts.likes / (counts.likes + counts.dislikes), 2); + + await expect(page.locator(".ryd-tooltip")).toHaveCount(1); + await expect(page.locator("#ryd-bar-container")).toHaveCount(1); + await expect(page.locator("#ryd-bar")).toHaveCount(1); + await expect(page.locator('[data-fixture-unrelated-actions="true"] #ryd-bar')).toHaveCount(0); + await expect(page.locator('[data-fixture-hidden-duplicate="true"] #ryd-bar')).toHaveCount(0); + await expect(page.locator('[data-fixture-retained-outgoing="true"] #ryd-bar')).toHaveCount(0); + await expect(page.locator('[data-fixture-unrelated-control="share"] span[role="text"]')).toHaveText("Share"); + await expect(page.locator('[data-fixture-unrelated-control="download"] span[role="text"]')).toHaveText("Download"); + + await page.waitForTimeout(soakMs); + await expect(currentCount).toHaveText(String(counts.dislikes)); + await expect(page.locator(".ryd-tooltip")).toHaveCount(1); + await expect(page.locator("#ryd-bar-container")).toHaveCount(1); + await expect(page.locator("#ryd-bar")).toHaveCount(1); +} + +async function hydrateAndExpect(runtime, videoId, counts) { + await runtime.page.evaluate(() => globalThis.__extensionLifecycleFixture.hydratePending()); + await expectCurrentSurface(runtime, videoId, counts); +} + +async function replaceCurrentHostAndExpect(runtime, videoId, counts) { + const previousIdentity = (await readLifecycleSnapshot(runtime.page)).currentHostIdentity; + const replacementIdentity = await runtime.page.evaluate(() => + globalThis.__extensionLifecycleFixture.replaceReadyHost(), + ); + expect(replacementIdentity).not.toBe(previousIdentity); + await expectCurrentSurface(runtime, videoId, counts); +} + +async function expectOneVoteAndConfirmation(runtime, videoId, value, activationTarget) { + const interactionStart = runtime.apiServer.records.length; + const target = + activationTarget ?? runtime.page.locator('[data-fixture-current-actions="true"] dislike-button-view-model button'); + await target.click(); + + const readInteractions = () => + runtime.apiServer.records + .slice(interactionStart) + .filter( + (record) => + record.method !== "OPTIONS" && ["/interact/vote", "/interact/confirmVote"].includes(record.pathname), + ); + await expect.poll(() => readInteractions().length).toBe(2); + await runtime.page.waitForTimeout(400); + + const interactions = readInteractions(); + expect(interactions.map((record) => record.pathname)).toEqual(["/interact/vote", "/interact/confirmVote"]); + expect(interactions[0].body).toEqual({ + userId: expect.stringMatching(/^[A-Za-z0-9]{36}$/), + value, + videoId, + }); + expect(interactions[1].body).toEqual({ + solution: expect.any(String), + userId: interactions[0].body.userId, + videoId, + }); + expect(Buffer.from(interactions[1].body.solution, "base64")).toHaveLength(4); + expect(interactions[1]).toMatchObject({ responseBody: true, responseStatus: 200 }); + expect(readInteractions()).toHaveLength(2); + return interactions[0].body.userId; +} + +test.describe("generated extension artifact Shorts placeholder pool", () => { + test("preserves every action through ten pre-rendered and recycled Next transitions", async ({}, testInfo) => { + test.setTimeout(45_000); + await withExtensionArtifact( + testInfo, + { + countsByVideo: SHORTS_PLACEHOLDER_POOL_COUNTS, + installRoute: async (runtime) => { + await installShortsPlaceholderPoolRoute(runtime.adapter.context, { + onUnexpectedRequest: (request) => runtime.unexpectedFixtureRequests.push(request), + }); + }, + }, + async (runtime) => { + await runtime.page.setViewportSize({ height: 720, width: 1280 }); + await runtime.page.goto(shortsPlaceholderPoolUrl(), { waitUntil: "domcontentloaded" }); + const results = await runShortsPlaceholderPoolContract({ + page: runtime.page, + readRequests: () => runtime.adapter.backend.requests, + runtimeName: "extension", + }); + + expect(results).toHaveLength(11); + expect(results.filter((result) => result.pixelOracle).map((result) => result.logicalIndex)).toEqual([0, 1, 10]); + expect( + runtime.apiServer.records.filter((record) => + ["/interact/vote", "/interact/confirmVote"].includes(record.pathname), + ), + ).toEqual([]); + await assertHermeticRuntime(runtime, "shorts-placeholder-pool-ten-next"); + }, + ); + }); +}); + +test.describe("generated extension artifact navigation matrix", () => { + registerNavigationRuntimeContractScenarios({ + runtimeName: "extension", + register: ({ scenario, title }) => { + test(title, async ({}, testInfo) => { + await withExtensionArtifact(testInfo, { countsByVideo: videoCountsForScenario(scenario) }, async (runtime) => { + await runtime.page.setViewportSize(scenario.viewport); + await installNavigationMatrixFixture(runtime.adapter.context, scenario); + const pageKind = scenario.origin.kind; + const marker = "rydNavigationFixture=1&rydRuntime=extension"; + const url = + pageKind === "shorts" + ? `https://www.youtube.com/shorts/${scenario.origin.videoId}?${marker}` + : `https://www.youtube.com/watch?v=${scenario.origin.videoId}&${marker}`; + await runtime.page.goto(url, { waitUntil: "domcontentloaded" }); + + try { + const adapter = createNavigationRuntimeContractAdapter({ + backend: runtime.adapter.backend, + matrixRuntime: EXTENSION_MATRIX_RUNTIME, + page: runtime.page, + readInteractionRecords: () => runtime.apiServer.records, + runtimeName: "extension", + }); + await runNavigationRuntimeContract({ + adapter, + afterNavigation: async () => { + if (!scenario.id.startsWith("short-next-short-active-reel")) return; + const shortsVisual = await readExtensionShortsVisualContract( + runtime.page, + scenario.destination.videoId, + ); + expectExtensionShortsVisualContract(shortsVisual, scenario.destination.counts, scenario.viewport); + annotateVisualEvidence(testInfo, await captureShortsVisualReview(runtime.page, scenario)); + }, + scenario, + }); + if (scenario.destination.kind === "shorts") { + await expect + .poll(() => readStoredSyntheticShortsState(runtime, scenario.destination.videoId)) + .toBe(false); + } + } catch (error) { + error.message += `\nExtension navigation diagnostics: ${JSON.stringify( + await collectMatrixFailureDiagnostics(runtime), + )}`; + throw error; + } + await assertHermeticRuntime(runtime, scenario.id); + }); + }); + }, + }); +}); + +test.describe("generated extension artifact current Shorts topology", () => { + test("premium teaser and the Shorts renderer share one aggregate request per navigation", async ({}, testInfo) => { + const scenario = NAVIGATION_MATRIX.find(({ id }) => id === "short-next-short-active-reel"); + await withExtensionArtifact(testInfo, { countsByVideo: videoCountsForScenario(scenario) }, async (runtime) => { + await runtime.page.setViewportSize(scenario.viewport); + await installShortsPremiumTeaserContainer(runtime.adapter.context); + await installNavigationMatrixFixture(runtime.adapter.context, scenario); + await runtime.page.goto( + `https://www.youtube.com/shorts/${scenario.origin.videoId}` + + "?rydNavigationFixture=1&rydRuntime=extension&rydPremiumTeaser=1", + { waitUntil: "domcontentloaded" }, + ); + + await expect(runtime.page.locator(".ryd-premium-teaser")).toHaveCount(1); + await expect( + runtime.page.locator( + `ytd-reel-video-renderer[video-id="${scenario.origin.videoId}"] [data-ryd-synthetic-shorts-dislike] #text`, + ), + ).toHaveText(String(scenario.origin.counts.dislikes)); + expect(statsRequests(runtime, scenario.origin.videoId)).toHaveLength(1); + + await runtime.page.locator("#short-next").click(); + + await expect(runtime.page).toHaveURL((url) => url.pathname === `/shorts/${scenario.destination.videoId}`); + await expect( + runtime.page.locator( + `ytd-reel-video-renderer[video-id="${scenario.destination.videoId}"] [data-ryd-synthetic-shorts-dislike] #text`, + ), + ).toHaveText(String(scenario.destination.counts.dislikes)); + await expect(runtime.page.locator("#ryd-premium-teaser-dislikes")).toHaveText( + String(scenario.destination.counts.dislikes), + ); + expect(statsRequests(runtime, scenario.origin.videoId)).toHaveLength(1); + expect(statsRequests(runtime, scenario.destination.videoId)).toHaveLength(1); + await runtime.page.waitForTimeout(650); + expect(statsRequests(runtime, scenario.destination.videoId)).toHaveLength(1); + await assertHermeticRuntime(runtime, "shorts-premium-teaser-request-coalescing"); + }); + }); + + test("href-only renderer identity initializes directly and after next-Short navigation", async ({}, testInfo) => { + await withExtensionArtifact(testInfo, { countsByVideo: COUNTS }, async (runtime) => { + await runtime.page.setViewportSize({ height: 720, width: 1280 }); + await installHrefIdentifiedShortsTopology(runtime.adapter.context); + await runtime.page.goto( + `https://www.youtube.com/shorts/${DEFAULT_VIDEO_IDS.A}` + + "?rydNavigationFixture=1&rydRuntime=extension&rydHrefIdentifiedShorts=1", + { waitUntil: "domcontentloaded" }, + ); + + const rendererFor = (videoId) => + runtime.page.locator("ytd-reel-video-renderer").filter({ + has: runtime.page.locator(`a[href='/shorts/${videoId}']`), + }); + const dislikeFor = (videoId) => rendererFor(videoId).locator("[data-ryd-synthetic-shorts-dislike]"); + const rendererA = rendererFor(DEFAULT_VIDEO_IDS.A); + const rendererB = rendererFor(DEFAULT_VIDEO_IDS.B); + + await expect(rendererA).toHaveCount(1); + await expect(rendererA).toBeVisible(); + await expect(rendererA).not.toHaveAttribute("is-active", /.*/); + await expect(rendererA).not.toHaveAttribute("video-id", /.*/); + await expect(runtime.page.locator(".reel-video-in-sequence-new")).toHaveCount(0); + await expect(runtime.page.locator(".reel-video-in-sequence-thumbnail")).toHaveCount(0); + await expect(runtime.page.locator("#experiment-overlay")).toHaveCount(0); + await expect(dislikeFor(DEFAULT_VIDEO_IDS.A).locator("#text")).toHaveText( + String(COUNTS[DEFAULT_VIDEO_IDS.A].dislikes), + ); + await expect(dislikeFor(DEFAULT_VIDEO_IDS.A)).toHaveAttribute("data-ryd-video-id", DEFAULT_VIDEO_IDS.A); + await expect(dislikeFor(DEFAULT_VIDEO_IDS.A).locator("button")).toBeEnabled(); + await expect(rendererA.locator("reel-action-bar-view-model button:visible")).toHaveCount(6); + expect(statsRequests(runtime, DEFAULT_VIDEO_IDS.A)).toHaveLength(1); + expect(statsRequests(runtime, DEFAULT_VIDEO_IDS.B)).toHaveLength(0); + + await runtime.page.locator("#short-next").click(); + + await expect(runtime.page).toHaveURL((url) => url.pathname === `/shorts/${DEFAULT_VIDEO_IDS.B}`); + await expect(rendererA).toBeHidden(); + await expect(rendererB).toBeVisible(); + await expect(rendererB).not.toHaveAttribute("is-active", /.*/); + await expect(rendererB).not.toHaveAttribute("video-id", /.*/); + await expect(dislikeFor(DEFAULT_VIDEO_IDS.B).locator("#text")).toHaveText( + String(COUNTS[DEFAULT_VIDEO_IDS.B].dislikes), + ); + await expect(dislikeFor(DEFAULT_VIDEO_IDS.B)).toHaveAttribute("data-ryd-video-id", DEFAULT_VIDEO_IDS.B); + await expect(dislikeFor(DEFAULT_VIDEO_IDS.B).locator("button")).toBeEnabled(); + await expect(rendererB.locator("reel-action-bar-view-model button:visible")).toHaveCount(6); + await expect(runtime.page.locator("[data-ryd-synthetic-shorts-dislike]:visible")).toHaveCount(1); + await expect(runtime.page.locator("dislike-button-view-model, #dislike-button")).toHaveCount(0); + expect(statsRequests(runtime, DEFAULT_VIDEO_IDS.A)).toHaveLength(1); + expect(statsRequests(runtime, DEFAULT_VIDEO_IDS.B)).toHaveLength(1); + expect( + runtime.apiServer.records.filter((record) => + ["/interact/vote", "/interact/confirmVote"].includes(record.pathname), + ), + ).toEqual([]); + + await expectOneVoteAndConfirmation( + runtime, + DEFAULT_VIDEO_IDS.B, + -1, + dislikeFor(DEFAULT_VIDEO_IDS.B).locator("button"), + ); + await expect(dislikeFor(DEFAULT_VIDEO_IDS.B).locator("#text")).toHaveText( + String(COUNTS[DEFAULT_VIDEO_IDS.B].dislikes + 1), + ); + await expect(dislikeFor(DEFAULT_VIDEO_IDS.B).locator("button")).toHaveAttribute("aria-pressed", "true"); + await expect(dislikeFor(DEFAULT_VIDEO_IDS.B)).toHaveClass(/style-default-active/); + await expect + .poll(() => + runtime.adapter.worker.evaluate(async ({ key }) => (await chrome.storage.local.get([key]))[key], { + key: `rydSyntheticDislikedShort:${DEFAULT_VIDEO_IDS.B}`, + }), + ) + .toBe(true); + await expect(rendererA).toBeHidden(); + await expect(dislikeFor(DEFAULT_VIDEO_IDS.A).locator("#text")).not.toHaveText( + String(COUNTS[DEFAULT_VIDEO_IDS.B].dislikes + 1), + ); + await assertHermeticRuntime(runtime, "href-only-current-shorts-topology"); + }); + }); +}); + +test.describe("generated extension artifact synthetic Shorts vote transitions", () => { + for (const transition of SHORTS_TRANSITION_CASES) { + test(`${transition.initialState} + ${transition.action} -> ${transition.nextState}`, async ({}, testInfo) => { + await withExtensionArtifact(testInfo, { countsByVideo: COUNTS }, async (runtime) => { + const videoId = DEFAULT_VIDEO_IDS.A; + const initialCounts = COUNTS[videoId]; + await runtime.page.setViewportSize({ height: 720, width: 1280 }); + await installShortsNativeLikeBehavior(runtime.adapter.context, transition.initialState); + await setStoredSyntheticShortsState(runtime, videoId, transition.initialState === "disliked"); + await runtime.page.goto( + `https://www.youtube.com/shorts/${videoId}?rydNavigationFixture=1&rydRuntime=extension`, + { waitUntil: "domcontentloaded" }, + ); + + const renderer = runtime.page.locator(`ytd-reel-video-renderer[video-id="${videoId}"][is-active]`); + const like = renderer.locator("like-button-view-model"); + const likeButton = like.locator("button"); + const likeCount = like.locator("#text, [role='text']"); + const dislike = renderer.locator("[data-ryd-synthetic-shorts-dislike]"); + const dislikeButton = dislike.locator("button"); + const dislikeCount = dislike.locator("#text, [role='text']"); + + await expect(dislike).toHaveCount(1); + await expect(dislikeButton).toBeEnabled(); + await expect(dislikeCount).toHaveText(String(initialCounts.dislikes)); + await expect(likeCount).toHaveText(String(initialCounts.likes)); + await expect(likeButton).toHaveAttribute("aria-pressed", String(transition.initialState === "liked")); + await expect(dislikeButton).toHaveAttribute("aria-pressed", String(transition.initialState === "disliked")); + await expect(renderer.locator("reel-action-bar-view-model button:visible")).toHaveCount(6); + + const nativeReactionStatsBefore = await runtime.page.evaluate(() => + globalThis.__navigationFixture.nativeReactionSnapshot(), + ); + const activationTarget = transition.action === "like" ? likeButton : dislikeButton; + await expectOneVoteAndConfirmation(runtime, videoId, transition.value, activationTarget); + + const expectedLikes = initialCounts.likes + transition.likesDelta; + const expectedDislikes = initialCounts.dislikes + transition.dislikesDelta; + await expect(likeCount).toHaveText(String(expectedLikes)); + await expect(dislikeCount).toHaveText(String(expectedDislikes)); + await expect(likeButton).toHaveAttribute("aria-pressed", String(transition.nextState === "liked")); + await expect(dislikeButton).toHaveAttribute("aria-pressed", String(transition.nextState === "disliked")); + await expect(dislike).toHaveClass(transition.nextState === "disliked" ? /style-default-active/ : /style-text/); + await expect + .poll(() => readStoredSyntheticShortsState(runtime, videoId)) + .toBe(transition.nextState === "disliked"); + await expect(renderer.locator("[data-ryd-synthetic-shorts-dislike]")).toHaveCount(1); + const nativeReactionStatsAfter = await runtime.page.evaluate(() => + globalThis.__navigationFixture.nativeReactionSnapshot(), + ); + const expectedNativeLikeActivations = + transition.action === "like" || (transition.action === "dislike" && transition.initialState === "liked") + ? 1 + : 0; + expect(nativeReactionStatsAfter.activations - nativeReactionStatsBefore.activations).toBe( + expectedNativeLikeActivations, + ); + expect(nativeReactionStatsAfter.likeActivations - nativeReactionStatsBefore.likeActivations).toBe( + expectedNativeLikeActivations, + ); + expect(nativeReactionStatsAfter.dislikeActivations - nativeReactionStatsBefore.dislikeActivations).toBe(0); + await assertHermeticRuntime(runtime, `synthetic-shorts-${transition.initialState}-${transition.action}`); + }); + }); + } +}); + +test.describe("generated extension artifact real watch topology", () => { + test("direct load waits through a native-button skeleton and initializes once after in-place hydration", async ({}, testInfo) => { + await withExtensionArtifact( + testInfo, + { countsByVideo: COUNTS, installRoute: installOwnedLifecycleRoute }, + async (runtime) => { + await openLifecycleFixture(runtime, { delayed: true }); + await expectNoSkeletonMisbind(runtime, DEFAULT_VIDEO_IDS.A); + await hydrateAndExpect(runtime, DEFAULT_VIDEO_IDS.A, COUNTS[DEFAULT_VIDEO_IDS.A]); + expect(statsRequests(runtime, DEFAULT_VIDEO_IDS.A)).toHaveLength(1); + await assertHermeticRuntime(runtime, "direct-delayed-hydration"); + }, + ); + }); + + test("recommendation navigation reuses A-owned controls instead of Share/Download or a hidden duplicate", async ({}, testInfo) => { + await withExtensionArtifact( + testInfo, + { countsByVideo: COUNTS, installRoute: installOwnedLifecycleRoute }, + async (runtime) => { + await openLifecycleFixture(runtime); + await expectCurrentSurface(runtime, DEFAULT_VIDEO_IDS.A, COUNTS[DEFAULT_VIDEO_IDS.A]); + const outgoing = await readLifecycleSnapshot(runtime.page); + + await runtime.page.locator("#fixture-recommendation").click(); + + const skeleton = await readLifecycleSnapshot(runtime.page); + expect(skeleton.currentHostIdentity).toBe(outgoing.currentHostIdentity); + expect(skeleton.currentFixtureControlVideoId).toBe(DEFAULT_VIDEO_IDS.A); + expect(skeleton.phase).toBe("visible-outgoing"); + await expectCurrentSurface(runtime, DEFAULT_VIDEO_IDS.B, COUNTS[DEFAULT_VIDEO_IDS.B]); + expect(statsRequests(runtime, DEFAULT_VIDEO_IDS.A)).toHaveLength(1); + expect(statsRequests(runtime, DEFAULT_VIDEO_IDS.B)).toHaveLength(1); + + await expectOneVoteAndConfirmation(runtime, DEFAULT_VIDEO_IDS.B, -1); + await expect( + runtime.page.locator( + '[data-fixture-current-actions="true"] dislike-button-view-model .ytSpecButtonShapeNextButtonTextContent', + ), + ).toHaveText(String(COUNTS[DEFAULT_VIDEO_IDS.B].dislikes + 1)); + await expect( + runtime.page.locator('[data-fixture-current-actions="true"] like-button-view-model [role="text"]'), + ).toHaveText(String(COUNTS[DEFAULT_VIDEO_IDS.B].likes)); + await assertHermeticRuntime(runtime, "recommendation-reused-controls"); + }, + ); + }); + + test("SPA navigation waits for in-place native-button hydration without a second navigation event", async ({}, testInfo) => { + await withExtensionArtifact( + testInfo, + { countsByVideo: COUNTS, installRoute: installOwnedLifecycleRoute }, + async (runtime) => { + await openLifecycleFixture(runtime); + await expectCurrentSurface(runtime, DEFAULT_VIDEO_IDS.A, COUNTS[DEFAULT_VIDEO_IDS.A]); + const outgoing = await readLifecycleSnapshot(runtime.page); + + await runtime.page.evaluate((videoId) => { + globalThis.__extensionLifecycleFixture.beginNavigation(videoId, { + mode: "reuse-in-place", + pushState: true, + trigger: "delayed-in-place", + }); + }, DEFAULT_VIDEO_IDS.B); + + const skeleton = await readLifecycleSnapshot(runtime.page); + expect(skeleton.currentHostIdentity).toBe(outgoing.currentHostIdentity); + await expectNoSkeletonMisbind(runtime, DEFAULT_VIDEO_IDS.B); + await hydrateAndExpect(runtime, DEFAULT_VIDEO_IDS.B, COUNTS[DEFAULT_VIDEO_IDS.B]); + expect(statsRequests(runtime, DEFAULT_VIDEO_IDS.A)).toHaveLength(1); + expect(statsRequests(runtime, DEFAULT_VIDEO_IDS.B)).toHaveLength(1); + await assertHermeticRuntime(runtime, "spa-delayed-in-place-hydration"); + }, + ); + }); + + test("playlist host replacement recovers without a second navigation event and deduplicates listeners", async ({}, testInfo) => { + await withExtensionArtifact( + testInfo, + { countsByVideo: COUNTS, installRoute: installOwnedLifecycleRoute }, + async (runtime) => { + await openLifecycleFixture(runtime); + await expectCurrentSurface(runtime, DEFAULT_VIDEO_IDS.A, COUNTS[DEFAULT_VIDEO_IDS.A]); + + await runtime.page.locator("#fixture-playlist").click(); + + await expectNoSkeletonMisbind(runtime, DEFAULT_VIDEO_IDS.B); + await hydrateAndExpect(runtime, DEFAULT_VIDEO_IDS.B, COUNTS[DEFAULT_VIDEO_IDS.B]); + expect(statsRequests(runtime, DEFAULT_VIDEO_IDS.B)).toHaveLength(1); + + await replaceCurrentHostAndExpect(runtime, DEFAULT_VIDEO_IDS.B, COUNTS[DEFAULT_VIDEO_IDS.B]); + await replaceCurrentHostAndExpect(runtime, DEFAULT_VIDEO_IDS.B, COUNTS[DEFAULT_VIDEO_IDS.B]); + expect(statsRequests(runtime, DEFAULT_VIDEO_IDS.B)).toHaveLength(1); + + await expectOneVoteAndConfirmation(runtime, DEFAULT_VIDEO_IDS.B, -1); + await expect( + runtime.page.locator( + '[data-fixture-current-actions="true"] dislike-button-view-model .ytSpecButtonShapeNextButtonTextContent', + ), + ).toHaveText(String(COUNTS[DEFAULT_VIDEO_IDS.B].dislikes + 1)); + await expect(runtime.page.locator("#ryd-bar")).toHaveCount(1); + await assertHermeticRuntime(runtime, "playlist-replacement-and-vote"); + }, + ); + }); + + test("history back and forward repeatedly replace controls without losing or duplicating the current surface", async ({}, testInfo) => { + await withExtensionArtifact( + testInfo, + { countsByVideo: COUNTS, installRoute: installOwnedLifecycleRoute }, + async (runtime) => { + await openLifecycleFixture(runtime); + await expectCurrentSurface(runtime, DEFAULT_VIDEO_IDS.A, COUNTS[DEFAULT_VIDEO_IDS.A]); + await runtime.page.locator("#fixture-history-next").click(); + await expectNoSkeletonMisbind(runtime, DEFAULT_VIDEO_IDS.B); + await hydrateAndExpect(runtime, DEFAULT_VIDEO_IDS.B, COUNTS[DEFAULT_VIDEO_IDS.B]); + + await runtime.page.goBack(); + await expectNoSkeletonMisbind(runtime, DEFAULT_VIDEO_IDS.A, { statsRequestCount: 1 }); + await hydrateAndExpect(runtime, DEFAULT_VIDEO_IDS.A, COUNTS[DEFAULT_VIDEO_IDS.A]); + + await runtime.page.goForward(); + await expectNoSkeletonMisbind(runtime, DEFAULT_VIDEO_IDS.B, { statsRequestCount: 1 }); + await hydrateAndExpect(runtime, DEFAULT_VIDEO_IDS.B, COUNTS[DEFAULT_VIDEO_IDS.B]); + + expect(statsRequests(runtime, DEFAULT_VIDEO_IDS.A)).toHaveLength(2); + expect(statsRequests(runtime, DEFAULT_VIDEO_IDS.B)).toHaveLength(2); + await expect(runtime.page.locator(".ryd-tooltip")).toHaveCount(1); + await expect(runtime.page.locator("#ryd-bar-container")).toHaveCount(1); + await expect(runtime.page.locator("#ryd-bar")).toHaveCount(1); + await assertHermeticRuntime(runtime, "history-back-forward-replacements"); + }, + ); + }); +}); diff --git a/Extensions/e2e/extension/extension-artifact-visual.e2e.js b/Extensions/e2e/extension/extension-artifact-visual.e2e.js new file mode 100644 index 0000000..03f0cfa --- /dev/null +++ b/Extensions/e2e/extension/extension-artifact-visual.e2e.js @@ -0,0 +1,179 @@ +const fs = require("node:fs"); +const path = require("node:path"); +const { test, expect } = require("@playwright/test"); +const { VIDEO_A } = require("../../UserScript/e2e/harness"); +const { HermeticExtensionArtifactAdapter, startHermeticApiServer } = require("../hermetic-artifact-smoke"); +const { + EXTENSION_WATCH_VISUAL_PROFILE, + attachVisualFailureScreenshot, + expectNoStructuralLayoutShift, + expectWatchVisualState, + readWatchVisualContract, + waitForBarRatio, +} = require("../watch-visual-contract"); +const { registerWatchVisualContract } = require("../watch-visual-scenarios"); +const { annotateVisualEvidence, captureOptionalVisualEvidence } = require("../visual-evidence"); + +const REPOSITORY_ROOT = path.resolve(__dirname, "../../.."); +const EXTENSION_ARTIFACT = path.resolve( + process.env.RYD_EXTENSION_ARTIFACT || path.join(REPOSITORY_ROOT, "Extensions", "combined", "dist", "chrome"), +); +const BASE_COUNTS = Object.freeze({ likes: 100, dislikes: 25 }); +const VISUAL_REVIEW_DIRECTORY = path.join(REPOSITORY_ROOT, "test-results", "visual-review", "extension"); + +async function captureVisualReview(page, name) { + const outputPath = path.join(VISUAL_REVIEW_DIRECTORY, `${name}.png`); + return captureOptionalVisualEvidence({ + capture: async (screenshotPath) => { + fs.mkdirSync(VISUAL_REVIEW_DIRECTORY, { recursive: true }); + await page.locator("#top-row").screenshot({ + animations: "disabled", + path: screenshotPath, + }); + }, + outputPath, + }); +} + +function nonPreflightRecords(records, startIndex = 0) { + return records.slice(startIndex).filter((record) => record.method !== "OPTIONS"); +} + +async function installCurrentDesktopLayoutMarker(context) { + await context.addInitScript(() => { + if (!location.hostname.endsWith("youtube.com")) return; + document.addEventListener( + "DOMContentLoaded", + () => { + if (document.getElementById("comment-teaser")) return; + const marker = document.createElement("div"); + marker.id = "comment-teaser"; + marker.hidden = true; + document.body.append(marker); + }, + { once: true }, + ); + }); +} + +async function withExtensionVisualFixture(testInfo, { initialState = "neutral", viewport }, run) { + if (!fs.existsSync(path.join(EXTENSION_ARTIFACT, "manifest.json"))) { + throw new Error( + `The generated Chrome extension artifact is missing at ${EXTENSION_ARTIFACT}. ` + + "Run the extension production build first or set RYD_EXTENSION_ARTIFACT.", + ); + } + + const apiServer = await startHermeticApiServer(); + const adapter = new HermeticExtensionArtifactAdapter({ + apiServer, + artifactDirectory: EXTENSION_ARTIFACT, + backendOptions: { + countsByVideo: { [VIDEO_A]: BASE_COUNTS }, + fixture: { initialState, nativeDislikeText: false, roleAttribute: "data-fixture-role" }, + }, + }); + const workerErrors = []; + let caughtError = null; + + try { + await adapter.start(); + adapter.worker.on("console", (message) => { + if (["assert", "error", "warning"].includes(message.type())) workerErrors.push(message.text()); + }); + await installCurrentDesktopLayoutMarker(adapter.context); + await adapter.page.setViewportSize({ height: viewport.height, width: viewport.width }); + await adapter.openWatch(VIDEO_A); + await adapter.waitForWatchResult(VIDEO_A); + await expect + .poll(() => apiServer.records.filter((record) => record.pathname === "/puzzle/registration").length) + .toBe(2); + await run({ adapter, apiServer, page: adapter.page, workerErrors }); + } catch (error) { + caughtError = error; + throw error; + } finally { + if (caughtError || (testInfo.status && testInfo.status !== testInfo.expectedStatus)) { + await attachVisualFailureScreenshot(testInfo, adapter.page, `${viewport.name}-${initialState}`).catch(() => {}); + } + await adapter.close().catch(() => {}); + await apiServer.close().catch(() => {}); + } +} + +async function assertCleanExtensionRuntime({ adapter, apiServer, workerErrors }, scenarioId) { + await adapter.assertNoPageSignals(scenarioId); + expect(workerErrors, "the MV3 background worker emitted browser warnings or errors").toEqual([]); + expect(adapter.backend.blockedRequests, "the content script escaped the hermetic route set").toEqual([]); + expect(apiServer.unexpectedRequests, "the MV3 background escaped the fake protocol").toEqual([]); +} + +async function waitForOneVoteHandshake(apiServer, startIndex) { + const interactions = () => + nonPreflightRecords(apiServer.records, startIndex).filter((record) => + ["/interact/vote", "/interact/confirmVote"].includes(record.pathname), + ); + await expect.poll(() => interactions().length).toBe(2); + await new Promise((resolve) => setTimeout(resolve, 350)); + return interactions(); +} + +function expectExactVoteHandshake(interactions, transition) { + expect(interactions.map(({ method, pathname }) => ({ method, pathname }))).toEqual([ + { method: "POST", pathname: "/interact/vote" }, + { method: "POST", pathname: "/interact/confirmVote" }, + ]); + expect(interactions[0].body).toEqual({ + userId: expect.stringMatching(/^[A-Za-z0-9]{36}$/), + value: transition.value, + videoId: VIDEO_A, + }); + expect(interactions[1].body).toEqual({ + solution: expect.any(String), + userId: interactions[0].body.userId, + videoId: VIDEO_A, + }); + expect(Buffer.from(interactions[1].body.solution, "base64")).toHaveLength(4); + expect(Number.isFinite(interactions[0].respondedAt), "the vote request must receive a response").toBe(true); + expect(interactions[0].responseStatus, "the vote response must be successful").toBeGreaterThanOrEqual(200); + expect(interactions[0].responseStatus, "the vote response must be successful").toBeLessThan(300); + expect(Number.isFinite(interactions[1].respondedAt), "the confirmation request must receive a response").toBe(true); + expect(interactions[1].responseStatus, "the confirmation response must be successful").toBeGreaterThanOrEqual(200); + expect(interactions[1].responseStatus, "the confirmation response must be successful").toBeLessThan(300); + expect(interactions[1].responseBody).toBe(true); +} + +registerWatchVisualContract({ + runtime: "extension", + test, + async runScenario({ scenario, testInfo }) { + const { transition, viewport } = scenario; + await withExtensionVisualFixture(testInfo, { initialState: transition.initialState, viewport }, async (runtime) => { + const before = await readWatchVisualContract(runtime.page, EXTENSION_WATCH_VISUAL_PROFILE); + expectWatchVisualState(before, transition.initialState, BASE_COUNTS, viewport, EXTENSION_WATCH_VISUAL_PROFILE); + + const interactionStart = runtime.apiServer.records.length; + await runtime.page + .locator(`[${EXTENSION_WATCH_VISUAL_PROFILE.roleAttribute}="${transition.action}"] button`) + .click(); + const interactions = await waitForOneVoteHandshake(runtime.apiServer, interactionStart); + const nextCounts = { + likes: BASE_COUNTS.likes + transition.likesDelta, + dislikes: BASE_COUNTS.dislikes + transition.dislikesDelta, + }; + await waitForBarRatio(runtime.page, nextCounts, EXTENSION_WATCH_VISUAL_PROFILE); + + const after = await readWatchVisualContract(runtime.page, EXTENSION_WATCH_VISUAL_PROFILE); + expectWatchVisualState(after, transition.nextState, nextCounts, viewport, EXTENSION_WATCH_VISUAL_PROFILE, { + assertTooltipText: false, + }); + expectNoStructuralLayoutShift(before, after); + expectExactVoteHandshake(interactions, transition); + expect(runtime.adapter.backend.requestsFor("GET", "/votes")).toHaveLength(1); + expect(runtime.adapter.backend.requestsFor("POST", "/interact/vote")).toHaveLength(0); + expect(runtime.adapter.backend.requestsFor("POST", "/interact/confirmVote")).toHaveLength(0); + annotateVisualEvidence(testInfo, await captureVisualReview(runtime.page, scenario.id)); + await assertCleanExtensionRuntime(runtime, scenario.id); + }); + }, +}); diff --git a/Extensions/e2e/extension/extension-hide-clutter-popup.e2e.js b/Extensions/e2e/extension/extension-hide-clutter-popup.e2e.js new file mode 100644 index 0000000..4e2b1a9 --- /dev/null +++ b/Extensions/e2e/extension/extension-hide-clutter-popup.e2e.js @@ -0,0 +1,157 @@ +const fs = require("node:fs"); +const path = require("node:path"); +const { test, expect } = require("@playwright/test"); +const { HermeticExtensionArtifactAdapter, startHermeticApiServer } = require("../hermetic-artifact-smoke"); +const { annotateVisualEvidence, captureOptionalVisualEvidence } = require("../visual-evidence"); + +const REPOSITORY_ROOT = path.resolve(__dirname, "../../.."); +const EXTENSION_ARTIFACT = path.resolve( + process.env.RYD_EXTENSION_ARTIFACT || path.join(REPOSITORY_ROOT, "Extensions", "combined", "dist", "chrome"), +); +const POPUP_VISUAL_REVIEW_PATH = path.join( + REPOSITORY_ROOT, + "test-results", + "visual-review", + "extension", + "hide-clutter-popup.png", +); + +async function readStoredPreference(worker) { + return worker.evaluate(async () => (await chrome.storage.sync.get(["hideClutterButtons"])).hideClutterButtons); +} + +async function openPopup(adapter) { + const page = adapter.page; + await page.setViewportSize({ height: 720, width: 360 }); + await page.goto(`chrome-extension://${adapter.extensionId}/popup.html`, { waitUntil: "load" }); + await page.locator("#advancedToggle").click(); + await expect(page.locator("#advancedSettings")).toHaveCSS("opacity", "1"); + return page; +} + +test("built popup defaults hide-clutter off, persists it, and presents an accessible visual explanation", async ({}, testInfo) => { + const apiServer = await startHermeticApiServer(); + const adapter = new HermeticExtensionArtifactAdapter({ apiServer, artifactDirectory: EXTENSION_ARTIFACT }); + let caughtError = null; + + try { + await adapter.start(); + await adapter.worker.evaluate(() => chrome.storage.sync.remove(["hideClutterButtons"])); + await adapter.context.route("https://fonts.googleapis.com/**", (route) => + route.fulfill({ body: "", contentType: "text/css; charset=utf-8", status: 200 }), + ); + await adapter.context.route("https://fonts.gstatic.com/**", (route) => + route.fulfill({ body: "", contentType: "font/woff2", status: 200 }), + ); + await adapter.context.route("https://raw.githubusercontent.com/**", (route) => + route.fulfill({ + body: JSON.stringify({ version: "4.0.5" }), + contentType: "application/json; charset=utf-8", + status: 200, + }), + ); + + const page = await openPopup(adapter); + const checkbox = page.locator("#hide_clutter_buttons"); + const settingLabel = page.locator('label[for="hide_clutter_buttons"].setting-row-label'); + const help = page.locator("#hide_clutter_buttons_help"); + const tooltip = page.locator("#hide_clutter_buttons_tooltip"); + const before = tooltip.locator('[data-hide-clutter-example="before"]'); + const after = tooltip.locator('[data-hide-clutter-example="after"]'); + + await expect(checkbox).not.toBeChecked(); + await expect.poll(() => readStoredPreference(adapter.worker)).toBe(false); + await expect(help).toHaveAttribute("aria-describedby", "hide_clutter_buttons_tooltip"); + expect(await help.evaluate((element) => element.tabIndex)).toBe(0); + + await help.hover(); + await expect(tooltip).toBeVisible(); + await expect(before).toBeVisible(); + await expect(after).toBeVisible(); + await expect + .poll(() => + page.evaluate(() => + [...document.querySelectorAll("[data-hide-clutter-example]")].map((image) => ({ + complete: image.complete, + naturalHeight: image.naturalHeight, + naturalWidth: image.naturalWidth, + })), + ), + ) + .toEqual([ + { complete: true, naturalHeight: 128, naturalWidth: 294 }, + { complete: true, naturalHeight: 108, naturalWidth: 294 }, + ]); + + const renderedExamples = await page.evaluate(() => + [...document.querySelectorAll("[data-hide-clutter-example]")].map((image) => { + const box = image.getBoundingClientRect(); + const canvas = document.createElement("canvas"); + canvas.width = image.naturalWidth; + canvas.height = image.naturalHeight; + const context = canvas.getContext("2d", { willReadFrequently: true }); + context.drawImage(image, 0, 0); + const pixels = context.getImageData(10, 38, 155, 46).data; + let reactionInkPixels = 0; + for (let index = 0; index < pixels.length; index += 4) { + if (Math.max(pixels[index], pixels[index + 1], pixels[index + 2]) >= 30 && pixels[index + 3] > 0) { + reactionInkPixels += 1; + } + } + return { + naturalWidth: image.naturalWidth, + reactionInkPixels, + renderedWidth: box.width, + scale: box.width / image.naturalWidth, + }; + }), + ); + for (const example of renderedExamples) { + expect(example.renderedWidth).toBeGreaterThanOrEqual(220); + expect(example.scale).toBeGreaterThanOrEqual(0.75); + expect(example.reactionInkPixels).toBeGreaterThan(2_500); + } + + const visualEvidence = await captureOptionalVisualEvidence({ + capture: async (screenshotPath) => { + fs.mkdirSync(path.dirname(screenshotPath), { recursive: true }); + await page.screenshot({ animations: "disabled", path: screenshotPath }); + }, + outputPath: POPUP_VISUAL_REVIEW_PATH, + }); + annotateVisualEvidence(testInfo, visualEvidence); + + const popoverBox = await tooltip.boundingBox(); + expect(popoverBox).not.toBeNull(); + expect(popoverBox.x).toBeGreaterThanOrEqual(0); + expect(popoverBox.x + popoverBox.width).toBeLessThanOrEqual(360); + + await page.mouse.move(1, 1); + await help.focus(); + await expect(help).toBeFocused(); + await expect(tooltip).toBeVisible(); + + await settingLabel.click(); + await expect(checkbox).toBeChecked(); + await expect.poll(() => readStoredPreference(adapter.worker)).toBe(true); + + await page.reload({ waitUntil: "load" }); + await page.locator("#advancedToggle").click(); + await expect(page.locator("#hide_clutter_buttons")).toBeChecked(); + await page.locator('label[for="hide_clutter_buttons"].setting-row-label').click(); + await expect(page.locator("#hide_clutter_buttons")).not.toBeChecked(); + await expect.poll(() => readStoredPreference(adapter.worker)).toBe(false); + await adapter.pageSignals.assertClean(testInfo.title); + } catch (error) { + caughtError = error; + throw error; + } finally { + if (caughtError && adapter.context) { + const screenshotPath = testInfo.outputPath("hide-clutter-popup-failure.png"); + await adapter.page.screenshot({ fullPage: true, path: screenshotPath }).catch(() => {}); + await testInfo.attach("hide-clutter-popup", { contentType: "image/png", path: screenshotPath }).catch(() => {}); + } + await adapter.close().catch(() => {}); + await apiServer.close().catch(() => {}); + } +}); diff --git a/Extensions/e2e/extension/extension-initialization-races.e2e.js b/Extensions/e2e/extension/extension-initialization-races.e2e.js new file mode 100644 index 0000000..f22e630 --- /dev/null +++ b/Extensions/e2e/extension/extension-initialization-races.e2e.js @@ -0,0 +1,307 @@ +const path = require("node:path"); +const { test, expect } = require("@playwright/test"); +const { VIDEO_A, VIDEO_B } = require("../../UserScript/e2e/harness"); +const { + HermeticExtensionArtifactAdapter, + SPA_COUNTS, + isArtifactVoteHandshakeValid, + readArtifactVoteHandshake, + startHermeticApiServer, +} = require("../hermetic-artifact-smoke"); +const { assertInvariantContinuously } = require("../continuous-invariants"); + +const EXTENSION_ARTIFACT = path.resolve( + process.env.RYD_EXTENSION_ARTIFACT || path.join(__dirname, "../../combined/dist/chrome"), +); + +async function readCurrentWatch(adapter) { + return adapter.page.evaluate(() => { + const rendered = (element) => { + if (!element?.isConnected || element.closest("[hidden], [aria-hidden='true'], [inert]")) return false; + const box = element.getBoundingClientRect(); + const style = getComputedStyle(element); + return box.width > 0 && box.height > 0 && style.display !== "none" && style.visibility !== "hidden"; + }; + const watch = [...document.querySelectorAll("ytd-watch-flexy, ytd-watch-grid")].find(rendered); + const actions = watch && [...watch.querySelectorAll("#top-level-buttons-computed")].find(rendered); + const dislike = actions?.querySelector("dislike-button-view-model"); + const nativeButton = dislike?.querySelector("button"); + const count = dislike?.querySelector( + "#text, [role='text'], .ytSpecButtonShapeNextButtonTextContent, .yt-spec-button-shape-next__button-text-content", + ); + return { + count: count?.textContent?.trim() ?? "", + currentVideoId: watch?.getAttribute("video-id") ?? null, + nativeDislikePressed: nativeButton?.getAttribute("aria-pressed") ?? null, + nativeDislikeVisible: rendered(nativeButton), + ownedVisibleBarCount: [...(actions?.querySelectorAll("#ryd-bar") ?? [])].filter(rendered).length, + urlVideoId: new URL(location.href).searchParams.get("v"), + }; + }); +} + +function voteDataRequests(adapter) { + return adapter.backend.requestsFor("GET", "/votes"); +} + +async function withExtension(testInfo, run, backendOptions = {}) { + const apiServer = await startHermeticApiServer(); + const adapter = new HermeticExtensionArtifactAdapter({ + apiServer, + artifactDirectory: EXTENSION_ARTIFACT, + backendOptions, + }); + const heldRequests = []; + const deferVotes = () => { + const held = adapter.deferNextStatsRequest(); + const pending = { held, seen: false }; + void held.seen.then(() => { + pending.seen = true; + }); + heldRequests.push(pending); + return held; + }; + try { + await adapter.start(); + adapter.page.setDefaultTimeout(5_000); + await run({ adapter, apiServer, deferVotes }); + expect(adapter.backend.blockedRequests).toEqual([]); + expect(apiServer.unexpectedRequests).toEqual([]); + } catch (error) { + if (adapter.page && !adapter.page.isClosed()) { + await testInfo.attach("initialization-race", { + body: await adapter.page.screenshot(), + contentType: "image/png", + }); + await testInfo.attach("initialization-race-diagnostics", { + body: JSON.stringify({ + watch: await readCurrentWatch(adapter), + requests: voteDataRequests(adapter).map(({ at, query, respondedAt, responseStatus }) => ({ + at, + query, + respondedAt, + responseStatus, + })), + interactions: apiServer.records + .filter(({ pathname }) => pathname.startsWith("/interact/")) + .map(({ pathname, body, respondedAt, responseStatus }) => ({ + pathname, + videoId: body?.videoId, + value: body?.value, + respondedAt, + responseStatus, + })), + signals: adapter.pageSignals.snapshot(), + }), + contentType: "application/json", + }); + } + throw error; + } finally { + // A navigation may cancel the browser request while its fake response is held. + // Unblock the route during teardown without requiring that cancelled response to finish. + for (const { held, seen } of heldRequests) { + if (!held.released && seen) { + held.release({ body: { dislikes: 999, likes: 1 } }); + } + } + await adapter.close(); + await apiServer.close(); + } +} + +async function expectWatchRemains(adapter, videoId, count) { + return assertInvariantContinuously({ + durationMs: 1_000, + label: `Watch ${videoId} retains its current response`, + read: () => readCurrentWatch(adapter), + isValid: (snapshot) => + snapshot.urlVideoId === videoId && + snapshot.currentVideoId === videoId && + snapshot.count === String(count) && + snapshot.nativeDislikeVisible && + snapshot.ownedVisibleBarCount === 1, + }); +} + +test("clearing a native Dislike clicked before initial counts settle submits a neutral vote", async ({}, testInfo) => { + await withExtension(testInfo, async ({ adapter, apiServer, deferVotes }) => { + const held = deferVotes(); + await adapter.openWatch(VIDEO_A); + await expect.poll(() => voteDataRequests(adapter).length).toBe(1); + const dislike = adapter.page.locator("ytd-watch-flexy #top-row dislike-button-view-model button"); + await dislike.click(); + expect(await readCurrentWatch(adapter)).toMatchObject({ nativeDislikePressed: "true", ownedVisibleBarCount: 0 }); + expect(apiServer.records.filter(({ pathname }) => pathname.startsWith("/interact/"))).toEqual([]); + + held.release({ body: { dislikes: 25, likes: 100, rating: 4.5 } }); + await adapter.waitForWatchResult(VIDEO_A); + expect(await readCurrentWatch(adapter)).toMatchObject({ nativeDislikePressed: "true" }); + + const interactionStart = apiServer.records.length; + await dislike.click(); + expect(await readCurrentWatch(adapter)).toMatchObject({ nativeDislikePressed: "false" }); + const readHandshake = () => readArtifactVoteHandshake(apiServer.records, interactionStart, VIDEO_A, 0); + await expect.poll(() => isArtifactVoteHandshakeValid(readHandshake())).toBe(true); + await assertInvariantContinuously({ + durationMs: 1_000, + label: "Clearing the pending native Dislike submits exactly one neutral vote and confirmation", + read: readHandshake, + isValid: isArtifactVoteHandshakeValid, + }); + expect(voteDataRequests(adapter)).toHaveLength(1); + await adapter.assertNoPageSignals("native-reaction-during-initial-votes"); + }); +}); + +test("destination initializes while the initial outgoing votes request remains pending", async ({}, testInfo) => { + await withExtension( + testInfo, + async ({ adapter, deferVotes }) => { + const held = deferVotes(); + await adapter.openSpaWatch(VIDEO_A); + await expect.poll(() => voteDataRequests(adapter).length).toBe(1); + await adapter.navigateSpaWatchWhilePending(VIDEO_A, VIDEO_B); + + const result = await adapter.waitForWatchResult(VIDEO_B); + expect(result.count).toBe(String(SPA_COUNTS[VIDEO_B].dislikes)); + expect(held.released).toBe(false); + expect(voteDataRequests(adapter).map(({ query }) => query.videoId)).toEqual([VIDEO_A, VIDEO_B]); + await expectWatchRemains(adapter, VIDEO_B, SPA_COUNTS[VIDEO_B].dislikes); + + held.release({ body: { dislikes: 999, likes: 1, rating: 1 } }); + await expectWatchRemains(adapter, VIDEO_B, SPA_COUNTS[VIDEO_B].dislikes); + expect(voteDataRequests(adapter)).toHaveLength(2); + await adapter.assertNoPageSignals("pending-initial-votes-do-not-block-destination"); + }, + { countsByVideo: SPA_COUNTS }, + ); +}); + +test("the watchdog initializes B with A pending when navigation emits no events", async ({}, testInfo) => { + await withExtension( + testInfo, + async ({ adapter, deferVotes }) => { + const held = deferVotes(); + await adapter.openSpaWatch(VIDEO_A); + await expect.poll(() => voteDataRequests(adapter).length).toBe(1); + await adapter.page.evaluate((videoId) => { + globalThis.__navigationFixture.navigate("watch", videoId, { dispatchEvent: false }); + }, VIDEO_B); + + const result = await adapter.waitForWatchResult(VIDEO_B); + expect(result.count).toBe(String(SPA_COUNTS[VIDEO_B].dislikes)); + expect(held.released).toBe(false); + expect(voteDataRequests(adapter).map(({ query }) => query.videoId)).toEqual([VIDEO_A, VIDEO_B]); + await expectWatchRemains(adapter, VIDEO_B, SPA_COUNTS[VIDEO_B].dislikes); + + held.release({ body: { dislikes: 999, likes: 1, rating: 1 } }); + await expectWatchRemains(adapter, VIDEO_B, SPA_COUNTS[VIDEO_B].dislikes); + expect(voteDataRequests(adapter)).toHaveLength(2); + await adapter.assertNoPageSignals("watchdog-recovers-pending-initial-votes-without-navigation-events"); + }, + { countsByVideo: SPA_COUNTS }, + ); +}); + +test("navigation start does not restart outgoing A while the URL change is delayed", async ({}, testInfo) => { + await withExtension( + testInfo, + async ({ adapter, deferVotes }) => { + const held = deferVotes(); + await adapter.openSpaWatch(VIDEO_A); + await expect.poll(() => voteDataRequests(adapter).length).toBe(1); + await adapter.page.evaluate(() => { + document.dispatchEvent(new Event("yt-navigate-start", { bubbles: true })); + }); + + await assertInvariantContinuously({ + durationMs: 750, + label: "Navigation start suppresses outgoing requests before the URL changes", + read: async () => ({ watch: await readCurrentWatch(adapter), requestCount: voteDataRequests(adapter).length }), + isValid: ({ watch, requestCount }) => + requestCount === 1 && + watch.urlVideoId === VIDEO_A && + watch.currentVideoId === VIDEO_A && + watch.ownedVisibleBarCount === 0, + }); + await adapter.page.evaluate((videoId) => { + globalThis.__navigationFixture.navigate("watch", videoId); + }, VIDEO_B); + + const result = await adapter.waitForWatchResult(VIDEO_B); + expect(result.count).toBe(String(SPA_COUNTS[VIDEO_B].dislikes)); + expect(held.released).toBe(false); + expect(voteDataRequests(adapter).map(({ query }) => query.videoId)).toEqual([VIDEO_A, VIDEO_B]); + held.release({ body: { dislikes: 999, likes: 1, rating: 1 } }); + await expectWatchRemains(adapter, VIDEO_B, SPA_COUNTS[VIDEO_B].dislikes); + expect(voteDataRequests(adapter)).toHaveLength(2); + await adapter.assertNoPageSignals("navigation-start-does-not-restart-outgoing-initial-votes"); + }, + { countsByVideo: SPA_COUNTS }, + ); +}); + +test("returning to A ignores the first A response still pending from before B", async ({}, testInfo) => { + await withExtension( + testInfo, + async ({ adapter, deferVotes }) => { + const firstA = deferVotes(); + await adapter.openSpaWatch(VIDEO_A); + await expect.poll(() => voteDataRequests(adapter).length).toBe(1); + await adapter.navigateSpaWatchWhilePending(VIDEO_A, VIDEO_B); + await adapter.waitForWatchResult(VIDEO_B); + + await adapter.page.evaluate((videoId) => { + document.dispatchEvent(new Event("yt-navigate-start", { bubbles: true })); + globalThis.__navigationFixture.navigate("watch", videoId); + }, VIDEO_A); + const result = await adapter.waitForWatchResult(VIDEO_A); + expect(result.count).toBe(String(SPA_COUNTS[VIDEO_A].dislikes)); + expect(firstA.released).toBe(false); + expect(voteDataRequests(adapter).map(({ query }) => query.videoId)).toEqual([VIDEO_A, VIDEO_B, VIDEO_A]); + + firstA.release({ body: { dislikes: 999, likes: 1, rating: 1 } }); + await expectWatchRemains(adapter, VIDEO_A, SPA_COUNTS[VIDEO_A].dislikes); + expect(voteDataRequests(adapter)).toHaveLength(3); + await adapter.assertNoPageSignals("first-A-response-cannot-overwrite-returned-A"); + }, + { countsByVideo: SPA_COUNTS }, + ); +}); + +test("HTTP 503 remains unavailable without automatic retries until a manual reload", async ({}, testInfo) => { + await withExtension(testInfo, async ({ adapter }) => { + adapter.backend.enqueue("GET", "/votes", { status: 503, body: { error: "test unavailable" } }); + await adapter.openWatch(VIDEO_A); + await expect.poll(async () => (await readCurrentWatch(adapter)).count).toBe("Temporarily Unavailable"); + expect(voteDataRequests(adapter)[0].responseStatus).toBe(503); + + await assertInvariantContinuously({ + durationMs: 2_500, + label: "HTTP 503 does not automatically retry across initialization watchdog cycles", + read: async () => ({ watch: await readCurrentWatch(adapter), requestCount: voteDataRequests(adapter).length }), + isValid: ({ watch, requestCount }) => + requestCount === 1 && + watch.count === "Temporarily Unavailable" && + watch.nativeDislikeVisible && + watch.ownedVisibleBarCount === 0, + }); + + await adapter.page.reload(); + const result = await adapter.waitForWatchResult(VIDEO_A); + expect(result.count).toBe("25"); + expect(voteDataRequests(adapter).map(({ responseStatus }) => responseStatus)).toEqual([503, 200]); + + const { page } = adapter.pageSignals.snapshot(); + expect(page.pageErrors).toEqual([]); + expect(page.unhandledRejections).toEqual([]); + expect(page.consoleErrors).toHaveLength(1); + expect(page.consoleErrors[0]).toMatchObject({ + location: { url: voteDataRequests(adapter)[0].url }, + text: "Failed to load resource: the server responded with a status of 503 (Service Unavailable)", + type: "error", + }); + await adapter.workerSignals.assertClean("expected-503-without-retries"); + }); +}); diff --git a/Extensions/e2e/extension/extension-mobile-artifact.e2e.js b/Extensions/e2e/extension/extension-mobile-artifact.e2e.js new file mode 100644 index 0000000..0d68998 --- /dev/null +++ b/Extensions/e2e/extension/extension-mobile-artifact.e2e.js @@ -0,0 +1,299 @@ +const fs = require("node:fs"); +const path = require("node:path"); +const { devices, test, expect } = require("@playwright/test"); +const { + HermeticExtensionArtifactAdapter, + isArtifactVoteHandshakeValid, + readArtifactVoteHandshake, + startHermeticApiServer, +} = require("../hermetic-artifact-smoke"); +const { VIDEO_A, VIDEO_B, openNavigationFixture, openWatchFixture } = require("../../UserScript/e2e/harness"); +const { LIVE_RUNTIME_PROFILES } = require("../live-runtime-adapter"); + +const REPOSITORY_ROOT = path.resolve(__dirname, "../../.."); +const EXTENSION_ARTIFACT = path.resolve( + process.env.RYD_EXTENSION_ARTIFACT || path.join(REPOSITORY_ROOT, "Extensions", "combined", "dist", "chrome"), +); +const MOBILE_VIEWPORT = Object.freeze({ height: 844, width: 390 }); +const MOBILE_CONTEXT_OPTIONS = Object.freeze({ + deviceScaleFactor: 3, + hasTouch: true, + isMobile: true, + screen: MOBILE_VIEWPORT, + userAgent: devices["Pixel 5"].userAgent, + viewport: MOBILE_VIEWPORT, +}); +const COUNTS = Object.freeze({ + [VIDEO_A]: Object.freeze({ dislikes: 11, likes: 89 }), + [VIDEO_B]: Object.freeze({ dislikes: 35, likes: 65 }), +}); +const CONSOLE_FAILURE_TYPES = new Set(["assert", "error", "warning"]); + +function testFailed(testInfo, caughtError) { + return Boolean(caughtError) || (testInfo.status && testInfo.status !== testInfo.expectedStatus); +} + +async function attachFailureEvidence(runtime, testInfo) { + const diagnostics = { + backgroundRequests: runtime.apiServer.records.map(({ body, method, pathname, query, responseStatus }) => ({ + body, + method, + pathname, + query, + responseStatus, + })), + blockedRequests: runtime.adapter.backend?.blockedRequests ?? [], + pageSignals: runtime.adapter.pageSignals?.snapshot() ?? null, + routedRequests: + runtime.adapter.backend?.requests?.map(({ body, method, pathname, query, responseStatus }) => ({ + body, + method, + pathname, + query, + responseStatus, + })) ?? [], + unexpectedBackgroundRequests: runtime.apiServer.unexpectedRequests, + workerConsoleFailures: runtime.workerConsoleFailures, + }; + await testInfo.attach("mobile-extension-diagnostics", { + body: Buffer.from(JSON.stringify(diagnostics, null, 2)), + contentType: "application/json", + }); + + if (!runtime.page || runtime.page.isClosed()) return; + const screenshotPath = testInfo.outputPath("mobile-extension-failure.png"); + await runtime.page.screenshot({ fullPage: true, path: screenshotPath }).catch(() => {}); + if (fs.existsSync(screenshotPath)) { + await testInfo.attach("mobile-extension-page", { contentType: "image/png", path: screenshotPath }); + } +} + +async function withMobileExtension(testInfo, run) { + if (!fs.existsSync(path.join(EXTENSION_ARTIFACT, "manifest.json"))) { + throw new Error( + `The generated Chrome extension artifact is missing at ${EXTENSION_ARTIFACT}. ` + + "Run the extension production build first or set RYD_EXTENSION_ARTIFACT.", + ); + } + + const apiServer = await startHermeticApiServer(); + const adapter = new HermeticExtensionArtifactAdapter({ + apiServer, + artifactDirectory: EXTENSION_ARTIFACT, + backendOptions: { countsByVideo: COUNTS }, + contextOptions: MOBILE_CONTEXT_OPTIONS, + }); + const runtime = { adapter, apiServer, caughtError: null, page: null, workerConsoleFailures: [] }; + + try { + await adapter.start(); + runtime.page = adapter.page; + adapter.worker.on("console", (message) => { + if (CONSOLE_FAILURE_TYPES.has(message.type())) { + runtime.workerConsoleFailures.push({ text: message.text(), type: message.type() }); + } + }); + await adapter.context.addInitScript(() => { + globalThis.__rydMobileActivationSignals = { click: 0, touchend: 0, touchstart: 0 }; + for (const eventName of ["click", "touchend", "touchstart"]) { + document.addEventListener( + eventName, + (event) => { + if (event.target instanceof Element && event.target.closest("dislike-button-view-model")) { + globalThis.__rydMobileActivationSignals[eventName] += 1; + } + }, + true, + ); + } + }); + const mobileRuntime = await runtime.page.evaluate(() => ({ + coarsePointer: matchMedia("(pointer: coarse)").matches, + maxTouchPoints: navigator.maxTouchPoints, + userAgent: navigator.userAgent, + })); + expect(mobileRuntime.userAgent).toMatch(/Android/i); + expect(mobileRuntime.maxTouchPoints).toBeGreaterThan(0); + expect(mobileRuntime.coarsePointer).toBe(true); + await run(runtime); + await assertHermeticRuntime(runtime, testInfo.title); + } catch (error) { + runtime.caughtError = error; + throw error; + } finally { + if (adapter.context && testFailed(testInfo, runtime.caughtError)) { + await attachFailureEvidence(runtime, testInfo).catch(() => {}); + } + await adapter.close().catch(() => {}); + await apiServer.close().catch(() => {}); + } +} + +async function assertHermeticRuntime(runtime, scenarioId) { + await runtime.adapter.pageSignals.assertClean(scenarioId); + expect(runtime.workerConsoleFailures, "the MV3 background worker emitted a console failure").toEqual([]); + expect(runtime.adapter.backend.blockedRequests, "the page escaped the hermetic route set").toEqual([]); + expect(runtime.apiServer.unexpectedRequests, "the background worker escaped the fake protocol").toEqual([]); +} + +async function expectVisibleReactionPair(root, { dislikes, likes }) { + const like = root.locator(":scope > like-button-view-model"); + const dislike = root.locator(":scope > dislike-button-view-model"); + const controls = root.locator(":scope > like-button-view-model, :scope > dislike-button-view-model"); + const buttons = controls.locator("button"); + + await expect(root).toHaveCount(1); + await expect(root).toBeVisible(); + await expect(controls, "the mobile reaction surface must contain exactly Like and Dislike").toHaveCount(2); + await expect(buttons, "each mobile reaction control must expose one activation button").toHaveCount(2); + await expect(like).toBeVisible(); + await expect(dislike).toBeVisible(); + await expect(like.locator("button")).toBeVisible(); + await expect(dislike.locator("button")).toBeVisible(); + await expect(like.locator("#text, [role='text']").first()).toHaveText(String(likes)); + await expect(dislike.locator("#text, [role='text']").first()).toHaveText(String(dislikes)); + await expect(like.locator("button")).toHaveAttribute("aria-label", /like/i); + await expect(dislike.locator("button")).toHaveAttribute("aria-label", /dislike/i); + + const boxes = await buttons.evaluateAll((elements) => + elements.map((element) => { + const box = element.getBoundingClientRect(); + return { height: box.height, width: box.width }; + }), + ); + expect(boxes).toHaveLength(2); + for (const box of boxes) { + expect(box.height).toBeGreaterThan(0); + expect(box.width).toBeGreaterThan(0); + } +} + +function statsRequests(runtime, videoId) { + return runtime.adapter.backend.requestsFor("GET", "/votes").filter((request) => request.query.videoId === videoId); +} + +function interactionRecords(runtime, startIndex) { + return runtime.apiServer.records + .slice(startIndex) + .filter( + (record) => record.method === "POST" && ["/interact/vote", "/interact/confirmVote"].includes(record.pathname), + ); +} + +test.describe("generated extension artifact on the mobile YouTube hostname", () => { + test("direct mobile Watch renders the complete reaction surface at compact width", async ({}, testInfo) => { + await withMobileExtension(testInfo, async (runtime) => { + await openWatchFixture(runtime.page, VIDEO_A, { hostname: "m.youtube.com" }); + + await expect(runtime.page).toHaveURL( + (url) => url.hostname === "m.youtube.com" && url.searchParams.get("v") === VIDEO_A, + ); + const mobileActionBar = runtime.page.locator('.slim-video-action-bar-actions[data-fixture-shell="mobile"]'); + await expect(mobileActionBar).toBeVisible(); + await expectVisibleReactionPair(mobileActionBar.locator(":scope > .segmented-buttons"), COUNTS[VIDEO_A]); + expect(statsRequests(runtime, VIDEO_A)).toHaveLength(1); + await expect(runtime.page.locator(".ryd-tooltip, #ryd-bar-container, #ryd-bar")).toHaveCount(0); + expect(interactionRecords(runtime, 0)).toHaveLength(0); + }); + }); + + test("native-dependent mobile Shorts compatibility binds B and submits one dislike handshake", async ({}, testInfo) => { + await withMobileExtension(testInfo, async (runtime) => { + await openNavigationFixture(runtime.page, { + hostname: "m.youtube.com", + pageKind: "shorts", + videoId: VIDEO_A, + }); + + await expect(runtime.page).toHaveURL( + (url) => url.hostname === "m.youtube.com" && url.pathname === `/shorts/${VIDEO_A}`, + ); + expect(LIVE_RUNTIME_PROFILES.extension.capabilities.shortsControlModelBySurface).toEqual({ + desktop: "synthetic-owned", + mobile: "native-youtube-required", + }); + expect( + await runtime.page.evaluate(() => globalThis.__navigationFixtureBaseline.renderedMobileShortsNativeDislikes), + "this scenario is native-host compatibility, not absent-native mobile coverage", + ).toBe(1); + const rendererA = runtime.page.locator(`ytm-reel-video-renderer[video-id="${VIDEO_A}"]`); + await expect(rendererA).toHaveAttribute("is-active", ""); + await expect(rendererA).toBeVisible(); + await expectVisibleReactionPair(rendererA.locator("ytm-like-button-renderer"), COUNTS[VIDEO_A]); + await expect(rendererA.locator("[data-ryd-synthetic-shorts-dislike]")).toHaveCount(0); + expect(statsRequests(runtime, VIDEO_A)).toHaveLength(1); + + await runtime.page.locator("#short-next").click(); + + await expect(runtime.page).toHaveURL( + (url) => url.hostname === "m.youtube.com" && url.pathname === `/shorts/${VIDEO_B}`, + ); + const activeB = runtime.page.locator(`ytm-reel-video-renderer[video-id="${VIDEO_B}"][is-active]`); + await expect(runtime.page.locator("ytm-reel-video-renderer[is-active]")).toHaveCount(1); + await expect(runtime.page.locator("ytm-reel-video-renderer:visible")).toHaveCount(1); + await expect(rendererA).not.toHaveAttribute("is-active", ""); + await expect(rendererA).toBeHidden(); + await expect(activeB).toBeVisible(); + const controlsB = activeB.locator("ytm-like-button-renderer"); + await expectVisibleReactionPair(controlsB, COUNTS[VIDEO_B]); + await expect(activeB.locator("[data-ryd-synthetic-shorts-dislike]")).toHaveCount(0); + expect(statsRequests(runtime, VIDEO_A)).toHaveLength(1); + expect(statsRequests(runtime, VIDEO_B)).toHaveLength(1); + + const interactionStartIndex = runtime.apiServer.records.length; + expect(interactionRecords(runtime, 0)).toHaveLength(0); + const dislikeButton = controlsB.locator(":scope > dislike-button-view-model button"); + const dislikeBox = await dislikeButton.boundingBox(); + expect(dislikeBox, "the active mobile Shorts Dislike button must have a tappable box").not.toBeNull(); + await runtime.page.touchscreen.tap(dislikeBox.x + dislikeBox.width / 2, dislikeBox.y + dislikeBox.height / 2); + await expect + .poll(() => + isArtifactVoteHandshakeValid( + readArtifactVoteHandshake(runtime.apiServer.records, interactionStartIndex, VIDEO_B, -1), + ), + ) + .toBe(true); + await runtime.page.waitForTimeout(400); + + const handshake = readArtifactVoteHandshake(runtime.apiServer.records, interactionStartIndex, VIDEO_B, -1); + expect(handshake).toMatchObject({ + confirmationCount: 1, + expectedValue: -1, + expectedVideoId: VIDEO_B, + interactionCount: 2, + interactionPaths: ["/interact/vote", "/interact/confirmVote"], + voteCount: 1, + }); + expect(handshake.vote.body).toEqual({ + userId: expect.stringMatching(/^[A-Za-z0-9]{36}$/), + value: -1, + videoId: VIDEO_B, + }); + expect(handshake.vote).toMatchObject({ responded: true, responseStatus: 200 }); + expect(handshake.confirmation.body).toEqual({ + solution: expect.any(String), + userId: handshake.vote.body.userId, + videoId: VIDEO_B, + }); + expect(Buffer.from(handshake.confirmation.body.solution, "base64")).toHaveLength(4); + expect(handshake.confirmation).toMatchObject({ responded: true, responseBody: true, responseStatus: 200 }); + expect(interactionRecords(runtime, interactionStartIndex)).toHaveLength(2); + expect(interactionRecords(runtime, 0)).toHaveLength(2); + await expect + .poll(() => runtime.page.evaluate(() => ({ ...globalThis.__rydMobileActivationSignals }))) + .toEqual({ click: 1, touchend: 1, touchstart: 1 }); + expect(runtime.adapter.backend.requestsFor("POST", "/interact/vote")).toHaveLength(0); + expect(runtime.adapter.backend.requestsFor("POST", "/interact/confirmVote")).toHaveLength(0); + + await expectVisibleReactionPair(controlsB, { + dislikes: COUNTS[VIDEO_B].dislikes + 1, + likes: COUNTS[VIDEO_B].likes, + }); + await expect(rendererA.locator('[data-fixture-role="dislike"] #text')).toHaveText( + String(COUNTS[VIDEO_A].dislikes), + ); + expect(statsRequests(runtime, VIDEO_A)).toHaveLength(1); + expect(statsRequests(runtime, VIDEO_B)).toHaveLength(1); + }); + }); +}); diff --git a/Extensions/e2e/extension/extension-navigation-fixture.js b/Extensions/e2e/extension/extension-navigation-fixture.js new file mode 100644 index 0000000..eee96c0 --- /dev/null +++ b/Extensions/e2e/extension/extension-navigation-fixture.js @@ -0,0 +1,493 @@ +const DEFAULT_VIDEO_IDS = Object.freeze({ + A: "abcdefghijk", + B: "zyxwvutsrqp", + C: "mnopqrstuvw", +}); + +function createExtensionNavigationFixture({ initialVideoId = DEFAULT_VIDEO_IDS.A, likesByVideo = {} } = {}) { + const configuration = JSON.stringify({ initialVideoId, likesByVideo, videoIds: DEFAULT_VIDEO_IDS }); + + return ` + + + + + Extension navigation lifecycle fixture + + + +
    + + +`; +} + +module.exports = { + DEFAULT_VIDEO_IDS, + createExtensionNavigationFixture, +}; diff --git a/Extensions/e2e/extension/extension-render-oracle.e2e.js b/Extensions/e2e/extension/extension-render-oracle.e2e.js new file mode 100644 index 0000000..09cbf60 --- /dev/null +++ b/Extensions/e2e/extension/extension-render-oracle.e2e.js @@ -0,0 +1,62 @@ +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("../../UserScript/e2e/harness"); +const { HermeticExtensionArtifactAdapter, startHermeticApiServer } = require("../hermetic-artifact-smoke"); + +const REPOSITORY_ROOT = path.resolve(__dirname, "../../.."); +const EXTENSION_ARTIFACT = path.resolve( + process.env.RYD_EXTENSION_ARTIFACT || path.join(REPOSITORY_ROOT, "Extensions", "combined", "dist", "chrome"), +); +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-render-oracle-/); + fs.rmSync(resolved, { force: true, recursive: true }); +} + +test("the render oracle rejects a generated extension whose content script does nothing", async () => { + expect( + fs.existsSync(path.join(EXTENSION_ARTIFACT, "manifest.json")), + `The generated Chrome extension artifact is missing at ${EXTENSION_ARTIFACT}`, + ).toBe(true); + + const oracleRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ryd-render-oracle-")); + const brokenArtifact = path.join(oracleRoot, "extension"); + fs.cpSync(EXTENSION_ARTIFACT, brokenArtifact, { recursive: true }); + fs.writeFileSync( + path.join(brokenArtifact, "ryd.content-script.js"), + `void ${JSON.stringify(PRODUCTION_API_ORIGIN)};`, + ); + + const apiServer = await startHermeticApiServer(); + const adapter = new HermeticExtensionArtifactAdapter({ + apiServer, + artifactDirectory: brokenArtifact, + backendOptions: { fixture: { nativeDislikeText: false, roleAttribute: "data-fixture-role" } }, + }); + + 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("#ryd-bar-container")).toHaveCount(0); + await expect(adapter.page.locator("#ryd-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(() => {}); + await apiServer.close().catch(() => {}); + removeOwnedOracleDirectory(oracleRoot); + } +}); diff --git a/Extensions/e2e/extension/extension-runtime-resilience.e2e.js b/Extensions/e2e/extension/extension-runtime-resilience.e2e.js new file mode 100644 index 0000000..a197751 --- /dev/null +++ b/Extensions/e2e/extension/extension-runtime-resilience.e2e.js @@ -0,0 +1,30 @@ +const { test } = require("@playwright/test"); +const { HermeticExtensionArtifactAdapter, SPA_COUNTS, startHermeticApiServer } = require("../hermetic-artifact-smoke"); +const { + registerRuntimeResilienceContractScenarios, + runRuntimeResilienceContract, +} = require("../runtime-resilience-contract"); + +registerRuntimeResilienceContractScenarios({ + runtimeName: "extension", + register: ({ scenario, title }) => { + test(title, async () => { + const apiServer = await startHermeticApiServer(); + try { + await runRuntimeResilienceContract({ + scenario, + createAdapter: ({ gateMode }) => + new HermeticExtensionArtifactAdapter({ + apiServer, + backendOptions: { + countsByVideo: SPA_COUNTS, + fixture: { signedIn: gateMode !== "signed-out" }, + }, + }), + }); + } finally { + await apiServer.close(); + } + }); + }, +}); diff --git a/Extensions/e2e/extension/extension-startup-resilience.e2e.js b/Extensions/e2e/extension/extension-startup-resilience.e2e.js new file mode 100644 index 0000000..be7464f --- /dev/null +++ b/Extensions/e2e/extension/extension-startup-resilience.e2e.js @@ -0,0 +1,53 @@ +const fs = require("node:fs"); +const path = require("node:path"); +const { test, expect } = require("@playwright/test"); +const { VIDEO_A, openWatchFixture } = require("../../UserScript/e2e/harness"); +const { HermeticExtensionArtifactAdapter, startHermeticApiServer } = require("../hermetic-artifact-smoke"); + +const REPOSITORY_ROOT = path.resolve(__dirname, "../../.."); +const EXTENSION_ARTIFACT = path.resolve( + process.env.RYD_EXTENSION_ARTIFACT || path.join(REPOSITORY_ROOT, "Extensions", "combined", "dist", "chrome"), +); + +test("bundled selectors initialize Watch when the remote selector request never settles", async () => { + expect( + fs.existsSync(path.join(EXTENSION_ARTIFACT, "manifest.json")), + `The generated Chrome extension artifact is missing at ${EXTENSION_ARTIFACT}`, + ).toBe(true); + + const apiServer = await startHermeticApiServer(); + const adapter = new HermeticExtensionArtifactAdapter({ + apiServer, + artifactDirectory: EXTENSION_ARTIFACT, + selectorResponse: () => new Promise(() => {}), + }); + + try { + await adapter.start(); + adapter.page.setDefaultTimeout(4_000); + await openWatchFixture(adapter.page, VIDEO_A); + + const result = await adapter.waitForWatchResult(VIDEO_A); + expect(result).toMatchObject({ + actionSurfaceVisible: true, + count: "25", + countVisible: true, + fillVisible: true, + ownedByExpectedWatch: true, + rateBarVisible: true, + sameActionSurface: true, + videoId: VIDEO_A, + }); + + const selectorRequests = adapter.backend.requestsFor("GET", "/configs/selectors"); + expect(selectorRequests).toHaveLength(1); + expect(selectorRequests[0].respondedAt).toBeUndefined(); + expect(adapter.backend.requestsFor("GET", "/votes").map(({ query }) => query.videoId)).toEqual([VIDEO_A]); + expect(adapter.backend.blockedRequests).toEqual([]); + expect(apiServer.unexpectedRequests).toEqual([]); + await adapter.pageSignals.assertClean("hanging-selector-startup-fallback"); + } finally { + await adapter.close().catch(() => {}); + await apiServer.close().catch(() => {}); + } +}); diff --git a/Extensions/e2e/extension/extension-worker-signals.e2e.js b/Extensions/e2e/extension/extension-worker-signals.e2e.js new file mode 100644 index 0000000..50e9b10 --- /dev/null +++ b/Extensions/e2e/extension/extension-worker-signals.e2e.js @@ -0,0 +1,39 @@ +const fs = require("node:fs"); +const path = require("node:path"); +const { test, expect } = require("@playwright/test"); +const { HermeticExtensionArtifactAdapter, startHermeticApiServer } = require("../hermetic-artifact-smoke"); + +const REPOSITORY_ROOT = path.resolve(__dirname, "../../.."); +const EXTENSION_ARTIFACT = path.resolve( + process.env.RYD_EXTENSION_ARTIFACT || path.join(REPOSITORY_ROOT, "Extensions", "combined", "dist", "chrome"), +); +const INTENTIONAL_REJECTION = "intentional MV3 rejection probe"; + +test("the runtime guard detects an unhandled rejection in the real MV3 worker", async () => { + expect( + fs.existsSync(path.join(EXTENSION_ARTIFACT, "manifest.json")), + `The generated Chrome extension artifact is missing at ${EXTENSION_ARTIFACT}`, + ).toBe(true); + + const apiServer = await startHermeticApiServer(); + const adapter = new HermeticExtensionArtifactAdapter({ apiServer, artifactDirectory: EXTENSION_ARTIFACT }); + try { + await adapter.start(); + await adapter.worker.evaluate((message) => { + void Promise.reject(new Error(message)); + }, INTENTIONAL_REJECTION); + + await expect + .poll(() => apiServer.workerSignals.some((signal) => signal.message === INTENTIONAL_REJECTION)) + .toBe(true); + await expect(adapter.pageSignals.assertClean("intentional-worker-rejection-negative-control")).rejects.toThrow( + new RegExp(INTENTIONAL_REJECTION), + ); + + expect(apiServer.unexpectedRequests).toEqual([]); + expect(adapter.backend.blockedRequests).toEqual([]); + } finally { + await adapter.close().catch(() => {}); + await apiServer.close().catch(() => {}); + } +}); diff --git a/Extensions/e2e/extension/github-login.e2e.js b/Extensions/e2e/extension/github-login.e2e.js new file mode 100644 index 0000000..2925b26 --- /dev/null +++ b/Extensions/e2e/extension/github-login.e2e.js @@ -0,0 +1,128 @@ +const { test, expect } = require("@playwright/test"); +const { HermeticExtensionArtifactAdapter, startHermeticApiServer } = require("../hermetic-artifact-smoke"); +const messages = require("../../combined/_locales/en/messages.json"); + +for (const scenario of ["success", "unavailable", "not_contributor", "wrong-state"]) { + test(`built popup GitHub login: ${scenario}`, async ({}, testInfo) => { + const apiServer = await startHermeticApiServer(); + const adapter = new HermeticExtensionArtifactAdapter({ apiServer }); + const diagnostics = []; + try { + await adapter.start(); + adapter.page.on("pageerror", (error) => diagnostics.push(error.message)); + adapter.page.on("console", (message) => { + if (message.type() === "error") diagnostics.push(message.text()); + }); + await adapter.worker.evaluate((scenario) => { + const originalFetch = globalThis.fetch.bind(globalThis); + const redirectUri = `https://${chrome.runtime.id}.chromiumapp.org/`; + const ledger = (globalThis.githubLoginFixture = { opens: 0, exchanges: 0, pkceVerified: false }); + let challenge; + chrome.identity = { + getRedirectURL: () => redirectUri, + launchWebAuthFlow: ({ url }, callback) => { + ledger.opens++; + const authUrl = new URL(url); + const state = scenario === "wrong-state" ? "another-attempt" : authUrl.searchParams.get("state"); + const responseUrl = `${redirectUri}?code=fixture-code&state=${state}`; + callback?.(responseUrl); + return Promise.resolve(responseUrl); + }, + }; + if (typeof browser !== "undefined") browser.identity = chrome.identity; + globalThis.fetch = async (url, options) => { + const parsed = new URL(url); + if (parsed.pathname === "/api/auth/github/login") { + if (scenario === "unavailable") return Response.json({ error: "github_not_configured" }, { status: 503 }); + challenge = parsed.searchParams.get("codeChallenge"); + const params = new URLSearchParams({ + state: "fixture-state", + redirect_uri: redirectUri, + code_challenge: challenge, + code_challenge_method: "S256", + }); + return Response.json({ + authUrl: `https://github.com/login/oauth/authorize?${params}`, + state: "fixture-state", + redirectUri, + }); + } + if (parsed.pathname === "/api/auth/github/exchange") { + ledger.exchanges++; + const body = JSON.parse(options.body); + const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(body.codeVerifier)); + const actual = btoa(String.fromCharCode(...new Uint8Array(digest))) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); + ledger.pkceVerified = + actual === challenge && body.state === "fixture-state" && body.redirectUri === redirectUri; + if (!ledger.pkceVerified) return Response.json({ error: "github_invalid_state" }, { status: 400 }); + if (scenario === "not_contributor") return Response.json({ error: "not_contributor" }, { status: 403 }); + return Response.json({ + success: true, + sessionToken: "fixture-session", + user: { + id: "gh_42", + fullName: "Test Contributor", + imageUrl: "", + hasActiveMembership: true, + membershipTier: "premium", + authProvider: "github", + githubLogin: "fixture-contributor", + }, + }); + } + return originalFetch(url, options); + }; + }, scenario); + await adapter.page.goto(`chrome-extension://${adapter.extensionId}/popup.html`); + await adapter.page.evaluate(() => { + // Keep the browser/provider boundary deterministic; the bundled click handler still runs. + chrome.permissions.contains = (permissions, callback) => callback(true); + chrome.identity = { getRedirectURL: () => `https://${chrome.runtime.id}.chromiumapp.org/` }; + }); + const expectedMessage = { + unavailable: "githubLoginUnavailable", + not_contributor: "githubLoginNotContributor", + "wrong-state": "githubLoginExpired", + }[scenario]; + const dialogs = []; + adapter.page.on("dialog", async (dialog) => { + dialogs.push(dialog.message()); + await dialog.dismiss(); + }); + await adapter.page.locator("#github-login-btn").click(); + if (expectedMessage) { + await expect.poll(() => dialogs).toEqual([messages[expectedMessage].message]); + await expect(adapter.page.locator("#patreon-logged-out")).toBeVisible(); + } else { + await expect(adapter.page.locator("#patreon-logged-in")).toBeVisible(); + await expect(adapter.page.locator("#patreon-user-name")).toHaveText("Test Contributor"); + await adapter.page.screenshot({ + path: testInfo.outputPath("github-contributor-signed-in.png"), + fullPage: true, + }); + } + const evidence = await adapter.worker.evaluate(async () => ({ + ...globalThis.githubLoginFixture, + authenticated: (await chrome.storage.sync.get("patreonAuthenticated")).patreonAuthenticated === true, + })); + expect(evidence.opens).toBe(scenario === "unavailable" ? 0 : 1); + expect(evidence.exchanges).toBe(["success", "not_contributor"].includes(scenario) ? 1 : 0); + expect(evidence.authenticated).toBe(scenario === "success"); + if (evidence.exchanges) expect(evidence.pkceVerified).toBe(true); + } catch (error) { + console.error( + JSON.stringify({ + diagnostics, + ledger: await adapter.worker?.evaluate(() => globalThis.githubLoginFixture).catch(() => null), + }), + ); + throw error; + } finally { + await adapter.close(); + await apiServer.close(); + } + }); +} diff --git a/Extensions/e2e/firefox-changelog-lifecycle.js b/Extensions/e2e/firefox-changelog-lifecycle.js new file mode 100644 index 0000000..d298ad7 --- /dev/null +++ b/Extensions/e2e/firefox-changelog-lifecycle.js @@ -0,0 +1,256 @@ +const assert = require("node:assert/strict"); +const crypto = require("node:crypto"); +const fs = require("node:fs/promises"); +const path = require("node:path"); + +const EVENT_KEY = "__rydTestInstalledEvents"; +const RUNTIME_KEY = "__rydTestLifecycleRuntime"; +const CHANGELOG_PATH = "/changelog/4/changelog_4.0.html"; +const STORAGE_KEYS = ["lastShownChangelogVersion", "pendingChangelogVersion", EVENT_KEY, RUNTIME_KEY]; +const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +async function instrumentChangelogLifecycle({ derived, manifest, result }) { + const buildId = crypto.randomUUID(); + const filename = "ryd.lifecycle-probe.js"; + const probe = `(() => { + const buildId = ${JSON.stringify(buildId)}; + const backgroundStartId = crypto.randomUUID(); + const startedAt = new Date().toISOString(); + browser.storage.local.set({ [${JSON.stringify(RUNTIME_KEY)}]: { + buildId, backgroundStartId, startedAt, version: browser.runtime.getManifest().version, + runtimeUrl: browser.runtime.getURL("") + }}); + browser.runtime.onInstalled.addListener(details => { + browser.storage.local.get([${JSON.stringify(EVENT_KEY)}, "lastShownChangelogVersion"]).then(stored => { + const events = stored[${JSON.stringify(EVENT_KEY)}] || []; + events.push({ + buildId, backgroundStartId, recordedAt: new Date().toISOString(), + reason: details.reason, temporary: details.temporary ?? null, + previousVersion: details.previousVersion ?? null, + shownAtInstall: stored.lastShownChangelogVersion ?? null, + version: browser.runtime.getManifest().version + }); + return browser.storage.local.set({ [${JSON.stringify(EVENT_KEY)}]: events }); + }); + }); + })(); +`; + await fs.writeFile(path.join(derived, filename), probe); + manifest.background.scripts.unshift(filename); + result.changelogLifecycle = { + buildId, + instrumentation: { + filename, + sha256: crypto.createHash("sha256").update(probe).digest("hex"), + eventKey: EVENT_KEY, + runtimeKey: RUNTIME_KEY, + description: + "Test-only first background script records real onInstalled reason, temporary flag, previous version, and a background-start ID in owned local storage. Product handlers are unchanged.", + }, + observations: [], + }; +} + +async function observeChangelogLifecycle({ driver, derived, manifest, result, until }) { + const lifecycle = result.changelogLifecycle; + const addonId = manifest.browser_specific_settings.gecko.id; + const expectImmediate = process.env.RYD_FIREFOX_CHANGELOG_EXPECT_IMMEDIATE === "1"; + lifecycle.expectImmediateTemporaryReload = expectImmediate; + + async function openPopup() { + await driver.context("chrome"); + const uuid = await until( + () => driver.script(`return WebExtensionPolicy.getByID(arguments[0])?.mozExtensionHostname || false;`, [addonId]), + "temporary extension policy after lifecycle change", + ); + await driver.context("content"); + const previousHandlesResponse = await driver.command("WebDriver:GetWindowHandles"); + const previousHandles = previousHandlesResponse.value || previousHandlesResponse; + await driver.context("chrome"); + await driver.script(` + gBrowser.selectedTab = gBrowser.addTab("about:blank", { + triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal() + }); + `); + await driver.context("content"); + const handlesResponse = await driver.command("WebDriver:GetWindowHandles"); + const handles = handlesResponse.value || handlesResponse; + const handle = handles.find((candidate) => !previousHandles.includes(candidate)); + assert(handle, "Lifecycle probe must own a fresh browser tab"); + await driver.command("WebDriver:SwitchToWindow", { handle }); + await driver.command("WebDriver:Navigate", { url: `moz-extension://${uuid}/popup.html` }); + await until( + () => driver.script(`return typeof browser !== "undefined" && Boolean(browser.storage?.local);`), + "extension storage API in owned lifecycle popup", + ); + } + + async function storage() { + return driver.script( + `const done = arguments[arguments.length - 1]; browser.storage.local.get(arguments[0]).then(done);`, + [STORAGE_KEYS], + true, + ); + } + + async function snapshot(label, previousStartId) { + await openPopup(); + const state = await until(async () => { + const value = await storage(); + const runtime = value[RUNTIME_KEY]; + const events = value[EVENT_KEY] || []; + return runtime?.buildId === lifecycle.buildId && + runtime.backgroundStartId !== previousStartId && + events.some((event) => event.backgroundStartId === runtime.backgroundStartId) + ? value + : false; + }, `${label} instrumented onInstalled event`); + assert.equal(state[RUNTIME_KEY].version, manifest.version); + // Observe asynchronous product storage/tab work over a bounded interval; the + // baseline records its outcome without assuming that reload opens a tab. + await delay(500); + const settledState = await storage(); + await driver.context("chrome"); + const native = await driver.script( + ` + const done = arguments[arguments.length - 1]; + const { AddonManager } = ChromeUtils.importESModule("resource://gre/modules/AddonManager.sys.mjs"); + AddonManager.getAddonByID(arguments[0]).then(addon => { + const uuid = WebExtensionPolicy.getByID(arguments[0]).mozExtensionHostname; + const changelogUrl = "moz-extension://" + uuid + arguments[1]; + done({ + id: addon.id, version: addon.version, temporarilyInstalled: addon.temporarilyInstalled, + sourceUri: addon.sourceURI?.spec || null, + changelogUrl, + changelogTabCount: Array.from(gBrowser.tabs).filter(tab => tab.linkedBrowser.currentURI.spec === changelogUrl).length + }); + }); + `, + [addonId, CHANGELOG_PATH], + true, + ); + assert.equal(native.id, addonId); + assert.equal(native.version, manifest.version); + assert.equal(native.temporarilyInstalled, true); + const observation = { label, state: settledState, native }; + lifecycle.observations.push(observation); + console.log("CHANGELOG LIFECYCLE", JSON.stringify(observation)); + return observation; + } + + async function clearShownAndCloseChangelog(label, { clearShown = true, seedPending = false } = {}) { + await openPopup(); + if (clearShown) + await driver.script( + `const done = arguments[arguments.length - 1]; browser.storage.local.remove("lastShownChangelogVersion").then(() => browser.storage.local.get(arguments[0])).then(done);`, + [STORAGE_KEYS], + true, + ); + if (seedPending) + await driver.script( + `const done = arguments[arguments.length - 1]; browser.storage.local.set({ pendingChangelogVersion: arguments[0] }).then(done);`, + [manifest.version], + true, + ); + const clearedState = await storage(); + if (clearShown) assert.equal(clearedState.lastShownChangelogVersion, undefined); + else assert.equal(clearedState.lastShownChangelogVersion, manifest.version); + if (seedPending) assert.equal(clearedState.pendingChangelogVersion, manifest.version); + await driver.context("chrome"); + const closed = await driver.script( + ` + const uuid = WebExtensionPolicy.getByID(arguments[0]).mozExtensionHostname; + const url = "moz-extension://" + uuid + arguments[1]; + const tabs = Array.from(gBrowser.tabs).filter(tab => tab.linkedBrowser.currentURI.spec === url); + for (const tab of tabs) gBrowser.removeTab(tab, { animate: false }); + return tabs.length; + `, + [addonId, CHANGELOG_PATH], + ); + lifecycle.observations.push({ label, clearedState, closedChangelogTabs: closed }); + // Keep Marionette's selected document outside the extension before its + // pages are destroyed by uninstall/reload. + await driver.context("content"); + await driver.command("WebDriver:Navigate", { url: "about:blank" }); + await driver.context("chrome"); + } + + async function reloadAddon() { + const reloaded = await driver.script( + ` + const done = arguments[arguments.length - 1]; + const { AddonManager } = ChromeUtils.importESModule("resource://gre/modules/AddonManager.sys.mjs"); + AddonManager.getAddonByID(arguments[0]).then(addon => addon.reload()).then(() => done(true), error => done({error:String(error)})); + `, + [addonId], + true, + ); + assert.equal(reloaded, true); + } + + const fresh = await snapshot("fresh-install"); + await clearShownAndCloseChangelog("before-remove-and-reinstall-with-shown-marker", { clearShown: false }); + await driver.script( + ` + const done = arguments[arguments.length - 1]; + const { AddonManager } = ChromeUtils.importESModule("resource://gre/modules/AddonManager.sys.mjs"); + AddonManager.getAddonByID(arguments[0]).then(addon => addon.uninstall()).then(() => done(true), error => done({error:String(error)})); + `, + [addonId], + true, + ); + await until( + () => driver.script(`return !WebExtensionPolicy.getByID(arguments[0]);`, [addonId]), + "temporary extension removal", + ); + const reinstalled = await driver.script( + ` + const done = arguments[arguments.length - 1]; + const { AddonManager } = ChromeUtils.importESModule("resource://gre/modules/AddonManager.sys.mjs"); + const file = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile); + file.initWithPath(arguments[0]); + AddonManager.installTemporaryAddon(file).then(addon => done({id:addon.id,version:addon.version}), error => done({error:String(error)})); + `, + [derived], + true, + ); + assert(!reinstalled.error, reinstalled.error); + const afterReinstall = await snapshot("remove-and-reinstall", fresh.state[RUNTIME_KEY].backgroundStartId); + + await clearShownAndCloseChangelog("before-reload-unseen"); + await reloadAddon(); + const afterUnseenReload = await snapshot("reload-unseen", afterReinstall.state[RUNTIME_KEY].backgroundStartId); + if (expectImmediate) { + const event = afterUnseenReload.state[EVENT_KEY].at(-1); + assert.equal(event.reason, "update"); + assert.equal(event.temporary, true); + assert.equal(event.previousVersion, manifest.version); + assert.equal(afterUnseenReload.native.changelogTabCount, 1); + assert.equal(afterUnseenReload.state.lastShownChangelogVersion, manifest.version); + assert.equal(afterUnseenReload.state.pendingChangelogVersion, undefined); + await clearShownAndCloseChangelog("before-reload-already-seen", { clearShown: false }); + await reloadAddon(); + const afterSeenReload = await snapshot( + "reload-already-seen", + afterUnseenReload.state[RUNTIME_KEY].backgroundStartId, + ); + assert.equal(afterSeenReload.native.changelogTabCount, 0); + assert.equal(afterSeenReload.state.lastShownChangelogVersion, manifest.version); + assert.equal(afterSeenReload.state.pendingChangelogVersion, undefined); + await clearShownAndCloseChangelog("before-reload-pending-only", { seedPending: true }); + await reloadAddon(); + const afterPendingReload = await snapshot( + "reload-pending-only", + afterSeenReload.state[RUNTIME_KEY].backgroundStartId, + ); + assert.equal(afterPendingReload.native.changelogTabCount, 1); + assert.equal(afterPendingReload.state.lastShownChangelogVersion, manifest.version); + assert.equal(afterPendingReload.state.pendingChangelogVersion, undefined); + result.scenarios.push("unseen temporary reload opens exactly one changelog and consumes pending state"); + result.scenarios.push("already-seen temporary reload opens no duplicate changelog"); + result.scenarios.push("temporary reload recovers pending-only changelog state and records successful display"); + } + result.scenarios.push("observed native temporary-addon changelog lifecycle after removal/reinstall and reload"); +} + +module.exports = { instrumentChangelogLifecycle, observeChangelogLifecycle }; diff --git a/Extensions/e2e/firefox-consent-smoke.js b/Extensions/e2e/firefox-consent-smoke.js new file mode 100644 index 0000000..01d7088 --- /dev/null +++ b/Extensions/e2e/firefox-consent-smoke.js @@ -0,0 +1,596 @@ +const assert = require("node:assert/strict"); +const fs = require("node:fs/promises"); +const path = require("node:path"); +const net = require("node:net"); +const http = require("node:http"); +const { spawn } = require("node:child_process"); +const crypto = require("node:crypto"); + +// Run after the production build: node Extensions/e2e/firefox-consent-smoke.js +// RYD_FIREFOX_BINARY selects a portable browser. RYD_FIREFOX_PACKAGED=1 requires +// Developer Edition or Nightly and enables unsigned packages only in the owned profile. +// RYD_FIREFOX_CHANGELOG_LIFECYCLE=1 observes temporary install/reload events. +// RYD_FIREFOX_CHANGELOG_EXPECT_IMMEDIATE=1 also asserts immediate reload display and pending-state recovery. +const ROOT = path.resolve(__dirname, "../.."); +const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +async function until(operation, description, timeout = 10000) { + const deadline = Date.now() + timeout; + while (Date.now() < deadline) { + const result = await operation(); + if (result) return result; + await delay(100); + } + throw new Error(`Timed out waiting for ${description}`); +} + +class Marionette { + constructor(socket) { + this.socket = socket; + this.buffer = Buffer.alloc(0); + this.sequence = 0; + this.pending = new Map(); + this.ready = new Promise((resolve) => (this.connected = resolve)); + socket.on("data", (chunk) => { + this.buffer = Buffer.concat([this.buffer, chunk]); + while (this.buffer.includes(58)) { + const colon = this.buffer.indexOf(58); + const length = Number(this.buffer.subarray(0, colon).toString()); + if (this.buffer.length < colon + 1 + length) return; + const message = JSON.parse(this.buffer.subarray(colon + 1, colon + 1 + length)); + this.buffer = this.buffer.subarray(colon + 1 + length); + if (!Array.isArray(message)) this.connected(message); + else { + const [, id, error, result] = message; + const pending = this.pending.get(id); + if (!pending) continue; + this.pending.delete(id); + clearTimeout(pending.timer); + error ? pending.reject(new Error(JSON.stringify(error))) : pending.resolve(result); + } + } + }); + socket.on("error", () => {}); + } + + command(name, parameters = {}) { + const id = ++this.sequence; + const message = JSON.stringify([0, id, name, parameters]); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`Timed out: ${name}`)), 30000); + this.pending.set(id, { resolve, reject, timer }); + this.socket.write(`${Buffer.byteLength(message)}:${message}`); + }); + } + + async script(script, args = [], async = false) { + const response = await this.command(async ? "WebDriver:ExecuteAsyncScript" : "WebDriver:ExecuteScript", { + script, + args, + newSandbox: false, + sandbox: null, + line: 1, + filename: "firefox-consent-smoke.js", + scriptTimeout: 25000, + }); + return response.value; + } + + async context(value) { + await this.command("Marionette:SetContext", { value }); + } + + async click(selector) { + const element = await this.command("WebDriver:FindElement", { using: "css selector", value: selector }); + await this.command("WebDriver:ElementClick", { id: element.value["element-6066-11e4-a52e-4f735466cecf"] }); + } +} + +async function listen(server) { + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + return server.address().port; +} + +async function connect(port) { + for (let attempt = 0; attempt < 100; attempt++) { + try { + const socket = await new Promise((resolve, reject) => { + const client = net.connect(port, "127.0.0.1", () => resolve(client)); + client.once("error", reject); + }); + const driver = new Marionette(socket); + await driver.ready; + return driver; + } catch (_) { + await delay(200); + } + } + throw new Error("Firefox Marionette did not start"); +} + +async function run() { + const artifact = path.resolve( + process.env.RYD_FIREFOX_ARTIFACT || path.join(ROOT, "Extensions/combined/dist/firefox"), + ); + const firefox = process.env.RYD_FIREFOX_BINARY || "C:\\Program Files\\Mozilla Firefox\\firefox.exe"; + const packaged = process.env.RYD_FIREFOX_PACKAGED === "1"; + const changelogLifecycle = process.env.RYD_FIREFOX_CHANGELOG_LIFECYCLE === "1"; + assert(!(packaged && changelogLifecycle), "Changelog lifecycle reproduction requires a temporary addon"); + const evidence = path.join(ROOT, "test-results", `firefox-consent-${new Date().toISOString().replace(/[:.]/g, "-")}`); + const profile = path.join(evidence, "profile"); + const derived = path.join(evidence, "extension"); + await fs.mkdir(profile, { recursive: true }); + await fs.cp(artifact, derived, { recursive: true }); + const manifest = JSON.parse(await fs.readFile(path.join(derived, "manifest.json"), "utf8")); + const requests = []; + const blocked = []; + const unexpectedRequests = []; + const result = { + artifact, + version: manifest.version, + scenarios: [], + screenshots: [], + requests, + blocked, + unexpectedRequests, + }; + result.validationDepth = + "generated Firefox artifact with loopback API substitution, native optional consent, isolated temporary install"; + result.limitations = [ + "Temporary installation grants required categories without showing the install/update consent prompt.", + "Native arrow-panel text is captured from Firefox UI; headless screenshots do not capture the separate native panel.", + ]; + const server = http.createServer((request, response) => { + const url = new URL(request.url, `http://${request.headers.host}`); + requests.push({ method: request.method, path: url.pathname }); + response.setHeader("Access-Control-Allow-Origin", "*"); + response.setHeader("Access-Control-Allow-Headers", "Content-Type"); + response.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); + response.setHeader("Content-Type", "application/json"); + if (request.method === "OPTIONS") response.end(); + else if (url.pathname === "/puzzle/registration") + response.end( + request.method === "POST" ? "true" : JSON.stringify({ challenge: "AAAAAAAAAAAAAAAAAAAAAA==", difficulty: 0 }), + ); + else if (url.pathname === "/puzzle/registration/confirm") response.end("true"); + else if (url.pathname.endsWith("/login")) { + response.end( + JSON.stringify({ + authUrl: `${origin}/oauth-authorize?redirectUri=${encodeURIComponent(url.searchParams.get("redirectUri"))}`, + state: "fixture-state", + }), + ); + } else if (url.pathname === "/oauth-authorize") { + response.writeHead(302, { + Location: `${url.searchParams.get("redirectUri")}?code=fixture-code&state=fixture-state`, + }); + response.end(); + } else if (url.pathname.endsWith("/exchange")) { + response.end( + JSON.stringify({ + success: true, + sessionToken: "fixture-session-token", + user: { fullName: "Test member", membershipTier: "premium", hasActiveMembership: true }, + }), + ); + } else if (url.pathname === "/api/auth/verify") + response.end(JSON.stringify({ valid: true, membershipTier: "premium" })); + else { + unexpectedRequests.push({ method: request.method, path: url.pathname }); + response.writeHead(404); + response.end("{}"); + } + }); + const port = await listen(server); + const origin = `http://127.0.0.1:${port}`; + const proxy = http.createServer((request, response) => { + blocked.push({ method: request.method, host: new URL(request.url).hostname }); + response.writeHead(502); + response.end(); + }); + proxy.on("connect", (request, socket) => { + blocked.push({ method: "CONNECT", host: request.url }); + socket.end("HTTP/1.1 502 Blocked by isolated test\r\n\r\n"); + }); + const proxyPort = await listen(proxy); + const sourceHashes = { + "manifest.json": crypto + .createHash("sha256") + .update(await fs.readFile(path.join(artifact, "manifest.json"))) + .digest("hex"), + }; + for (const filename of ["ryd.background.js", "ryd.content-script.js", "popup.js"]) { + const file = path.join(derived, filename); + const source = await fs.readFile(file, "utf8"); + sourceHashes[filename] = crypto.createHash("sha256").update(source).digest("hex"); + assert( + source.includes("https://returnyoutubedislikeapi.com"), + `${filename} must contain its production API origin`, + ); + await fs.writeFile(file, source.replaceAll("https://returnyoutubedislikeapi.com", origin)); + } + result.sourceHashes = sourceHashes; + if (changelogLifecycle) + await require("./firefox-changelog-lifecycle").instrumentChangelogLifecycle({ derived, manifest, result }); + manifest.permissions.push(`${origin}/*`); + await fs.writeFile(path.join(derived, "manifest.json"), JSON.stringify(manifest, null, 2)); + const portServer = net.createServer(); + const marionettePort = await listen(portServer); + await new Promise((resolve) => portServer.close(resolve)); + const prefs = { + "marionette.port": marionettePort, + "browser.shell.checkDefaultBrowser": false, + "browser.startup.homepage_override.mstone": "ignore", + "browser.startup.page": 0, + "startup.homepage_welcome_url": "about:blank", + "startup.homepage_welcome_url.additional": "", + "browser.newtabpage.enabled": false, + "browser.tabs.warnOnClose": false, + "datareporting.policy.dataSubmissionEnabled": false, + "toolkit.telemetry.enabled": false, + "app.update.disabledForTesting": true, + "network.proxy.type": 1, + "network.proxy.http": "127.0.0.1", + "network.proxy.http_port": proxyPort, + "network.proxy.ssl": "127.0.0.1", + "network.proxy.ssl_port": proxyPort, + "network.proxy.no_proxies_on": "127.0.0.1,localhost", + "network.proxy.failover_direct": false, + "network.trr.mode": 5, + "dom.security.https_only_mode": false, + "remote.active-protocols": 1, + }; + if (packaged) prefs["xpinstall.signatures.required"] = false; + await fs.writeFile( + path.join(profile, "user.js"), + Object.entries(prefs) + .map(([key, value]) => `user_pref(${JSON.stringify(key)}, ${JSON.stringify(value)});`) + .join("\n"), + ); + const child = spawn( + firefox, + ["-headless", "-no-remote", "-profile", profile, "--marionette", "--remote-allow-system-access", "about:blank"], + { windowsHide: true, stdio: ["ignore", "pipe", "pipe"] }, + ); + let firefoxLog = ""; + child.stdout.on("data", (chunk) => (firefoxLog += chunk)); + child.stderr.on("data", (chunk) => (firefoxLog += chunk)); + let driver; + try { + driver = await connect(marionettePort); + result.session = await driver.command("WebDriver:NewSession", { + acceptInsecureCerts: true, + unhandledPromptBehavior: "dismiss and notify", + }); + console.log(`Firefox ${result.session.capabilities.browserVersion}, extension ${manifest.version}`); + await driver.context("chrome"); + await driver.script(` + window.__rydConsentDiagnostics = []; + window.__rydConsentListener = { observe(message) { + const text = message.message || ""; + if (text.includes("moz-extension://")) window.__rydConsentDiagnostics.push(text); + }}; + Services.console.registerListener(window.__rydConsentListener); + `); + const installed = packaged + ? await require("./firefox-install-consent").validatePackagedConsent({ + driver, + derived, + evidence, + manifest, + result, + requests, + }) + : await driver.script( + ` + const done = arguments[arguments.length - 1]; + const { AddonManager } = ChromeUtils.importESModule("resource://gre/modules/AddonManager.sys.mjs"); + const file = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile); + file.initWithPath(arguments[0]); + AddonManager.installTemporaryAddon(file).then(addon => done({id:addon.id,version:addon.version}), error=>done({error:String(error)})); + `, + [derived], + true, + ); + assert(!installed.error, installed.error); + result.installed = installed; + assert.equal(installed.version, manifest.version); + assert.equal(installed.id, manifest.browser_specific_settings.gecko.id); + const uuid = await driver.script(`return WebExtensionPolicy.getByID(arguments[0]).mozExtensionHostname;`, [ + installed.id, + ]); + if (!packaged) { + result.freshInstallChangelog = await until( + () => + driver.script( + ` + const url = "moz-extension://" + arguments[0] + "/changelog/4/changelog_4.0.html"; + const tabs = Array.from(gBrowser.tabs).filter(tab => tab.linkedBrowser.currentURI.spec === url); + return tabs.length ? { url, tabCount: tabs.length } : false; + `, + [uuid], + ), + "automatic changelog tab on a fresh temporary installation", + ); + assert.equal(result.freshInstallChangelog.tabCount, 1); + result.scenarios.push("fresh temporary installation automatically opens one changelog tab"); + } + await driver.context("content"); + await driver.command("WebDriver:SetWindowRect", { width: 380, height: 900 }); + await driver.command("WebDriver:Navigate", { url: `moz-extension://${uuid}/popup.html` }); + result.initialPermissions = await driver.script( + `const done=arguments[arguments.length-1]; browser.permissions.getAll().then(done);`, + [], + true, + ); + if (!packaged) { + result.freshInstallChangelog.storage = await until( + () => + driver.script( + `const done=arguments[arguments.length-1]; browser.storage.local.get(["lastShownChangelogVersion", "pendingChangelogVersion"]).then(value=>done(value.lastShownChangelogVersion ? value : false));`, + [], + true, + ), + "successful changelog display recorded in local storage", + ); + assert.equal(result.freshInstallChangelog.storage.lastShownChangelogVersion, manifest.version); + } + const optional = manifest.browser_specific_settings.gecko.data_collection_permissions.optional; + assert.deepEqual(optional, ["authenticationInfo"]); + assert(optional.every((permission) => !result.initialPermissions.data_collection.includes(permission))); + for (const message of ["patreon_oauth_login", "github_oauth_login"]) { + const deniedMessage = await driver.script( + `const done=arguments[arguments.length-1]; browser.runtime.sendMessage({message:arguments[0]}).then(done);`, + [message], + true, + ); + assert.equal(deniedMessage.success, false); + assert.match(deniedMessage.error, /consent removed|consent required/); + } + assert.equal(requests.filter((request) => request.path.startsWith("/api/auth/")).length, 0); + result.scenarios.push("background refuses authentication without optional consent"); + await driver.script( + `const done=arguments[arguments.length-1]; browser.storage.sync.set({patreonAuthenticated:true,patreonUser:{fullName:"Cached test member",membershipTier:"premium",hasActiveMembership:true},patreonSessionToken:"fixture-cached-session-token"}).then(done);`, + [], + true, + ); + await driver.command("WebDriver:Refresh"); + await until( + () => driver.script(`return document.getElementById("patreon-logged-out").style.display === "block";`), + "cached session kept inactive without consent", + ); + assert.equal(requests.filter((request) => request.path.startsWith("/api/auth/")).length, 0); + result.scenarios.push("cached account cannot verify or log in without consent"); + await delay(1000); + await driver.script( + `const done=arguments[arguments.length-1]; browser.tabs.getCurrent().then(tab=>browser.tabs.update(tab.id,{active:true})).then(()=>done(true));`, + [], + true, + ); + result.patreonPrivacyNote = await driver.script(` + const note = document.getElementById("patreon-privacy-note"); + const login = document.getElementById("patreon-login-btn"); + const rect = note.getBoundingClientRect(); + return { + text: note.textContent.trim(), + describedBy: login.getAttribute("aria-describedby"), + visible: note.checkVisibility(), + withinViewport: rect.left >= 0 && rect.right <= innerWidth, + belowLogin: rect.top >= login.getBoundingClientRect().bottom, + fontSize: getComputedStyle(note).fontSize, + viewportWidth: innerWidth + }; + `); + assert.equal( + result.patreonPrivacyNote.text, + "Patreon membership information is used to verify premium access. We do not receive your card or bank details.", + ); + assert.equal(result.patreonPrivacyNote.describedBy, "patreon-privacy-note"); + assert.equal(result.patreonPrivacyNote.visible, true); + assert.equal(result.patreonPrivacyNote.withinViewport, true); + assert.equal(result.patreonPrivacyNote.belowLogin, true); + const shot = await driver.command("WebDriver:TakeScreenshot", { full: true }); + await fs.writeFile(path.join(evidence, "popup.png"), Buffer.from(shot.value, "base64")); + result.screenshots.push("popup.png"); + await driver.click("#patreon-login-btn"); + await driver.context("chrome"); + result.nativePermissionPrompt = await until( + () => + driver.script( + `const notification = document.getElementById("addon-webext-permissions-notification"); return document.getElementById("notification-popup")?.state === "open" && notification ? { heading:notification.getAttribute("endlabel"), data:document.getElementById("addon-webext-perm-list-data-collection").textContent, allow:notification.getAttribute("buttonlabel"), deny:notification.getAttribute("secondarybuttonlabel") } : false;`, + ), + "native consent prompt", + ); + console.log("NATIVE PROMPT", JSON.stringify(result.nativePermissionPrompt)); + assert.match(result.nativePermissionPrompt.data, /authentication information/i); + assert.doesNotMatch(result.nativePermissionPrompt.data, /financial|payment/i); + // Native arrow panels live outside the headless compositor screenshot. Preserve their exact text instead. + await driver.script( + `document.querySelector("#addon-webext-permissions-notification .popup-notification-secondary-button").click();`, + ); + await driver.context("content"); + await until(async () => { + try { + await driver.command("WebDriver:GetAlertText"); + return true; + } catch (_) { + return false; + } + }, "extension denial notice"); + await driver.command("WebDriver:DismissAlert"); + const denied = await driver.script( + `const done=arguments[arguments.length-1]; browser.permissions.contains({data_collection:arguments[0]}).then(done);`, + [optional], + true, + ); + assert.equal(denied, false); + assert.equal(requests.filter((request) => request.path.startsWith("/api/auth/")).length, 0); + result.scenarios.push("native Deny prevents OAuth requests"); + console.log("DENIAL PASSED"); + await driver.click("#patreon-login-btn"); + await driver.context("chrome"); + await until( + () => driver.script(`return document.getElementById("notification-popup")?.state === "open";`), + "second consent prompt", + ); + await driver.script( + `document.querySelector("#addon-webext-permissions-notification .popup-notification-primary-button").click();`, + ); + await driver.context("content"); + await until( + () => driver.script(`return document.getElementById("patreon-logged-in").style.display === "block";`), + "mock OAuth completion", + ); + const granted = await driver.script( + `const done=arguments[arguments.length-1]; browser.permissions.contains({data_collection:arguments[0]}).then(done);`, + [optional], + true, + ); + assert.equal(granted, true); + assert.equal(requests.filter((request) => request.path === "/api/auth/oauth/login").length, 1); + assert.equal( + requests.filter((request) => request.path === "/api/auth/oauth/exchange" && request.method === "POST").length, + 1, + ); + result.scenarios.push("native Allow completes generated Patreon OAuth against loopback"); + assert.equal( + await driver.script(`return document.getElementById("patreon-privacy-note").checkVisibility();`), + false, + ); + console.log("GRANT PASSED"); + const grantShot = await driver.command("WebDriver:TakeScreenshot", { full: true }); + await fs.writeFile(path.join(evidence, "popup-granted.png"), Buffer.from(grantShot.value, "base64")); + result.screenshots.push("popup-granted.png"); + await driver.script( + `const done=arguments[arguments.length-1]; browser.permissions.remove({data_collection:arguments[0]}).then(done);`, + [optional], + true, + ); + await until( + () => + driver.script( + `const done=arguments[arguments.length-1]; browser.storage.sync.get(["patreonAuthenticated","patreonUser","patreonSessionToken"]).then(value=>done(Object.keys(value).length===0));`, + [], + true, + ), + "stored authentication cleanup after revocation", + ); + await until( + () => driver.script(`return document.getElementById("patreon-logged-out").style.display === "block";`), + "logged-out UI after revocation", + ); + result.scenarios.push("native revocation removes stored authentication and logs out popup"); + console.log("REVOCATION PASSED"); + await driver.click("#github-login-btn"); + await driver.context("chrome"); + await until( + () => driver.script(`return document.getElementById("notification-popup")?.state === "open";`), + "GitHub consent prompt", + ); + await driver.script( + `document.querySelector("#addon-webext-permissions-notification .popup-notification-primary-button").click();`, + ); + await driver.context("content"); + await until( + () => driver.script(`return document.getElementById("patreon-logged-in").style.display === "block";`), + "GitHub mock OAuth completion", + ); + assert.equal( + requests.filter((request) => request.path === "/api/auth/github/login" && request.method === "GET").length, + 1, + ); + assert.equal( + requests.filter((request) => request.path === "/api/auth/github/exchange" && request.method === "POST").length, + 1, + ); + result.scenarios.push("native Allow completes generated GitHub OAuth against loopback"); + await driver.script( + `const done=arguments[arguments.length-1]; browser.permissions.remove({data_collection:["authenticationInfo"]}).then(done);`, + [], + true, + ); + await until( + () => + driver.script( + `const done=arguments[arguments.length-1]; browser.storage.sync.get(["patreonAuthenticated","patreonUser","patreonSessionToken"]).then(value=>done(Object.keys(value).length===0));`, + [], + true, + ), + "authentication permission revocation clears GitHub authentication", + ); + await until( + () => driver.script(`return document.getElementById("patreon-logged-out").style.display === "block";`), + "GitHub logged-out UI after authentication permission revocation", + ); + result.scenarios.push("revoking authentication category clears GitHub account state"); + const revokeShot = await driver.command("WebDriver:TakeScreenshot", { full: true }); + await fs.writeFile(path.join(evidence, "popup-revoked.png"), Buffer.from(revokeShot.value, "base64")); + result.screenshots.push("popup-revoked.png"); + if (changelogLifecycle) + await require("./firefox-changelog-lifecycle").observeChangelogLifecycle({ + driver, + derived, + manifest, + result, + until, + }); + await driver.context("chrome"); + result.diagnostics = await driver.script( + `Services.console.unregisterListener(window.__rydConsentListener); return window.__rydConsentDiagnostics;`, + ); + for (const [filename, hash] of Object.entries(sourceHashes)) + assert.equal( + crypto + .createHash("sha256") + .update(await fs.readFile(path.join(artifact, filename))) + .digest("hex"), + hash, + `Artifact changed during test: ${filename}`, + ); + assert( + !blocked.some((request) => request.host.includes("returnyoutubedislikeapi.com")), + "Extension traffic escaped loopback substitution", + ); + assert.deepEqual(unexpectedRequests, [], "Unexpected extension request"); + result.passed = true; + } catch (error) { + result.error = String(error.stack || error); + throw error; + } finally { + if (driver) { + await driver.command("Marionette:Quit", { flags: ["eForceQuit"] }).catch(() => {}); + driver.socket.destroy(); + } + if (child.exitCode === null) child.kill(); + await until(() => child.exitCode !== null || child.signalCode !== null, "owned Firefox process exit").catch( + () => {}, + ); + server.closeAllConnections(); + proxy.closeAllConnections(); + await Promise.all([ + new Promise((resolve) => server.close(resolve)), + new Promise((resolve) => proxy.close(resolve)), + ]); + await fs.writeFile(path.join(evidence, "firefox.log"), firefoxLog); + await fs.writeFile(path.join(evidence, "result.json"), JSON.stringify(result, null, 2)); + for (const owned of [ + profile, + derived, + path.join(evidence, "baseline"), + path.join(evidence, "current.xpi"), + path.join(evidence, "baseline.xpi"), + ]) { + assert(path.resolve(owned).startsWith(`${path.resolve(evidence)}${path.sep}`)); + await fs.rm(owned, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }).catch(() => {}); + } + console.log(`Evidence: ${evidence}`); + } +} + +if (require.main === module) + run().catch((error) => { + console.error(error); + process.exitCode = 1; + }); + +module.exports = { Marionette, run }; diff --git a/Extensions/e2e/firefox-install-consent.js b/Extensions/e2e/firefox-install-consent.js new file mode 100644 index 0000000..24d5653 --- /dev/null +++ b/Extensions/e2e/firefox-install-consent.js @@ -0,0 +1,193 @@ +const assert = require("node:assert/strict"); +const fs = require("node:fs/promises"); +const path = require("node:path"); + +async function waitFor(operation, description) { + const deadline = Date.now() + 15000; + while (Date.now() < deadline) { + const value = await operation(); + if (value) return value; + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw new Error(`Timed out waiting for ${description}`); +} + +async function packageDirectory(driver, directory, output) { + return driver.script( + ` + const folder = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile); + folder.initWithPath(arguments[0]); + const output = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile); + output.initWithPath(arguments[1]); + const writer = Cc["@mozilla.org/zipwriter;1"].createInstance(Ci.nsIZipWriter); + writer.open(output, 0x04 | 0x08 | 0x20); + function append(directory, prefix) { + for (const item of directory.directoryEntries) { + const file = item.QueryInterface(Ci.nsIFile); + const name = prefix + file.leafName; + if (file.isDirectory()) append(file, name + "/"); + else writer.addEntryFile(name, Ci.nsIZipWriter.COMPRESSION_DEFAULT, file, false); + } + } + append(folder, ""); + writer.close(); + return true; + `, + [directory, output], + ); +} + +async function beginInstall(driver, xpi, update = false) { + await driver.script( + ` + const done = arguments[arguments.length - 1]; + const { AddonManager } = ChromeUtils.importESModule("resource://gre/modules/AddonManager.sys.mjs"); + const file = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile); + file.initWithPath(arguments[0]); + const update = arguments[1]; + AddonManager.getInstallForFile(file).then(install => { + window.__rydPendingInstall = install; + window.__rydInstallError = null; + install.addListener({ onInstallFailed: () => {window.__rydInstallError = install.error; }}); + AddonManager.installAddonFromAOMWithOptions(gBrowser.selectedBrowser, Services.io.newFileURI(file), install, {preferUpdateOverInstall:update}); + done({state:install.state,error:install.error}); + }, error => done({error:String(error)})); + `, + [xpi, update], + true, + ); + if (update) { + await waitFor( + () => + driver.script(` + const {ExtensionsUI} = ChromeUtils.importESModule("resource:///modules/ExtensionsUI.sys.mjs"); + const update = [...ExtensionsUI.updates][0]; + if (!update) return false; + ExtensionsUI.showUpdate(gBrowser, update); + return true; + `), + "native pending update notification", + ); + } + return waitFor( + () => + driver.script(` + const notification = document.getElementById("addon-webext-permissions-notification"); + if (document.getElementById("notification-popup")?.state !== "open" || !notification) return false; + return { + heading: notification.getAttribute("endlabel"), + requiredData: document.getElementById("addon-webext-perm-list-data-collection")?.textContent, + optionalData: document.getElementById("addon-webext-perm-list-optional")?.textContent, + allow: notification.getAttribute("buttonlabel"), + deny: notification.getAttribute("secondarybuttonlabel") + }; + `), + "native packaged permission prompt", + ); +} + +async function choose(driver, allow) { + await driver.script(`document.querySelector(arguments[0]).click();`, [ + `#addon-webext-permissions-notification .popup-notification-${allow ? "primary" : "secondary"}-button`, + ]); +} + +async function installedAddon(driver, id) { + return driver.script( + ` + const done = arguments[arguments.length - 1]; + const {AddonManager} = ChromeUtils.importESModule("resource://gre/modules/AddonManager.sys.mjs"); + AddonManager.getAddonByID(arguments[0]).then(addon => done(addon ? {id:addon.id,version:addon.version,active:addon.isActive} : null)); + `, + [id], + true, + ); +} + +async function dismissFinishedNotice(driver) { + await driver.script(` + for (const notification of [...PopupNotifications._currentNotifications]) { + if (notification.id !== "addon-webext-permissions") PopupNotifications.remove(notification); + } + `); +} + +async function validatePackagedConsent({ driver, derived, evidence, manifest, result, requests }) { + const id = manifest.browser_specific_settings.gecko.id; + const xpi = path.join(evidence, "current.xpi"); + await packageDirectory(driver, derived, xpi); + result.packagedInstall = { unsignedDeveloperProfile: true, requiredPrompt: await beginInstall(driver, xpi) }; + assert.match(result.packagedInstall.requiredPrompt.requiredData, /browsing activity/); + assert.match(result.packagedInstall.requiredPrompt.requiredData, /website activity/); + assert.match(result.packagedInstall.requiredPrompt.requiredData, /website content/); + assert.match(result.packagedInstall.requiredPrompt.requiredData, /personally identifying information/); + await choose(driver, false); + await waitFor( + () => + driver.script( + `return window.__rydPendingInstall.state === ChromeUtils.importESModule("resource://gre/modules/AddonManager.sys.mjs").AddonManager.STATE_CANCELLED;`, + ), + "cancelled fresh install", + ); + assert.equal(await installedAddon(driver, id), null); + assert.equal(requests.length, 0); + result.scenarios.push("native required-consent denial prevents packaged extension installation and startup"); + console.log("REQUIRED INSTALL DENIAL PASSED"); + await beginInstall(driver, xpi); + await choose(driver, true); + await waitFor(async () => (await installedAddon(driver, id))?.active, "fresh packaged installation"); + result.scenarios.push("native required-consent acceptance installs and starts packaged extension"); + await dismissFinishedNotice(driver); + + await driver.script( + ` + const done = arguments[arguments.length-1]; + const {AddonManager} = ChromeUtils.importESModule("resource://gre/modules/AddonManager.sys.mjs"); + AddonManager.getAddonByID(arguments[0]).then(addon=>addon.uninstall()).then(()=>done(true)); + `, + [id], + true, + ); + const baseline = path.join(evidence, "baseline"); + await fs.cp(derived, baseline, { recursive: true }); + const priorManifest = JSON.parse(JSON.stringify(manifest)); + priorManifest.version = "4.0.4"; + delete priorManifest.browser_specific_settings.gecko.data_collection_permissions; + await fs.writeFile(path.join(baseline, "manifest.json"), JSON.stringify(priorManifest)); + const baselineXpi = path.join(evidence, "baseline.xpi"); + await packageDirectory(driver, baseline, baselineXpi); + await beginInstall(driver, baselineXpi); + await choose(driver, true); + await waitFor(async () => (await installedAddon(driver, id))?.version === "4.0.4", "baseline packaged installation"); + await dismissFinishedNotice(driver); + result.packagedInstall.updateBaseline = + "Synthetic 4.0.4 package retaining current code and the prior release's absence of data_collection_permissions; fixed extension ID for unsigned update testing."; + result.packagedInstall.updatePrompt = await beginInstall(driver, xpi, true); + assert.match(result.packagedInstall.updatePrompt.requiredData, /browsing activity/); + await choose(driver, false); + await waitFor( + () => + driver.script( + `return window.__rydPendingInstall.state === ChromeUtils.importESModule("resource://gre/modules/AddonManager.sys.mjs").AddonManager.STATE_CANCELLED;`, + ), + "cancelled update", + ); + assert.equal((await installedAddon(driver, id)).version, "4.0.4"); + result.scenarios.push("native required-consent update denial preserves the installed previous version"); + console.log("REQUIRED UPDATE DENIAL PASSED"); + await beginInstall(driver, xpi, true); + await choose(driver, true); + await waitFor( + async () => (await installedAddon(driver, id))?.version === manifest.version, + "accepted packaged update", + ); + await dismissFinishedNotice(driver); + result.scenarios.push("native required-consent update acceptance activates 4.0.5"); + console.log("REQUIRED UPDATE ACCEPTANCE PASSED"); + result.validationDepth = + "packaged Firefox Developer artifact with loopback API substitution, native required install/update and optional consent"; + result.limitations = result.limitations.filter((value) => !value.startsWith("Temporary installation")); + return installedAddon(driver, id); +} + +module.exports = { validatePackagedConsent }; diff --git a/Extensions/e2e/generate-hide-clutter-tooltip-assets.js b/Extensions/e2e/generate-hide-clutter-tooltip-assets.js new file mode 100644 index 0000000..44088fc --- /dev/null +++ b/Extensions/e2e/generate-hide-clutter-tooltip-assets.js @@ -0,0 +1,82 @@ +const fs = require("node:fs"); +const path = require("node:path"); +const assert = require("node:assert/strict"); +const { chromium } = require("@playwright/test"); +const { OPTIONAL_ACTIONS } = require("./action-overflow-contract"); +const { createExtensionActionOverflowFixture } = require("./extension/extension-action-overflow-fixture"); + +const REPOSITORY_ROOT = path.resolve(__dirname, "../.."); +const OUTPUT_DIRECTORY = path.join(REPOSITORY_ROOT, "Extensions", "combined", "images"); +const OUTPUTS = Object.freeze({ + after: path.join(OUTPUT_DIRECTORY, "hide-clutter-after.png"), + before: path.join(OUTPUT_DIRECTORY, "hide-clutter-before.png"), +}); + +async function assertReactionPreview(page, label) { + const preview = await page.evaluate(() => { + const reaction = document.querySelector("[data-fixture-reaction-group]"); + const box = reaction?.getBoundingClientRect(); + return { + box: box ? { height: box.height, width: box.width } : null, + count: document.querySelectorAll("[data-fixture-reaction-group]").length, + texts: [...(reaction?.querySelectorAll("button [role='text']") ?? [])].map((element) => + element.textContent.trim(), + ), + visible: Boolean(box && box.width > 0 && box.height > 0 && getComputedStyle(reaction).display !== "none"), + }; + }); + assert.equal(preview.count, 1, `${label} preview must contain exactly one reaction group.`); + assert.equal(preview.visible, true, `${label} preview reaction group must be visible.`); + assert.deepEqual(preview.texts, ["501K", "6.6K"], `${label} preview lost its Like or Dislike count.`); + assert.ok(preview.box.width >= 140 && preview.box.height >= 32, `${label} preview reaction group is too small.`); +} + +async function generateHideClutterTooltipAssets() { + fs.mkdirSync(OUTPUT_DIRECTORY, { recursive: true }); + const browser = await chromium.launch({ headless: true }); + try { + const page = await browser.newPage({ + colorScheme: "dark", + deviceScaleFactor: 1, + reducedMotion: "reduce", + viewport: { height: 180, width: 840 }, + }); + await page.setContent(createExtensionActionOverflowFixture(), { waitUntil: "domcontentloaded" }); + await page.evaluate( + ({ ids, title }) => { + globalThis.__actionOverflowFixture.mount(); + globalThis.__actionOverflowFixture.renderPreview(ids, title); + }, + { ids: OPTIONAL_ACTIONS.map(({ id }) => id), title: "Standard controls" }, + ); + await assertReactionPreview(page, "Before"); + await page.locator(".fixture-preview-card").screenshot({ + animations: "disabled", + path: OUTPUTS.before, + }); + + await page.evaluate(() => globalThis.__actionOverflowFixture.renderPreview([], "Hide clutter buttons")); + await assertReactionPreview(page, "After"); + await page.locator(".fixture-preview-card").screenshot({ + animations: "disabled", + path: OUTPUTS.after, + }); + } finally { + await browser.close(); + } + return { ...OUTPUTS }; +} + +if (require.main === module) { + generateHideClutterTooltipAssets() + .then((outputs) => process.stdout.write(`${JSON.stringify(outputs, null, 2)}\n`)) + .catch((error) => { + process.stderr.write(`${error.stack ?? error.message}\n`); + process.exitCode = 1; + }); +} + +module.exports = { + OUTPUTS, + generateHideClutterTooltipAssets, +}; diff --git a/Extensions/e2e/hermetic-api-contract.js b/Extensions/e2e/hermetic-api-contract.js new file mode 100644 index 0000000..ac4e0ee --- /dev/null +++ b/Extensions/e2e/hermetic-api-contract.js @@ -0,0 +1,49 @@ +const assert = require("node:assert/strict"); + +const API_METHODS_BY_PATH = Object.freeze({ + "/configs/selectors": Object.freeze(["GET"]), + "/interact/confirmVote": Object.freeze(["POST"]), + "/interact/vote": Object.freeze(["POST"]), + "/puzzle/registration": Object.freeze(["GET", "POST"]), + "/votes": Object.freeze(["GET"]), +}); + +const WORKER_SIGNAL_PATH = "/__ryd_e2e/worker-signal"; + +function allowedMethodsForPath(pathname) { + if (pathname === WORKER_SIGNAL_PATH) return ["POST"]; + return API_METHODS_BY_PATH[pathname] ?? []; +} + +function isAllowedApiPreflight(pathname, requestedMethod) { + if (typeof requestedMethod !== "string" || requestedMethod.trim() === "") return false; + return allowedMethodsForPath(pathname).includes(requestedMethod.toUpperCase()); +} + +function assertExactSuccessfulVotesTraffic(records, expectedVideoIds, label = "The hermetic runtime") { + assert.ok(Array.isArray(records), `${label} has no recorded API traffic.`); + assert.ok(Array.isArray(expectedVideoIds), `${label} has no expected /votes request sequence.`); + const votes = records.filter((record) => record.method === "GET" && record.pathname === "/votes"); + assert.deepEqual( + votes.map((record) => record.query?.videoId), + expectedVideoIds, + `${label} emitted a missing, duplicate, or stale /votes request.`, + ); + for (const [index, record] of votes.entries()) { + const videoId = expectedVideoIds[index]; + assert.equal(record.responseStatus, 200, `${label} /votes request for ${videoId} did not receive HTTP 200.`); + assert.ok( + Number.isFinite(record.respondedAt), + `${label} /votes request for ${videoId} did not finish before the contract completed.`, + ); + } + return votes; +} + +module.exports = { + API_METHODS_BY_PATH, + WORKER_SIGNAL_PATH, + allowedMethodsForPath, + assertExactSuccessfulVotesTraffic, + isAllowedApiPreflight, +}; diff --git a/Extensions/e2e/hermetic-api-contract.spec.js b/Extensions/e2e/hermetic-api-contract.spec.js new file mode 100644 index 0000000..ce247a8 --- /dev/null +++ b/Extensions/e2e/hermetic-api-contract.spec.js @@ -0,0 +1,35 @@ +const { assertExactSuccessfulVotesTraffic } = require("./hermetic-api-contract"); + +const VIDEO_A = "abcdefghijk"; +const VIDEO_B = "zyxwvutsrqp"; + +function votes(videoId, overrides = {}) { + return { + method: "GET", + pathname: "/votes", + query: { videoId }, + respondedAt: 123, + responseStatus: 200, + ...overrides, + }; +} + +test("accepts the exact ordered successful /votes sequence", () => { + const records = [ + { method: "GET", pathname: "/configs/selectors", responseStatus: 200 }, + votes(VIDEO_A), + votes(VIDEO_B), + ]; + + expect(assertExactSuccessfulVotesTraffic(records, [VIDEO_A, VIDEO_B])).toEqual(records.slice(1)); +}); + +test.each([ + ["missing request", [votes(VIDEO_A)], /missing, duplicate, or stale/], + ["duplicate request", [votes(VIDEO_A), votes(VIDEO_B), votes(VIDEO_B)], /missing, duplicate, or stale/], + ["stale video ID", [votes(VIDEO_A), votes("stalevid001")], /missing, duplicate, or stale/], + ["non-200 response", [votes(VIDEO_A), votes(VIDEO_B, { responseStatus: 204 })], /did not receive HTTP 200/], + ["unfinished response", [votes(VIDEO_A), votes(VIDEO_B, { respondedAt: undefined })], /did not finish/], +])("rejects a %s", (_label, records, expectedError) => { + expect(() => assertExactSuccessfulVotesTraffic(records, [VIDEO_A, VIDEO_B], "Shared runtime")).toThrow(expectedError); +}); diff --git a/Extensions/e2e/hermetic-artifact-smoke.js b/Extensions/e2e/hermetic-artifact-smoke.js index 7f8bc37..717ed5e 100644 --- a/Extensions/e2e/hermetic-artifact-smoke.js +++ b/Extensions/e2e/hermetic-artifact-smoke.js @@ -8,24 +8,46 @@ const { VIDEO_A, VIDEO_B, createFakeBackend, + injectGeneratedUserscript, installGmEnvironment, openNavigationFixture, openWatchFixture, } = require("../UserScript/e2e/harness"); const { assertInvariantContinuously, waitForStableInvariant } = require("./continuous-invariants"); +const { + WORKER_SIGNAL_PATH, + assertExactSuccessfulVotesTraffic, + isAllowedApiPreflight, +} = require("./hermetic-api-contract"); const { LIVE_RUNTIME_PROFILES } = require("./live-runtime-adapter"); const { SHARED_LIVE_SCENARIO_IDS } = require("./shared-live-scenarios"); +const { assertProductionJavaScriptOutput } = require("./verify-extension-artifact"); +const { isVoteProtocolBodyPairValid } = require("./vote-protocol-contract"); const REPOSITORY_ROOT = path.resolve(__dirname, "../.."); const PRODUCTION_API_ORIGIN = "https://returnyoutubedislikeapi.com"; const ARTIFACT_SMOKE_SCENARIO_ID = "watch-render"; const ARTIFACT_WATCH_SPA_SCENARIO_ID = "watch-spa-side-panel"; const ARTIFACT_WATCH_SPA_VOTE_SCENARIO_ID = "watch-spa-dislike-activation"; +const ARTIFACT_WATCH_SPA_CLONED_VOTE_SCENARIO_ID = "watch-spa-cloned-controls-immediate-dislike"; const ARTIFACT_EXTENSION_DELAYED_FAILURE_SCENARIO_ID = "extension-watch-spa-delayed-outgoing-failure"; +const ARTIFACT_NATIVE_HYDRATION_DELAY_MS = 750; const SHARED_ARTIFACT_SCENARIO_IDS = Object.freeze([ ARTIFACT_SMOKE_SCENARIO_ID, ARTIFACT_WATCH_SPA_SCENARIO_ID, ARTIFACT_WATCH_SPA_VOTE_SCENARIO_ID, + ARTIFACT_WATCH_SPA_CLONED_VOTE_SCENARIO_ID, +]); +const SHARED_ARTIFACT_RUNTIMES = Object.freeze(["userscript", "extension"]); +const EXTENSION_ONLY_ARTIFACT_CAPABILITIES = Object.freeze(["background", "premium", "settings"]); +const ARTIFACT_BROWSER_SCENARIO_CATALOG = Object.freeze([ + ...SHARED_ARTIFACT_SCENARIO_IDS.map((id) => Object.freeze({ id, runtimes: SHARED_ARTIFACT_RUNTIMES, shared: true })), + Object.freeze({ + capability: "background", + id: ARTIFACT_EXTENSION_DELAYED_FAILURE_SCENARIO_ID, + runtimes: Object.freeze(["extension"]), + shared: false, + }), ]); const SPA_COUNTS = Object.freeze({ [VIDEO_A]: Object.freeze({ dislikes: 10, likes: 90 }), @@ -43,7 +65,90 @@ const ZERO_DIFFICULTY_PUZZLE = { difficulty: 0, }; const ARTIFACT_UNHANDLED_REJECTION_BINDING = "__rydArtifactReportUnhandledRejection"; -const CONSOLE_FAILURE_TYPES = new Set(["assert", "error"]); +const ARTIFACT_WORKER_SIGNAL_STORE = "__rydArtifactWorkerSignals"; +const CONSOLE_FAILURE_TYPES = new Set(["assert", "error", "warning"]); +const SHARED_ARTIFACT_FIXTURE_DEFAULTS = Object.freeze({ + nativeDislikeText: false, + roleAttribute: "data-fixture-role", +}); +const delay = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)); + +function assertArtifactBrowserScenarioCatalog(catalog = ARTIFACT_BROWSER_SCENARIO_CATALOG) { + assert.ok(Array.isArray(catalog), "The hermetic artifact browser catalog must be an array."); + assert.equal( + new Set(catalog.map(({ id }) => id)).size, + catalog.length, + "Artifact browser scenario IDs must be unique.", + ); + const byId = new Map(catalog.map((entry) => [entry.id, entry])); + + for (const scenarioId of SHARED_ARTIFACT_SCENARIO_IDS) { + const entry = byId.get(scenarioId); + assert.ok(entry, `The shared artifact browser catalog is missing ${scenarioId}.`); + assert.equal(entry.shared, true, `The core artifact browser scenario ${scenarioId} must remain shared.`); + assert.deepEqual( + entry.runtimes, + SHARED_ARTIFACT_RUNTIMES, + `The core artifact browser scenario ${scenarioId} must run for userscript and extension.`, + ); + assert.equal(entry.capability, undefined, `The shared artifact browser scenario ${scenarioId} is misclassified.`); + } + + for (const entry of catalog) { + assert.equal(typeof entry.id, "string", "Every artifact browser scenario must have a string ID."); + assert.equal( + typeof entry.shared, + "boolean", + `Artifact browser scenario ${entry.id} must declare shared explicitly.`, + ); + if (entry.shared) { + assert.ok( + SHARED_ARTIFACT_SCENARIO_IDS.includes(entry.id), + `Unknown shared artifact browser scenario ${entry.id} must be added to the shared scenario contract.`, + ); + assert.deepEqual( + entry.runtimes, + SHARED_ARTIFACT_RUNTIMES, + `The core artifact browser scenario ${entry.id} must run for userscript and extension.`, + ); + assert.equal(entry.capability, undefined, `The shared artifact browser scenario ${entry.id} is misclassified.`); + } else { + assert.deepEqual( + entry.runtimes, + ["extension"], + `Extension-only scenario ${entry.id} must run only for extension.`, + ); + assert.ok( + EXTENSION_ONLY_ARTIFACT_CAPABILITIES.includes(entry.capability), + `Extension-only scenario ${entry.id} must declare a supported capability.`, + ); + } + } + return catalog; +} + +function createArtifactBrowserScenarioPlan(catalog = ARTIFACT_BROWSER_SCENARIO_CATALOG) { + return assertArtifactBrowserScenarioCatalog(catalog).flatMap(({ id: scenarioId, runtimes }) => + runtimes.map((runtime) => Object.freeze({ runtime, scenarioId })), + ); +} + +function createSharedArtifactBackendOptions(backendOptions = {}) { + if (!backendOptions || typeof backendOptions !== "object" || Array.isArray(backendOptions)) { + throw new TypeError("Shared artifact backend options must be an object."); + } + const fixture = backendOptions.fixture ?? {}; + if (!fixture || typeof fixture !== "object" || Array.isArray(fixture)) { + throw new TypeError("Shared artifact fixture options must be an object."); + } + return { + ...backendOptions, + fixture: { + ...SHARED_ARTIFACT_FIXTURE_DEFAULTS, + ...fixture, + }, + }; +} function serializeBrowserError(error) { return { @@ -132,6 +237,160 @@ async function createPageSignalCollector(page, runtime) { }; } +function createWorkerRuntimeProbe(signalEndpoint) { + const endpoint = JSON.stringify(signalEndpoint); + const signalStore = JSON.stringify(ARTIFACT_WORKER_SIGNAL_STORE); + return `;(() => { + const endpoint = ${endpoint}; + const signals = []; + Object.defineProperty(globalThis, ${signalStore}, { configurable: false, value: signals, writable: false }); + const serialize = (kind, value, details = {}) => { + let message; + let name = kind === "unhandledrejection" ? "UnhandledRejection" : "Error"; + let stack = null; + if (value instanceof Error || (value && typeof value.message === "string")) { + message = value.message; + name = typeof value.name === "string" ? value.name : name; + stack = typeof value.stack === "string" ? value.stack : null; + } else { + try { + message = typeof value === "string" ? value : JSON.stringify(value); + } catch { + message = String(value); + } + if (message === undefined) message = String(value); + } + return { at: Date.now(), details, kind, message, name, stack }; + }; + const report = (kind, value, details) => { + const signal = serialize(kind, value, details); + signals.push(signal); + void fetch(endpoint, { + body: JSON.stringify(signal), + headers: { "content-type": "text/plain;charset=UTF-8" }, + method: "POST", + }).catch(() => {}); + }; + globalThis.addEventListener("error", (event) => { + report("error", event.error || event.message, { + column: event.colno || null, + filename: event.filename || null, + line: event.lineno || null, + }); + }); + globalThis.addEventListener("unhandledrejection", (event) => report("unhandledrejection", event.reason)); + for (const [method, kind] of [["error", "console-error"], ["warn", "console-warning"]]) { + const original = console[method].bind(console); + console[method] = (...values) => { + report(kind, values.map((value) => (typeof value === "string" ? value : String(value))).join(" ")); + return original(...values); + }; + } + const originalAssert = console.assert.bind(console); + console.assert = (condition, ...values) => { + if (!condition) report("console-assert", values.join(" ") || "Assertion failed"); + return originalAssert(condition, ...values); + }; +})();`; +} + +function createWorkerSignalCollector(worker, apiServer) { + assert.ok(worker && typeof worker.evaluate === "function", "A Playwright MV3 worker is required."); + assert.ok(Array.isArray(apiServer?.workerSignals), "The hermetic server must expose worker signals."); + + const consoleFailures = []; + let evaluatedSignals = []; + let evaluationFailure = null; + + worker.on("console", (message) => { + if (!CONSOLE_FAILURE_TYPES.has(message.type())) return; + consoleFailures.push({ text: message.text(), type: message.type() }); + }); + + async function refresh() { + try { + await worker.evaluate(() => new Promise((resolve) => globalThis.setTimeout(resolve, 0))); + evaluatedSignals = await worker.evaluate((storeName) => { + const signals = globalThis[storeName]; + if (!Array.isArray(signals)) throw new Error(`Missing MV3 worker signal probe ${storeName}`); + return signals.map((signal) => ({ ...signal })); + }, ARTIFACT_WORKER_SIGNAL_STORE); + evaluationFailure = null; + } catch (error) { + evaluationFailure = serializeBrowserError(error); + } + } + + const snapshot = () => ({ + consoleFailures: consoleFailures.map((signal) => ({ ...signal })), + evaluatedSignals: evaluatedSignals.map((signal) => ({ ...signal })), + evaluationFailure: evaluationFailure ? { ...evaluationFailure } : null, + reportedSignals: apiServer.workerSignals.map((signal) => ({ ...signal })), + workerUrl: worker.url(), + }); + + return { + async assertClean(scenarioId) { + await refresh(); + const diagnostics = snapshot(); + const signalCount = + diagnostics.consoleFailures.length + + diagnostics.evaluatedSignals.length + + diagnostics.reportedSignals.length + + Number(Boolean(diagnostics.evaluationFailure)); + assert.equal( + signalCount, + 0, + `extension MV3 worker emitted unexpected runtime signals during ${scenarioId}: ${JSON.stringify( + diagnostics, + null, + 2, + )}`, + ); + return diagnostics; + }, + refresh, + snapshot, + }; +} + +function combineExtensionSignalCollectors(pageCollector, workerCollector) { + return { + async assertClean(scenarioId) { + let pageFailure = null; + let pageDiagnostics; + try { + pageDiagnostics = await pageCollector.assertClean(scenarioId); + } catch (error) { + pageFailure = error; + pageDiagnostics = pageCollector.snapshot(); + } + + let workerFailure = null; + let workerDiagnostics; + try { + workerDiagnostics = await workerCollector.assertClean(scenarioId); + } catch (error) { + workerFailure = error; + workerDiagnostics = workerCollector.snapshot(); + } + + if (pageFailure || workerFailure) { + throw new Error( + `extension emitted unexpected runtime signals during ${scenarioId}: ${JSON.stringify( + { page: pageDiagnostics, worker: workerDiagnostics }, + null, + 2, + )}`, + { cause: pageFailure ?? workerFailure }, + ); + } + return { page: pageDiagnostics, worker: workerDiagnostics }; + }, + snapshot: () => ({ page: pageCollector.snapshot(), worker: workerCollector.snapshot() }), + }; +} + function assertLoopbackOrigin(value) { const url = new URL(value); assert.ok(["http:", "https:"].includes(url.protocol), "The hermetic API origin must use HTTP or HTTPS."); @@ -152,6 +411,34 @@ function removeOwnedTemporaryDirectory(directory, prefix) { fs.rmSync(resolvedDirectory, { force: true, recursive: true }); } +function readGeneratedMv3Contract(artifactDirectory) { + const manifestPath = path.join(artifactDirectory, "manifest.json"); + const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); + assert.equal(manifest.manifest_version, 3, "The extension browser suite requires a generated MV3 artifact."); + assert.equal( + manifest.background?.service_worker, + "ryd.background.js", + "The generated MV3 artifact must declare the built background service worker.", + ); + const contentScript = manifest.content_scripts?.find( + ({ css = [], js = [] }) => js.includes("ryd.content-script.js") && css.includes("content-style.css"), + ); + assert.ok(contentScript, "The generated MV3 artifact must declare its built content script and stylesheet."); + for (const asset of [manifest.background.service_worker, "ryd.content-script.js", "content-style.css"]) { + assert.ok( + fs.statSync(path.join(artifactDirectory, asset)).isFile(), + `The generated MV3 artifact is missing declared asset ${asset}.`, + ); + } + for (const asset of new Set([manifest.background.service_worker, ...contentScript.js])) { + assertProductionJavaScriptOutput(path.join(artifactDirectory, asset), asset); + } + return { + serviceWorkerPath: `/${manifest.background.service_worker.replace(/^\/+/, "")}`, + version: manifest.version, + }; +} + function prepareHermeticExtensionArtifact(sourceDirectory, apiOrigin) { const origin = assertLoopbackOrigin(apiOrigin); const source = path.resolve(sourceDirectory); @@ -189,7 +476,8 @@ function prepareHermeticExtensionArtifact(sourceDirectory, apiOrigin) { changelogListener, "api.runtime.onInstalled.addListener(() => {});", ); - fs.writeFileSync(backgroundBundlePath, hermeticBackground); + const workerSignalEndpoint = `${origin}${WORKER_SIGNAL_PATH}`; + fs.writeFileSync(backgroundBundlePath, `${createWorkerRuntimeProbe(workerSignalEndpoint)}\n${hermeticBackground}`); const contentScriptPath = path.join(extensionDirectory, "ryd.content-script.js"); const contentScriptSource = fs.readFileSync(contentScriptPath, "utf8"); @@ -211,6 +499,7 @@ function prepareHermeticExtensionArtifact(sourceDirectory, apiOrigin) { replacements: { "ryd.background.js": replacementCount, firstInstallChangelogListener: 1 }, routedBundles: ["ryd.content-script.js"], temporaryRoot, + workerSignalEndpoint, }; } @@ -237,10 +526,56 @@ function readRequestBody(request) { async function startHermeticApiServer({ dislikes = 25, likes = 100 } = {}) { const records = []; const unexpectedRequests = []; + const workerSignals = []; + const responsePlans = new Map(); + const responseKey = (method, pathname) => `${method.toUpperCase()} ${pathname}`; + const enqueue = (method, pathname, plannedResponse) => { + const key = responseKey(method, pathname); + const queue = responsePlans.get(key) ?? []; + queue.push(plannedResponse); + responsePlans.set(key, queue); + }; + const defer = (method, pathname) => { + let releaseResponse; + let resolveSeen; + let released = false; + const seen = new Promise((resolve) => { + resolveSeen = resolve; + }); + enqueue(method, pathname, (record) => { + resolveSeen(record); + return new Promise((resolve) => { + releaseResponse = resolve; + }); + }); + return { + get released() { + return released; + }, + release(response) { + if (released) return; + if (!releaseResponse) { + throw new Error(`Cannot release ${responseKey(method, pathname)} before its request is seen`); + } + released = true; + releaseResponse(response); + }, + seen, + }; + }; + const requestsFor = (method, pathname) => + records.filter((record) => record.method === method.toUpperCase() && record.pathname === pathname); + const takePlannedResponse = (record) => { + const queue = responsePlans.get(responseKey(record.method, record.pathname)); + if (!queue?.length) return null; + const planned = queue.shift(); + return typeof planned === "function" ? planned(record) : planned; + }; const server = http.createServer(async (request, response) => { const origin = `http://${request.headers.host}`; const url = new URL(request.url, origin); const record = { + at: Date.now(), body: await readRequestBody(request), method: request.method, pathname: url.pathname, @@ -255,6 +590,16 @@ async function startHermeticApiServer({ dislikes = 25, likes = 100 } = {}) { "content-type": "application/json; charset=utf-8", }; if (request.method === "OPTIONS") { + const requestedMethod = request.headers["access-control-request-method"]; + if (!isAllowedApiPreflight(url.pathname, requestedMethod)) { + unexpectedRequests.push(record); + record.respondedAt = Date.now(); + record.responseBody = { error: "unexpected hermetic preflight" }; + record.responseStatus = 404; + response.writeHead(404, headers); + response.end(JSON.stringify(record.responseBody)); + return; + } record.respondedAt = Date.now(); record.responseBody = null; record.responseStatus = 204; @@ -263,13 +608,24 @@ async function startHermeticApiServer({ dislikes = 25, likes = 100 } = {}) { return; } - let body; - if (request.method === "GET" && url.pathname === "/configs/selectors") body = {}; - else if (request.method === "GET" && url.pathname === "/votes") body = { dislikes, likes, rating: 4.5 }; - else if (request.method === "GET" && url.pathname === "/puzzle/registration") body = ZERO_DIFFICULTY_PUZZLE; - else if (request.method === "POST" && url.pathname === "/puzzle/registration") body = true; - else if (request.method === "POST" && url.pathname === "/interact/vote") body = ZERO_DIFFICULTY_PUZZLE; - else if (request.method === "POST" && url.pathname === "/interact/confirmVote") body = true; + if (request.method === "POST" && url.pathname === WORKER_SIGNAL_PATH) { + workerSignals.push(record.body); + record.respondedAt = Date.now(); + record.responseBody = null; + record.responseStatus = 204; + response.writeHead(204, headers); + response.end(); + return; + } + let defaultBody; + if (request.method === "GET" && url.pathname === "/configs/selectors") defaultBody = {}; + else if (request.method === "GET" && url.pathname === "/votes") { + defaultBody = { dislikes, likes, rating: 4.5 }; + } else if (request.method === "GET" && url.pathname === "/puzzle/registration") { + defaultBody = ZERO_DIFFICULTY_PUZZLE; + } else if (request.method === "POST" && url.pathname === "/puzzle/registration") defaultBody = true; + else if (request.method === "POST" && url.pathname === "/interact/vote") defaultBody = ZERO_DIFFICULTY_PUZZLE; + else if (request.method === "POST" && url.pathname === "/interact/confirmVote") defaultBody = true; else { unexpectedRequests.push(record); record.respondedAt = Date.now(); @@ -280,11 +636,17 @@ async function startHermeticApiServer({ dislikes = 25, likes = 100 } = {}) { return; } + const planned = takePlannedResponse(record); + const resolvedPlan = planned ? await planned : planned; + const responsePlan = resolvedPlan ?? { body: defaultBody }; + if (responsePlan.delayMs) await delay(responsePlan.delayMs); + const body = responsePlan.body === undefined ? defaultBody : responsePlan.body; + const status = responsePlan.status ?? 200; record.respondedAt = Date.now(); record.responseBody = body; - record.responseStatus = 200; - response.writeHead(200, headers); - response.end(JSON.stringify(body)); + record.responseStatus = status; + response.writeHead(status, { ...headers, ...(responsePlan.headers ?? {}) }); + response.end(typeof body === "string" ? body : JSON.stringify(body)); }); await new Promise((resolve, reject) => { @@ -296,9 +658,13 @@ async function startHermeticApiServer({ dislikes = 25, likes = 100 } = {}) { return { close: () => new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))), + defer, + enqueue, origin: `http://127.0.0.1:${address.port}`, records, + requestsFor, unexpectedRequests, + workerSignals, }; } @@ -319,43 +685,93 @@ async function installArtifactRoutes(context, backend, { passthroughOrigin = nul async function waitForWatchResult(page, runtime, videoId) { const profile = LIVE_RUNTIME_PROFILES[runtime]; + const dislikeTextSelector = [ + "dislike-button-view-model #text", + "dislike-button-view-model [role='text']", + "dislike-button-view-model .yt-spec-button-shape-next__button-text-content", + "dislike-button-view-model .ytSpecButtonShapeNextButtonTextContent", + ].join(", "); await page.waitForFunction( - ({ rateBarContainer, videoId: expectedVideoId }) => { - const watch = document.querySelector(`ytd-watch-flexy[video-id="${expectedVideoId}"]`); - const dislikeText = watch - ? document.querySelector('[data-ryd-role="dislike"] #text, [data-ryd-role="dislike"] [role="text"]') + ({ dislikeTextSelector: expectedDislikeTextSelector, rateBarContainer, videoId: expectedVideoId }) => { + const rendered = (element) => { + if (!element?.isConnected || element.closest("[hidden], [aria-hidden='true'], [inert]")) return false; + const box = element.getBoundingClientRect(); + const style = getComputedStyle(element); + return ( + box.width > 0 && + box.height > 0 && + style.display !== "none" && + style.visibility !== "hidden" && + Number.parseFloat(style.opacity) !== 0 + ); + }; + const watch = document.querySelector( + `ytd-watch-flexy[video-id="${expectedVideoId}"], ytd-watch-grid[video-id="${expectedVideoId}"]`, + ); + const actionSurface = watch + ? [...watch.querySelectorAll("#top-level-buttons-computed")].find( + (candidate) => rendered(candidate) && rendered(candidate.querySelector(rateBarContainer)), + ) : null; + const dislikeText = actionSurface?.querySelector(expectedDislikeTextSelector) ?? null; const count = (dislikeText?.textContent ?? "").replace(/\s+/g, " ").trim(); - const bar = document.querySelector(rateBarContainer); - return watch && bar && /\d/.test(count); + return Boolean(actionSurface && rendered(dislikeText) && /\d/.test(count)); }, - { rateBarContainer: profile.selectors.rateBarContainer, videoId }, + { dislikeTextSelector, rateBarContainer: profile.selectors.rateBarContainer, videoId }, ); return page.evaluate( - ({ rateBar, rateBarContainer, videoId: expectedVideoId }) => { - const dislikeText = document.querySelector( - '[data-ryd-role="dislike"] #text, [data-ryd-role="dislike"] [role="text"]', + ({ dislikeTextSelector: expectedDislikeTextSelector, rateBar, rateBarContainer, videoId: expectedVideoId }) => { + const rendered = (element) => { + if (!element?.isConnected || element.closest("[hidden], [aria-hidden='true'], [inert]")) return false; + const box = element.getBoundingClientRect(); + const style = getComputedStyle(element); + return ( + box.width > 0 && + box.height > 0 && + style.display !== "none" && + style.visibility !== "hidden" && + Number.parseFloat(style.opacity) !== 0 + ); + }; + const watch = document.querySelector( + `ytd-watch-flexy[video-id="${expectedVideoId}"], ytd-watch-grid[video-id="${expectedVideoId}"]`, ); - const container = document.querySelector(rateBarContainer); - const fill = document.querySelector(rateBar); + const actionSurface = watch + ? [...watch.querySelectorAll("#top-level-buttons-computed")].find( + (candidate) => rendered(candidate) && rendered(candidate.querySelector(rateBarContainer)), + ) + : null; + const dislikeText = actionSurface?.querySelector(expectedDislikeTextSelector) ?? null; + const container = actionSurface?.querySelector(rateBarContainer); + const fill = actionSurface?.querySelector(rateBar); const visible = (element) => { const box = element?.getBoundingClientRect(); return Boolean(box && box.width > 0 && box.height > 0); }; return { + actionSurfaceVisible: rendered(actionSurface), count: (dislikeText?.textContent ?? "").replace(/\s+/g, " ").trim(), + countVisible: rendered(dislikeText), fillRatio: container && fill && container.getBoundingClientRect().width > 0 ? fill.getBoundingClientRect().width / container.getBoundingClientRect().width : null, fillVisible: visible(fill), + ownedByExpectedWatch: Boolean( + actionSurface && actionSurface.closest("ytd-watch-flexy, ytd-watch-grid") === watch, + ), rateBarVisible: visible(container), - videoId: document.querySelector("ytd-watch-flexy")?.getAttribute("video-id") ?? null, + sameActionSurface: Boolean( + actionSurface && + dislikeText?.closest("#top-level-buttons-computed") === actionSurface && + container?.closest("#top-level-buttons-computed") === actionSurface, + ), + videoId: watch?.getAttribute("video-id") ?? null, expectedVideoId, }; }, - { ...profile.selectors, videoId }, + { ...profile.selectors, dislikeTextSelector, videoId }, ); } @@ -414,63 +830,114 @@ async function preparePendingSpaOutgoingControls(page, fromVideoId) { }, fromVideoId); } -async function finishSpaDestinationReplacement(page, toVideoId) { - return page.evaluate( - ({ expectedLikes, expectedVideoId }) => { - const currentSection = document.querySelector( - `#fixture-page [data-fixture-page-kind="watch"][data-fixture-video-id="${expectedVideoId}"]`, - ); - if (!currentSection) throw new Error(`The destination watch fixture for ${expectedVideoId} is missing.`); - if (!globalThis.__artifactInsideOutgoingActions) { - throw new Error("The retained inside-current-root outgoing controls are missing."); - } +function replaceSpaDestinationInPage({ expectedLikes, expectedVideoId, nativeHydrationDelay, beforeNavigation }) { + const replace = () => { + const currentSection = document.querySelector( + `#fixture-page [data-fixture-page-kind="watch"][data-fixture-video-id="${expectedVideoId}"]`, + ); + if (!currentSection) throw new Error(`The destination watch fixture for ${expectedVideoId} is missing.`); + if (!globalThis.__artifactInsideOutgoingActions) { + throw new Error("The retained inside-current-root outgoing controls are missing."); + } - const insideHolder = document.createElement("div"); - insideHolder.hidden = true; - insideHolder.setAttribute("data-artifact-outgoing-position", "inside-current-root"); - insideHolder.setAttribute( - "data-artifact-outgoing-video-id", - globalThis.__artifactInsideOutgoingActions - .querySelector("[data-fixture-control-video-id]") - ?.getAttribute("data-fixture-control-video-id") ?? "unknown", - ); - insideHolder.appendChild(globalThis.__artifactInsideOutgoingActions); - currentSection.appendChild(insideHolder); - delete globalThis.__artifactInsideOutgoingActions; + const insideHolder = document.createElement("div"); + insideHolder.hidden = true; + insideHolder.setAttribute("data-artifact-outgoing-position", "inside-current-root"); + insideHolder.setAttribute( + "data-artifact-outgoing-video-id", + globalThis.__artifactInsideOutgoingActions + .querySelector("[data-fixture-control-video-id]") + ?.getAttribute("data-fixture-control-video-id") ?? "unknown", + ); + insideHolder.appendChild(globalThis.__artifactInsideOutgoingActions); + const watchRoot = currentSection.querySelector( + `ytd-watch-flexy[video-id="${expectedVideoId}"], ytd-watch-grid[video-id="${expectedVideoId}"]`, + ); + if (!watchRoot) throw new Error(`The destination Watch root for ${expectedVideoId} is missing.`); + watchRoot.appendChild(insideHolder); + delete globalThis.__artifactInsideOutgoingActions; - const replaced = globalThis.__navigationFixture.replaceCurrentWatchActions({ retainOutgoing: true }); - if (!replaced) throw new Error(`The destination action container for ${expectedVideoId} was not replaced.`); - const destinationActions = currentSection.querySelector( - `#top-level-buttons-computed[data-fixture-watch-actions-replacement="${expectedVideoId}"]`, - ); - const likeButton = destinationActions?.querySelector('[data-ryd-role="like"] button'); - if (!likeButton) throw new Error(`The replacement controls for ${expectedVideoId} have no Like button.`); - likeButton.setAttribute("aria-label", `${expectedLikes} likes`); - const likeText = likeButton.querySelector("#text, [role='text']"); - if (likeText) likeText.textContent = String(expectedLikes); - return { destinationReplaced: true, insideBarCount: insideHolder.querySelectorAll(".ryd-tooltip").length }; + const currentActions = watchRoot.querySelector(":scope > #top-row #top-level-buttons-computed"); + const currentLikeButton = currentActions?.querySelector('[data-fixture-role="like"] button'); + if (!currentLikeButton) throw new Error(`The destination controls for ${expectedVideoId} have no Like button.`); + currentLikeButton.setAttribute("aria-label", `${expectedLikes} likes`); + const currentLikeText = currentLikeButton.querySelector("#text, [role='text']"); + if (currentLikeText) currentLikeText.textContent = String(expectedLikes); + + const nativeHydrationStartedAt = Date.now(); + const replaced = globalThis.__navigationFixture.replaceCurrentWatchActions({ + nativeHydrationDelay, + retainOutgoing: true, + }); + if (!replaced) throw new Error(`The destination action container for ${expectedVideoId} was not replaced.`); + const destinationActions = currentSection.querySelector( + `#top-level-buttons-computed[data-fixture-watch-actions-replacement="${expectedVideoId}"]`, + ); + const likeButton = destinationActions?.querySelector('[data-fixture-role="like"] button'); + if (nativeHydrationDelay === 0 && !likeButton) { + throw new Error(`The replacement controls for ${expectedVideoId} have no Like button.`); + } + return { + destinationReplaced: true, + insideBarCount: insideHolder.querySelectorAll(".ryd-tooltip").length, + nativeHydrationDelay, + nativeHydrationStartedAt, + }; + }; + if (!beforeNavigation) return replace(); + delete globalThis.__artifactPreparedDestinationReplacement; + document.addEventListener( + "yt-navigate-finish", + () => { + // Capture runs before the extension's navigation listeners, so they never + // see hydrated destination controls that the fixture removes afterwards. + globalThis.__artifactPreparedDestinationReplacement = replace(); }, - { expectedLikes: SPA_COUNTS[toVideoId].likes, expectedVideoId: toVideoId }, + { capture: true, once: true }, ); } +async function finishSpaDestinationReplacement( + page, + toVideoId, + { nativeHydrationDelay = 0, beforeNavigation = false } = {}, +) { + return page.evaluate(replaceSpaDestinationInPage, { + expectedLikes: SPA_COUNTS[toVideoId].likes, + expectedVideoId: toVideoId, + nativeHydrationDelay, + beforeNavigation, + }); +} + async function observeSpaDestinationDislikeText(page, videoId) { + await page.waitForFunction((expectedVideoId) => { + const currentRoot = document.querySelector( + `#fixture-page [data-fixture-page-kind="watch"][data-fixture-video-id="${expectedVideoId}"]`, + ); + return Boolean( + currentRoot?.querySelector( + `#top-level-buttons-computed[data-fixture-watch-actions-replacement="${expectedVideoId}"] ` + + `[data-fixture-control-video-id="${expectedVideoId}"] [data-fixture-role="dislike"]`, + ), + ); + }, videoId); await page.evaluate((expectedVideoId) => { globalThis.__artifactDestinationDislikeTextObserver?.disconnect(); const currentRoot = document.querySelector( `#fixture-page [data-fixture-page-kind="watch"][data-fixture-video-id="${expectedVideoId}"]`, ); - const count = currentRoot?.querySelector( + const dislike = currentRoot?.querySelector( `#top-level-buttons-computed[data-fixture-watch-actions-replacement="${expectedVideoId}"] ` + - `[data-fixture-control-video-id="${expectedVideoId}"] [data-ryd-role="dislike"] #text`, + `[data-fixture-control-video-id="${expectedVideoId}"] [data-fixture-role="dislike"]`, ); - if (!count) throw new Error(`The destination dislike text for ${expectedVideoId} is missing.`); - const read = () => (count.textContent ?? "").replace(/\s+/g, " ").trim(); + if (!dislike) throw new Error(`The destination Dislike control for ${expectedVideoId} is missing.`); + const read = () => (dislike.querySelector("#text, [role='text']")?.textContent ?? "").replace(/\s+/g, " ").trim(); globalThis.__artifactDestinationDislikeTexts = [read()]; globalThis.__artifactDestinationDislikeTextObserver = new MutationObserver(() => { globalThis.__artifactDestinationDislikeTexts.push(read()); }); - globalThis.__artifactDestinationDislikeTextObserver.observe(count, { + globalThis.__artifactDestinationDislikeTextObserver.observe(dislike, { characterData: true, childList: true, subtree: true, @@ -511,7 +978,7 @@ async function readSpaWatchSnapshot(page, runtime, fromVideoId, toVideoId) { ); const controls = actionHost?.querySelector(`[data-fixture-control-video-id="${destinationVideoId}"]`); const countElement = controls?.querySelector( - '[data-ryd-role="dislike"] #text, [data-ryd-role="dislike"] [role="text"]', + '[data-fixture-role="dislike"] #text, [data-fixture-role="dislike"] [role="text"]', ); const wrapper = actionHost?.querySelector(":scope > .ryd-tooltip"); const container = wrapper?.querySelector(runtimeProfile.selectors.rateBarContainer); @@ -596,7 +1063,7 @@ async function clickSpaDestinationDislike(page, videoId) { const selector = `#fixture-page [data-fixture-page-kind="watch"][data-fixture-video-id="${videoId}"] ` + `#top-level-buttons-computed[data-fixture-watch-actions-replacement="${videoId}"] ` + - `[data-fixture-control-video-id="${videoId}"] [data-ryd-role="dislike"] button`; + `[data-fixture-control-video-id="${videoId}"] [data-fixture-role="dislike"] button`; const buttons = page.locator(selector); assert.equal(await buttons.count(), 1, `Expected exactly one destination Dislike activation target for ${videoId}.`); const button = buttons.first(); @@ -610,6 +1077,76 @@ async function clickSpaDestinationDislike(page, videoId) { return { ariaPressedBefore, selector, videoId }; } +async function cloneRenderedSpaControlsAndActivateDislike(page, videoId) { + return page.evaluate((expectedVideoId) => { + const actionHost = document.querySelector( + `#fixture-page [data-fixture-page-kind="watch"][data-fixture-video-id="${expectedVideoId}"] ` + + `#top-level-buttons-computed[data-fixture-watch-actions-replacement="${expectedVideoId}"]`, + ); + if (!actionHost) throw new Error(`The rendered destination action host for ${expectedVideoId} is missing.`); + + const barSelector = "#ryd-bar, #return-youtube-dislike-bar"; + const countSelector = '[data-fixture-role="dislike"] #text, [data-fixture-role="dislike"] [role="text"]'; + const visible = (element) => { + if (!element?.isConnected) return false; + const bounds = element.getBoundingClientRect(); + const style = getComputedStyle(element); + return ( + bounds.width > 0 && + bounds.height > 0 && + style.display !== "none" && + style.visibility !== "hidden" && + Number.parseFloat(style.opacity) !== 0 + ); + }; + const countBefore = actionHost.querySelector(countSelector)?.textContent?.trim() ?? null; + const barBefore = actionHost.querySelector(barSelector); + const buttonBefore = actionHost.querySelector('[data-fixture-role="dislike"] button'); + if (!visible(actionHost) || !visible(countBefore === null ? null : actionHost.querySelector(countSelector))) { + throw new Error(`The destination count for ${expectedVideoId} is not visibly rendered before cloning.`); + } + if (!visible(barBefore) || !visible(buttonBefore)) { + throw new Error( + `The destination button and rate bar for ${expectedVideoId} are not visibly rendered before cloning.`, + ); + } + + const replacement = actionHost.cloneNode(true); + replacement.setAttribute("data-artifact-cloned-rendered-actions", "true"); + actionHost.replaceWith(replacement); + const activationTarget = replacement.querySelector('[data-fixture-role="dislike"] button'); + const count = replacement.querySelector(countSelector); + const bar = replacement.querySelector(barSelector); + if (!visible(replacement) || !visible(activationTarget) || !visible(count) || !visible(bar)) { + throw new Error(`The cloned destination presentation for ${expectedVideoId} is not visibly complete.`); + } + + const ariaPressedBefore = activationTarget.getAttribute("aria-pressed"); + let clickObservedAtDocument = false; + document.addEventListener( + "click", + (event) => { + clickObservedAtDocument = event.composedPath().includes(activationTarget); + }, + { capture: true, once: true }, + ); + activationTarget.click(); + return { + ariaDisabled: activationTarget.getAttribute("aria-disabled"), + ariaPressedAfterSynchronousClick: activationTarget.getAttribute("aria-pressed"), + ariaPressedBefore, + barCount: replacement.querySelectorAll(barSelector).length, + buttonDisabled: activationTarget.disabled, + clickObservedAtDocument, + countAfterSynchronousClick: count.textContent?.trim() ?? null, + countBefore, + countContainerCount: replacement.querySelectorAll(countSelector).length, + presentationCloned: replacement !== actionHost && !actionHost.isConnected, + videoId: expectedVideoId, + }; + }, videoId); +} + function interactionRecordsSince(records, startIndex) { return records .slice(startIndex) @@ -641,7 +1178,14 @@ function readArtifactVoteHandshake(records, startIndex, videoId, value) { interactionCount: interactions.length, sharedUserId: typeof userId === "string" && userId.length > 0 && confirmation?.body?.userId === userId ? userId : null, - vote: vote ? { body: vote.body } : null, + vote: vote + ? { + body: vote.body, + responded: Number.isFinite(vote.respondedAt), + responseBody: vote.responseBody, + responseStatus: vote.responseStatus, + } + : null, voteCount: votes.length, }; } @@ -660,6 +1204,7 @@ function enqueueRecordedSuccessfulVoteResponses(backend) { } function isArtifactVoteHandshakeValid(snapshot) { + const isSuccessfulStatus = (status) => Number.isInteger(status) && status >= 200 && status < 300; if ( snapshot.interactionCount !== 2 || snapshot.voteCount !== 1 || @@ -667,44 +1212,35 @@ function isArtifactVoteHandshakeValid(snapshot) { typeof snapshot.sharedUserId !== "string" || !/^[A-Za-z0-9]{36}$/.test(snapshot.sharedUserId) || snapshot.interactionPaths?.join(",") !== "/interact/vote,/interact/confirmVote" || + snapshot.vote?.responded !== true || + !isSuccessfulStatus(snapshot.vote?.responseStatus) || snapshot.confirmation?.responded !== true || - snapshot.confirmation?.responseStatus !== 200 || + !isSuccessfulStatus(snapshot.confirmation?.responseStatus) || snapshot.confirmation?.responseBody !== true ) { return false; } - const voteBody = snapshot.vote?.body; - const confirmationBody = snapshot.confirmation?.body; - let solutionBytes = null; - try { - solutionBytes = Buffer.from(confirmationBody?.solution ?? "", "base64"); - } catch { - // The validity result below reports malformed proof material without throwing from a polling predicate. - } - return ( - voteBody?.userId === snapshot.sharedUserId && - voteBody?.videoId === snapshot.expectedVideoId && - voteBody?.value === snapshot.expectedValue && - Object.keys(voteBody).sort().join(",") === "userId,value,videoId" && - confirmationBody?.userId === snapshot.sharedUserId && - confirmationBody?.videoId === snapshot.expectedVideoId && - Object.keys(confirmationBody).sort().join(",") === "solution,userId,videoId" && - typeof confirmationBody?.solution === "string" && - solutionBytes?.length === 4 - ); + return isVoteProtocolBodyPairValid(snapshot.vote?.body, snapshot.confirmation?.body, { + value: snapshot.expectedValue, + videoId: snapshot.expectedVideoId, + }); } function assertSpaStatsTraffic(backend, fromVideoId, toVideoId) { - const votesFor = (videoId) => - backend.requestsFor("GET", "/votes").filter((request) => request.query.videoId === videoId); - assert.equal(votesFor(fromVideoId).length, 1, `Expected one stats request for outgoing video ${fromVideoId}.`); - assert.equal(votesFor(toVideoId).length, 1, `Expected one stats request for destination video ${toVideoId}.`); + const votes = assertExactSuccessfulVotesTraffic( + backend.requests, + [fromVideoId, toVideoId], + "The shared artifact SPA contract", + ); assert.deepEqual( backend.blockedRequests, [], `The SPA scenario attempted unexpected network traffic: ${JSON.stringify(backend.blockedRequests)}`, ); - return { fromVideoRequests: votesFor(fromVideoId).length, toVideoRequests: votesFor(toVideoId).length }; + return { + fromVideoRequests: votes.filter((request) => request.query.videoId === fromVideoId).length, + toVideoRequests: votes.filter((request) => request.query.videoId === toVideoId).length, + }; } function assertSpaBackendTraffic(backend, fromVideoId, toVideoId) { @@ -733,7 +1269,7 @@ async function readWatchDiagnostics(page, runtime, videoId) { currentVideoId: document.querySelector("ytd-watch-flexy")?.getAttribute("video-id") ?? null, dislikeText: document - .querySelector('[data-ryd-role="dislike"] #text, [data-ryd-role="dislike"] [role="text"]') + .querySelector("dislike-button-view-model #text, dislike-button-view-model [role='text']") ?.textContent?.trim() ?? null, expectedVideoId, fillPresent: document.querySelector(rateBar) !== null, @@ -749,11 +1285,13 @@ class HermeticUserscriptArtifactAdapter { artifactPath = DEFAULT_USERSCRIPT_ARTIFACT, backendOptions = {}, browserType = chromium, + disableVoteSubmission = false, headless = true, } = {}) { this.artifactPath = path.resolve(artifactPath); - this.backendOptions = backendOptions; + this.backendOptions = createSharedArtifactBackendOptions(backendOptions); this.browserType = browserType; + this.disableVoteSubmission = disableVoteSubmission; this.headless = headless; this.profile = LIVE_RUNTIME_PROFILES.userscript; this.runtime = "userscript"; @@ -762,7 +1300,6 @@ class HermeticUserscriptArtifactAdapter { async start() { if (!fs.existsSync(this.artifactPath)) throw new Error(`Generated userscript is missing: ${this.artifactPath}`); this.backend = createFakeBackend(this.backendOptions); - enqueueRecordedSuccessfulVoteResponses(this.backend); this.browser = await this.browserType.launch({ headless: this.headless }); this.context = await this.browser.newContext({ serviceWorkers: "block" }); await installGmEnvironment(this.context); @@ -773,12 +1310,18 @@ class HermeticUserscriptArtifactAdapter { async openWatch(videoId) { await openWatchFixture(this.page, videoId); - await this.page.addScriptTag({ path: this.artifactPath }); + await injectGeneratedUserscript(this.page, { + artifactPath: this.artifactPath, + disableVoteSubmission: this.disableVoteSubmission, + }); } async openSpaWatch(videoId) { await openNavigationFixture(this.page, { pageKind: "watch", videoId }); - await this.page.addScriptTag({ path: this.artifactPath }); + await injectGeneratedUserscript(this.page, { + artifactPath: this.artifactPath, + disableVoteSubmission: this.disableVoteSubmission, + }); } async navigateSpaWatch(fromVideoId, toVideoId) { @@ -788,12 +1331,58 @@ class HermeticUserscriptArtifactAdapter { return { destination, outgoing }; } + deferNextStatsRequest() { + return this.backend.defer("GET", "/votes"); + } + + async navigateSpaWatchWhilePending(fromVideoId, toVideoId) { + const outgoing = await preparePendingSpaOutgoingControls(this.page, fromVideoId); + await this.page.locator("#watch-related").click(); + const destination = await finishSpaDestinationReplacement(this.page, toVideoId); + await observeSpaDestinationDislikeText(this.page, toVideoId); + return { destination, outgoing }; + } + + async readDestinationDislikeTextHistory() { + return this.page.evaluate(() => [...(globalThis.__artifactDestinationDislikeTexts ?? [])]); + } + + deferInteractionResponse(pathname) { + return this.backend.defer("POST", pathname); + } + + enqueueInteractionResponse(pathname, response) { + this.backend.enqueue("POST", pathname, response); + } + + async setVoteSubmissionDisabled(disabled) { + this.disableVoteSubmission = disabled === true; + } + + readInteractionRecords() { + return this.backend.requests; + } + + readStatsRequestTimings() { + return this.backend.requestsFor("GET", "/votes").map(({ at, query, respondedAt }) => ({ + at, + query: { ...query }, + respondedAt, + })); + } + async activateSpaDislike(videoId) { const interactionStartIndex = this.backend.requests.length; const activation = await clickSpaDestinationDislike(this.page, videoId); return { ...activation, interactionStartIndex }; } + async activateClonedSpaDislike(videoId) { + const interactionStartIndex = this.backend.requests.length; + const activation = await cloneRenderedSpaControlsAndActivateDislike(this.page, videoId); + return { ...activation, interactionStartIndex }; + } + async readSpaVoteHandshake(interactionStartIndex, videoId, value) { return readArtifactVoteHandshake(this.backend.requests, interactionStartIndex, videoId, value); } @@ -861,47 +1450,103 @@ class HermeticExtensionArtifactAdapter { backendOptions = {}, browserType = chromium, channel = "chromium", + contextOptions = {}, headless = true, + selectorResponse = { body: { rateBar: { oldDesignActions: ["#top-level-buttons-computed"] } } }, } = {}) { if (!apiServer?.origin) throw new TypeError("The extension adapter requires a running hermetic API server."); this.apiServer = apiServer; this.artifactDirectory = path.resolve(artifactDirectory); - this.backendOptions = backendOptions; + this.backendOptions = createSharedArtifactBackendOptions(backendOptions); this.browserType = browserType; this.channel = channel; + this.contextOptions = contextOptions; this.headless = headless; + this.selectorResponse = selectorResponse; this.profile = LIVE_RUNTIME_PROFILES.extension; this.runtime = "extension"; } async start() { + this.artifactContract = readGeneratedMv3Contract(this.artifactDirectory); this.backend = createFakeBackend(this.backendOptions); - this.backend.enqueue("GET", "/configs/selectors", { - body: { rateBar: { oldDesignActions: ["#top-level-buttons-computed"] } }, - }); + if (this.selectorResponse !== null) { + this.backend.enqueue("GET", "/configs/selectors", this.selectorResponse); + } this.apiRecordStart = this.apiServer.records.length; this.preparedArtifact = prepareHermeticExtensionArtifact(this.artifactDirectory, this.apiServer.origin); this.profileDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "ryd-mv3-profile-")); const extensionPath = this.preparedArtifact.extensionDirectory; this.context = await this.browserType.launchPersistentContext(this.profileDirectory, { - args: [`--disable-extensions-except=${extensionPath}`, `--load-extension=${extensionPath}`, "--no-first-run"], + ...this.contextOptions, + args: [ + `--disable-extensions-except=${extensionPath}`, + `--load-extension=${extensionPath}`, + "--host-resolver-rules=MAP * ~NOTFOUND, EXCLUDE localhost, EXCLUDE 127.0.0.1", + "--no-first-run", + ], channel: this.channel, headless: this.headless, serviceWorkers: "allow", }); await installArtifactRoutes(this.context, this.backend, { passthroughOrigin: this.apiServer.origin }); - this.worker = this.context.serviceWorkers()[0]; - if (!this.worker) this.worker = await this.context.waitForEvent("serviceworker", { timeout: 15_000 }); - assert.match(this.worker.url(), /^chrome-extension:\/\//, "The real MV3 background worker did not start."); + const isArtifactWorker = (worker) => new URL(worker.url()).pathname === this.artifactContract.serviceWorkerPath; + this.worker = this.context.serviceWorkers().find(isArtifactWorker); + if (!this.worker) { + this.worker = await this.context.waitForEvent("serviceworker", { + predicate: isArtifactWorker, + timeout: 15_000, + }); + } + const workerUrl = new URL(this.worker.url()); + assert.equal(workerUrl.protocol, "chrome-extension:", "The real MV3 background worker did not start."); + assert.match(workerUrl.hostname, /^[a-p]{32}$/, "The MV3 worker has no valid generated extension ID."); + assert.equal( + workerUrl.pathname, + this.artifactContract.serviceWorkerPath, + "Chromium started a different extension worker than the generated artifact declares.", + ); + this.extensionId = workerUrl.hostname; + this.workerSignals = createWorkerSignalCollector(this.worker, this.apiServer); this.page = await this.context.newPage(); - this.pageSignals = await createPageSignalCollector(this.page, this.runtime); + const pageSignals = await createPageSignalCollector(this.page, this.runtime); + this.pageSignals = combineExtensionSignalCollectors(pageSignals, this.workerSignals); } async openWatch(videoId) { await openWatchFixture(this.page, videoId); } + async setPremiumTeaserHidden(hidden) { + const stored = await this.worker.evaluate(async (value) => { + // Wait for the background's initial default write before applying the + // scenario preference; otherwise its pending write can replace our value. + await new Promise((resolve, reject) => { + const finish = (error) => { + clearTimeout(timeout); + chrome.storage.onChanged.removeListener(changed); + if (error) reject(error); + else resolve(); + }; + const changed = (changes, area) => { + if (area === "sync" && changes.hidePremiumTeaser?.newValue !== undefined) finish(); + }; + const timeout = setTimeout( + () => finish(new Error("The extension did not initialize hidePremiumTeaser.")), + 2_000, + ); + chrome.storage.onChanged.addListener(changed); + chrome.storage.sync.get(["hidePremiumTeaser"]).then((state) => { + if (state.hidePremiumTeaser !== undefined) finish(); + }, finish); + }); + await chrome.storage.sync.set({ hidePremiumTeaser: value }); + return (await chrome.storage.sync.get(["hidePremiumTeaser"])).hidePremiumTeaser; + }, hidden === true); + assert.equal(stored, hidden === true, "The premium teaser preference was not stored."); + } + async openSpaWatch(videoId) { await openNavigationFixture(this.page, { pageKind: "watch", videoId }); } @@ -909,7 +1554,9 @@ class HermeticExtensionArtifactAdapter { async navigateSpaWatch(fromVideoId, toVideoId) { const outgoing = await prepareSpaOutgoingControls(this.page, fromVideoId); await this.page.locator("#watch-related").click(); - const destination = await finishSpaDestinationReplacement(this.page, toVideoId); + const destination = await finishSpaDestinationReplacement(this.page, toVideoId, { + nativeHydrationDelay: ARTIFACT_NATIVE_HYDRATION_DELAY_MS, + }); return { destination, outgoing }; } @@ -919,12 +1566,46 @@ class HermeticExtensionArtifactAdapter { async navigateSpaWatchWhilePending(fromVideoId, toVideoId) { const outgoing = await preparePendingSpaOutgoingControls(this.page, fromVideoId); + await finishSpaDestinationReplacement(this.page, toVideoId, { + beforeNavigation: true, + nativeHydrationDelay: ARTIFACT_NATIVE_HYDRATION_DELAY_MS, + }); await this.page.locator("#watch-related").click(); - const destination = await finishSpaDestinationReplacement(this.page, toVideoId); + const destination = await this.page.evaluate(() => { + const result = globalThis.__artifactPreparedDestinationReplacement; + delete globalThis.__artifactPreparedDestinationReplacement; + if (!result) throw new Error("The destination controls were not prepared during navigation."); + return result; + }); await observeSpaDestinationDislikeText(this.page, toVideoId); return { destination, outgoing }; } + deferInteractionResponse(pathname) { + return this.apiServer.defer("POST", pathname); + } + + enqueueInteractionResponse(pathname, response) { + this.apiServer.enqueue("POST", pathname, response); + } + + readInteractionRecords() { + return this.apiServer.records.slice(this.apiRecordStart); + } + + async setVoteSubmissionDisabled(disabled) { + await this.worker.evaluate(async (value) => { + const deadline = Date.now() + 2_000; + while ((await chrome.storage.sync.get(["disableVoteSubmission"])).disableVoteSubmission === undefined) { + if (Date.now() >= deadline) throw new Error("The extension did not initialize disableVoteSubmission."); + await new Promise((resolve) => setTimeout(resolve, 20)); + } + await chrome.storage.sync.set({ disableVoteSubmission: value }); + const stored = (await chrome.storage.sync.get(["disableVoteSubmission"])).disableVoteSubmission; + if (stored !== value) throw new Error("The extension did not persist disableVoteSubmission."); + }, disabled === true); + } + async readDestinationDislikeTextHistory() { return this.page.evaluate(() => [...(globalThis.__artifactDestinationDislikeTexts ?? [])]); } @@ -943,6 +1624,12 @@ class HermeticExtensionArtifactAdapter { return { ...activation, interactionStartIndex }; } + async activateClonedSpaDislike(videoId) { + const interactionStartIndex = this.apiServer.records.length; + const activation = await cloneRenderedSpaControlsAndActivateDislike(this.page, videoId); + return { ...activation, interactionStartIndex }; + } + async readSpaVoteHandshake(interactionStartIndex, videoId, value) { return readArtifactVoteHandshake(this.apiServer.records, interactionStartIndex, videoId, value); } @@ -1056,6 +1743,18 @@ async function runArtifactWatchRenderScenario(adapter, { videoId = VIDEO_A } = { await adapter.openWatch(videoId); const result = await adapter.waitForWatchResult(videoId); assert.equal(result.videoId, videoId); + assert.equal( + result.ownedByExpectedWatch, + true, + `${adapter.runtime} rendered the result outside the expected watch root.`, + ); + assert.equal( + result.sameActionSurface, + true, + `${adapter.runtime} rendered the count and bar in different action surfaces.`, + ); + assert.equal(result.actionSurfaceVisible, true, `${adapter.runtime} rendered into a hidden watch action surface.`); + assert.equal(result.countVisible, true, `${adapter.runtime} rendered a hidden dislike count.`); assert.equal(result.rateBarVisible, true, `${adapter.runtime} did not render a visible watch ratio bar.`); assert.equal(result.fillVisible, true, `${adapter.runtime} did not render a visible watch ratio fill.`); assert.match(result.count, /\d/, `${adapter.runtime} did not render a numeric dislike count.`); @@ -1169,6 +1868,14 @@ async function runArtifactWatchSpaSetup(adapter, configuration) { `${adapter.runtime} destination watch first became valid after ${readiness.firstValidMs}ms; ` + `the budget is ${maxFirstValidMs}ms.`, ); + if (mutation.destination.nativeHydrationDelay > 0) { + assert.ok(readiness.invalidSamples > 0, `${adapter.runtime} became valid before delayed controls hydrated.`); + assert.ok( + readiness.firstValidMs >= mutation.destination.nativeHydrationDelay - intervalMs * 2, + `${adapter.runtime} became valid after ${readiness.firstValidMs}ms, before the ` + + `${mutation.destination.nativeHydrationDelay}ms native-control delay elapsed.`, + ); + } const stability = await assertInvariantContinuously({ durationMs: stabilityDurationMs, intervalMs, @@ -1231,16 +1938,24 @@ async function runArtifactWatchSpaScenario(adapter, options = {}) { } } -async function runArtifactWatchSpaVoteScenario( +async function runArtifactWatchSpaVoteContract( adapter, - { handshakeStableForMs = 1_000, handshakeTimeoutMs = 10_000, voteValue = -1, ...spaOptions } = {}, + { + activationMethod, + expectClonedPresentation = false, + handshakeStableForMs = 1_000, + handshakeTimeoutMs = 10_000, + scenarioId, + voteValue = -1, + ...spaOptions + }, ) { assert.ok( - SHARED_ARTIFACT_SCENARIO_IDS.includes(ARTIFACT_WATCH_SPA_VOTE_SCENARIO_ID), - `${ARTIFACT_WATCH_SPA_VOTE_SCENARIO_ID} must remain in the shared artifact scenario catalog.`, + SHARED_ARTIFACT_SCENARIO_IDS.includes(scenarioId), + `${scenarioId} must remain in the shared artifact scenario catalog.`, ); assertArtifactRuntimeAdapter(adapter, [ - "activateSpaDislike", + activationMethod, "assertNoPageSignals", "assertSpaVoteNetwork", "close", @@ -1257,12 +1972,31 @@ async function runArtifactWatchSpaVoteScenario( try { await adapter.start(); const result = await runArtifactWatchSpaSetup(adapter, configuration); - const activation = await adapter.activateSpaDislike(configuration.toVideoId); + const activation = await adapter[activationMethod](configuration.toVideoId); assert.ok( Number.isInteger(activation.interactionStartIndex) && activation.interactionStartIndex >= 0, "The adapter did not return a valid interaction record boundary.", ); assert.equal(activation.videoId, configuration.toVideoId, "The adapter activated the wrong destination video."); + if (expectClonedPresentation) { + assert.equal(activation.presentationCloned, true, "The initialized destination presentation was not cloned."); + assert.equal(activation.barCount, 1, "The cloned destination did not visibly retain exactly one rate bar."); + assert.equal( + activation.countContainerCount, + 1, + "The cloned destination did not visibly retain exactly one Dislike count.", + ); + assert.equal( + activation.countBefore, + String(configuration.toCounts.dislikes), + "The cloned destination did not start from the rendered destination Dislike count.", + ); + assert.equal( + activation.countAfterSynchronousClick, + String(configuration.toCounts.dislikes + 1), + `The first immediate click on cloned initialized controls was lost: ${JSON.stringify(activation)}`, + ); + } let handshake; try { @@ -1286,11 +2020,15 @@ async function runArtifactWatchSpaVoteScenario( configuration.toVideoId, activation.interactionStartIndex, ); - await adapter.assertNoPageSignals(ARTIFACT_WATCH_SPA_VOTE_SCENARIO_ID); + await adapter.assertNoPageSignals(scenarioId); return { ...result, - activation: { ariaPressedBefore: activation.ariaPressedBefore, videoId: activation.videoId }, + activation: { + ariaPressedBefore: activation.ariaPressedBefore, + presentationCloned: activation.presentationCloned === true, + videoId: activation.videoId, + }, handshake: { confirmationRequests: handshakeSnapshot.confirmationCount, confirmationStatus: handshakeSnapshot.confirmation.responseStatus, @@ -1302,10 +2040,12 @@ async function runArtifactWatchSpaVoteScenario( userId: handshakeSnapshot.sharedUserId, value: voteValue, videoId: configuration.toVideoId, + voteResponded: handshakeSnapshot.vote.responded, voteRequests: handshakeSnapshot.voteCount, + voteStatus: handshakeSnapshot.vote.responseStatus, }, runtime: adapter.runtime, - scenarioId: ARTIFACT_WATCH_SPA_VOTE_SCENARIO_ID, + scenarioId, traffic: { ...network, confirmationRequests: handshakeSnapshot.confirmationCount, @@ -1318,9 +2058,31 @@ async function runArtifactWatchSpaVoteScenario( } } +async function runArtifactWatchSpaVoteScenario(adapter, options = {}) { + return runArtifactWatchSpaVoteContract(adapter, { + ...options, + activationMethod: "activateSpaDislike", + scenarioId: ARTIFACT_WATCH_SPA_VOTE_SCENARIO_ID, + }); +} + +async function runArtifactWatchSpaClonedVoteScenario(adapter, options = {}) { + return runArtifactWatchSpaVoteContract(adapter, { + ...options, + activationMethod: "activateClonedSpaDislike", + expectClonedPresentation: true, + scenarioId: ARTIFACT_WATCH_SPA_CLONED_VOTE_SCENARIO_ID, + }); +} + async function runExtensionDelayedOutgoingFailureScenario( adapter, - { fromVideoId = VIDEO_A, maxDestinationRequestDelayMs = 250, requestTimeoutMs = 5_000, toVideoId = VIDEO_B } = {}, + { + fromVideoId = VIDEO_A, + maxDestinationRequestReadyDelayMs = 250, + requestTimeoutMs = 5_000, + toVideoId = VIDEO_B, + } = {}, ) { assert.equal(adapter?.runtime, "extension", "The delayed outgoing failure scenario requires the extension runtime."); assertArtifactRuntimeAdapter(adapter, [ @@ -1333,6 +2095,7 @@ async function runExtensionDelayedOutgoingFailureScenario( "readDestinationDislikeTextHistory", "readSpaWatchSnapshot", "readStatsRequestTimings", + "setPremiumTeaserHidden", "start", "waitForWatchResult", ]); @@ -1340,6 +2103,11 @@ async function runExtensionDelayedOutgoingFailureScenario( try { await adapter.start(); + // This scenario times native-controls initialization. The optional teaser can + // fetch counts before hydration and share that request with the main renderer, + // so hide it before loading either video to keep /votes timing attributable. + // The shared SPA scenarios retain the default teaser-enabled configuration. + await adapter.setPremiumTeaserHidden(true); const outgoingRequest = adapter.deferNextStatsRequest(); await adapter.openSpaWatch(fromVideoId); let requestTimeout; @@ -1384,10 +2152,17 @@ async function runExtensionDelayedOutgoingFailureScenario( const destinationRequests = timings.filter((record) => record.query.videoId === toVideoId); assert.equal(destinationRequests.length, 1, `Expected one destination stats request for ${toVideoId}.`); const destinationRequestDelayMs = destinationRequests[0].at - releasedAt; + const destinationControlsReadyAt = + mutation.destination.nativeHydrationStartedAt + mutation.destination.nativeHydrationDelay; + const destinationRequestReadyDelayMs = destinationRequests[0].at - destinationControlsReadyAt; assert.ok( - destinationRequestDelayMs <= maxDestinationRequestDelayMs, - `The queued destination initialization waited ${destinationRequestDelayMs}ms after A settled; ` + - `the budget is ${maxDestinationRequestDelayMs}ms.`, + destinationRequestReadyDelayMs >= 0, + `The destination stats request started ${-destinationRequestReadyDelayMs}ms before its controls hydrated.`, + ); + assert.ok( + destinationRequestReadyDelayMs <= maxDestinationRequestReadyDelayMs, + `The queued destination initialization waited ${destinationRequestReadyDelayMs}ms after its controls hydrated; ` + + `the budget is ${maxDestinationRequestReadyDelayMs}ms.`, ); const traffic = await adapter.assertSpaNetwork(fromVideoId, toVideoId); await adapter.assertNoPageSignals(ARTIFACT_EXTENSION_DELAYED_FAILURE_SCENARIO_ID); @@ -1399,6 +2174,7 @@ async function runExtensionDelayedOutgoingFailureScenario( videoId: destination.currentVideoId, }, destinationRequestDelayMs, + destinationRequestReadyDelayMs, runtime: adapter.runtime, scenarioId: ARTIFACT_EXTENSION_DELAYED_FAILURE_SCENARIO_ID, textHistory, @@ -1409,34 +2185,39 @@ async function runExtensionDelayedOutgoingFailureScenario( } } +function createCataloguedArtifactAdapter(runtime, scenarioId, apiServer) { + const backendOptions = scenarioId === ARTIFACT_SMOKE_SCENARIO_ID ? undefined : { countsByVideo: SPA_COUNTS }; + const options = backendOptions ? { backendOptions } : {}; + if (runtime === "userscript") return new HermeticUserscriptArtifactAdapter(options); + if (runtime === "extension") return new HermeticExtensionArtifactAdapter({ ...options, apiServer }); + throw new TypeError(`Unsupported catalogued artifact runtime: ${runtime}`); +} + +function runCataloguedArtifactScenario(scenarioId, adapter) { + switch (scenarioId) { + case ARTIFACT_SMOKE_SCENARIO_ID: + return runArtifactWatchRenderScenario(adapter); + case ARTIFACT_WATCH_SPA_SCENARIO_ID: + return runArtifactWatchSpaScenario(adapter); + case ARTIFACT_WATCH_SPA_VOTE_SCENARIO_ID: + return runArtifactWatchSpaVoteScenario(adapter); + case ARTIFACT_WATCH_SPA_CLONED_VOTE_SCENARIO_ID: + return runArtifactWatchSpaClonedVoteScenario(adapter); + case ARTIFACT_EXTENSION_DELAYED_FAILURE_SCENARIO_ID: + return runExtensionDelayedOutgoingFailureScenario(adapter); + default: + throw new TypeError(`Unknown catalogued artifact scenario: ${scenarioId}`); + } +} + async function runBothArtifactSmokes() { const apiServer = await startHermeticApiServer(); try { const results = []; - for (const adapter of [ - new HermeticUserscriptArtifactAdapter(), - new HermeticExtensionArtifactAdapter({ apiServer }), - ]) { - results.push(await runArtifactWatchRenderScenario(adapter)); + for (const { runtime, scenarioId } of createArtifactBrowserScenarioPlan()) { + const adapter = createCataloguedArtifactAdapter(runtime, scenarioId, apiServer); + results.push(await runCataloguedArtifactScenario(scenarioId, adapter)); } - const backendOptions = { countsByVideo: SPA_COUNTS }; - for (const adapter of [ - new HermeticUserscriptArtifactAdapter({ backendOptions }), - new HermeticExtensionArtifactAdapter({ apiServer, backendOptions }), - ]) { - results.push(await runArtifactWatchSpaScenario(adapter)); - } - for (const adapter of [ - new HermeticUserscriptArtifactAdapter({ backendOptions }), - new HermeticExtensionArtifactAdapter({ apiServer, backendOptions }), - ]) { - results.push(await runArtifactWatchSpaVoteScenario(adapter)); - } - results.push( - await runExtensionDelayedOutgoingFailureScenario( - new HermeticExtensionArtifactAdapter({ apiServer, backendOptions }), - ), - ); return results; } finally { await apiServer.close(); @@ -1453,21 +2234,30 @@ if (require.main === module) { } module.exports = { + ARTIFACT_BROWSER_SCENARIO_CATALOG, ARTIFACT_EXTENSION_DELAYED_FAILURE_SCENARIO_ID, ARTIFACT_SMOKE_SCENARIO_ID, + ARTIFACT_WATCH_SPA_CLONED_VOTE_SCENARIO_ID, ARTIFACT_WATCH_SPA_SCENARIO_ID, ARTIFACT_WATCH_SPA_VOTE_SCENARIO_ID, HermeticExtensionArtifactAdapter, HermeticUserscriptArtifactAdapter, SHARED_ARTIFACT_SCENARIO_IDS, + SHARED_ARTIFACT_RUNTIMES, SPA_COUNTS, assertLoopbackOrigin, + assertArtifactBrowserScenarioCatalog, + createArtifactBrowserScenarioPlan, createPageSignalCollector, + createSharedArtifactBackendOptions, + createWorkerSignalCollector, isArtifactVoteHandshakeValid, isSpaDestinationValid, prepareHermeticExtensionArtifact, + readGeneratedMv3Contract, readArtifactVoteHandshake, runArtifactWatchRenderScenario, + runArtifactWatchSpaClonedVoteScenario, runArtifactWatchSpaScenario, runArtifactWatchSpaVoteScenario, runBothArtifactSmokes, diff --git a/Extensions/e2e/hermetic-artifact-smoke.spec.js b/Extensions/e2e/hermetic-artifact-smoke.spec.js index 9ded76e..2b1978c 100644 --- a/Extensions/e2e/hermetic-artifact-smoke.spec.js +++ b/Extensions/e2e/hermetic-artifact-smoke.spec.js @@ -1,21 +1,37 @@ const fs = require("node:fs"); const { EventEmitter } = require("node:events"); +const http = require("node:http"); const os = require("node:os"); const path = require("node:path"); +const { createFakeBackend } = require("../UserScript/e2e/harness"); +const { WORKER_SIGNAL_PATH, isAllowedApiPreflight } = require("./hermetic-api-contract"); const { + ARTIFACT_BROWSER_SCENARIO_CATALOG, + ARTIFACT_EXTENSION_DELAYED_FAILURE_SCENARIO_ID, ARTIFACT_SMOKE_SCENARIO_ID, + ARTIFACT_WATCH_SPA_CLONED_VOTE_SCENARIO_ID, ARTIFACT_WATCH_SPA_SCENARIO_ID, ARTIFACT_WATCH_SPA_VOTE_SCENARIO_ID, + HermeticExtensionArtifactAdapter, SHARED_ARTIFACT_SCENARIO_IDS, + SHARED_ARTIFACT_RUNTIMES, + assertArtifactBrowserScenarioCatalog, assertLoopbackOrigin, + createArtifactBrowserScenarioPlan, createPageSignalCollector, + createSharedArtifactBackendOptions, + createWorkerSignalCollector, isArtifactVoteHandshakeValid, isSpaDestinationValid, prepareHermeticExtensionArtifact, + readGeneratedMv3Contract, readArtifactVoteHandshake, runArtifactWatchRenderScenario, + runArtifactWatchSpaClonedVoteScenario, runArtifactWatchSpaScenario, runArtifactWatchSpaVoteScenario, + runExtensionDelayedOutgoingFailureScenario, + startHermeticApiServer, } = require("./hermetic-artifact-smoke"); const PRODUCTION_API_ORIGIN = "https://returnyoutubedislikeapi.com"; @@ -26,13 +42,22 @@ function createExtensionFixture() { temporaryDirectories.push(directory); fs.writeFileSync( path.join(directory, "manifest.json"), - JSON.stringify({ host_permissions: ["*://returnyoutubedislikeapi.com/*"], manifest_version: 3 }), + JSON.stringify({ + background: { service_worker: "ryd.background.js" }, + content_scripts: [ + { css: ["content-style.css"], js: ["ryd.content-script.js"], matches: ["*://www.youtube.com/*"] }, + ], + host_permissions: ["*://returnyoutubedislikeapi.com/*"], + manifest_version: 3, + version: "4.0.5", + }), ); fs.writeFileSync( path.join(directory, "ryd.background.js"), `fetch("${PRODUCTION_API_ORIGIN}/register")\napi.runtime.onInstalled.addListener((details) => {\n maybeShowChangelog(details);\n});`, ); fs.writeFileSync(path.join(directory, "ryd.content-script.js"), `fetch("${PRODUCTION_API_ORIGIN}/votes")`); + fs.writeFileSync(path.join(directory, "content-style.css"), "#ryd-bar { display: block; }"); fs.writeFileSync(path.join(directory, "menu-fixer.js"), "document.documentElement.dataset.menuFixerLoaded = 'true';"); return directory; } @@ -45,13 +70,76 @@ afterEach(() => { test("the artifact smoke is the shared watch-render scenario", () => { expect(ARTIFACT_SMOKE_SCENARIO_ID).toBe("watch-render"); + expect(ARTIFACT_WATCH_SPA_CLONED_VOTE_SCENARIO_ID).toBe("watch-spa-cloned-controls-immediate-dislike"); expect(ARTIFACT_WATCH_SPA_SCENARIO_ID).toBe("watch-spa-side-panel"); expect(ARTIFACT_WATCH_SPA_VOTE_SCENARIO_ID).toBe("watch-spa-dislike-activation"); expect(SHARED_ARTIFACT_SCENARIO_IDS).toEqual([ "watch-render", "watch-spa-side-panel", "watch-spa-dislike-activation", + "watch-spa-cloned-controls-immediate-dislike", ]); + expect(SHARED_ARTIFACT_RUNTIMES).toEqual(["userscript", "extension"]); + expect(ARTIFACT_BROWSER_SCENARIO_CATALOG).toEqual([ + ...SHARED_ARTIFACT_SCENARIO_IDS.map((id) => ({ id, runtimes: SHARED_ARTIFACT_RUNTIMES, shared: true })), + { + capability: "background", + id: ARTIFACT_EXTENSION_DELAYED_FAILURE_SCENARIO_ID, + runtimes: ["extension"], + shared: false, + }, + ]); + expect(assertArtifactBrowserScenarioCatalog()).toBe(ARTIFACT_BROWSER_SCENARIO_CATALOG); + expect(createArtifactBrowserScenarioPlan()).toEqual([ + ...SHARED_ARTIFACT_SCENARIO_IDS.flatMap((scenarioId) => + SHARED_ARTIFACT_RUNTIMES.map((runtime) => ({ runtime, scenarioId })), + ), + { runtime: "extension", scenarioId: ARTIFACT_EXTENSION_DELAYED_FAILURE_SCENARIO_ID }, + ]); +}); + +test.each([ + [ + "a missing extension run from a core scenario", + (catalog) => { + catalog[0] = { ...catalog[0], runtimes: ["userscript"] }; + }, + /must run for userscript and extension/, + ], + [ + "a core scenario reclassified as extension-only", + (catalog) => { + catalog[0] = { ...catalog[0], capability: "background", runtimes: ["extension"], shared: false }; + }, + /must remain shared/, + ], + [ + "an undeclared extension-only capability", + (catalog) => { + catalog[catalog.length - 1] = { ...catalog.at(-1), capability: "mobile-layout" }; + }, + /must declare a supported capability/, + ], + [ + "an extension-only scenario registered for userscript", + (catalog) => { + catalog[catalog.length - 1] = { ...catalog.at(-1), runtimes: ["userscript", "extension"] }; + }, + /must run only for extension/, + ], + [ + "an unknown scenario presented as shared coverage", + (catalog) => { + catalog.push({ id: "uncatalogued-core-behavior", runtimes: ["userscript", "extension"], shared: true }); + }, + /must be added to the shared scenario contract/, + ], +])("the artifact catalog rejects %s", (_label, mutateCatalog, message) => { + const catalog = ARTIFACT_BROWSER_SCENARIO_CATALOG.map((entry) => ({ ...entry, runtimes: [...entry.runtimes] })); + mutateCatalog(catalog); + + expect(() => assertArtifactBrowserScenarioCatalog(catalog)).toThrow(message); + expect(() => createArtifactBrowserScenarioPlan(catalog)).toThrow(message); }); function validSpaSnapshot() { @@ -105,6 +193,9 @@ function validVoteHandshake(change = {}) { sharedUserId: ARTIFACT_USER_ID, vote: { body: { userId: ARTIFACT_USER_ID, value: -1, videoId: "zyxwvutsrqp" }, + responded: true, + responseBody: { challenge: "AAAAAAAAAAAAAAAAAAAAAA==", difficulty: 0 }, + responseStatus: 200, }, voteCount: 1, ...change, @@ -125,6 +216,141 @@ test.each(["https://returnyoutubedislikeapi.com", "https://api.example.test", "h }, ); +test.each([ + ["/votes", "GET"], + ["/puzzle/registration", "POST"], + ["/interact/vote", "POST"], + [WORKER_SIGNAL_PATH, "POST"], +])("accepts a preflight only for a declared API method: %s %s", (pathname, method) => { + expect(isAllowedApiPreflight(pathname, method)).toBe(true); +}); + +test.each([ + ["/not-an-api", "POST"], + ["/interact/vote", "GET"], + ["/votes", "POST"], + ["/votes", null], +])("rejects undeclared preflight traffic: %s %s", (pathname, method) => { + expect(isAllowedApiPreflight(pathname, method)).toBe(false); +}); + +function requestPreflight(origin, pathname, requestedMethod) { + const url = new URL(pathname, origin); + return new Promise((resolve, reject) => { + const request = http.request( + url, + { + headers: { + "access-control-request-method": requestedMethod, + origin: "https://www.youtube.com", + }, + method: "OPTIONS", + }, + (response) => { + response.resume(); + response.once("end", () => resolve(response.statusCode)); + }, + ); + request.once("error", reject); + request.end(); + }); +} + +test("the loopback backend records and rejects an OPTIONS request to an unknown path", async () => { + const apiServer = await startHermeticApiServer(); + try { + await expect(requestPreflight(apiServer.origin, "/interact/vote", "POST")).resolves.toBe(204); + await expect(requestPreflight(apiServer.origin, "/unexpected-preflight", "POST")).resolves.toBe(404); + + expect(apiServer.records.map(({ method, pathname }) => ({ method, pathname }))).toEqual([ + { method: "OPTIONS", pathname: "/interact/vote" }, + { method: "OPTIONS", pathname: "/unexpected-preflight" }, + ]); + expect(apiServer.unexpectedRequests).toHaveLength(1); + expect(apiServer.unexpectedRequests[0]).toMatchObject({ + method: "OPTIONS", + pathname: "/unexpected-preflight", + responseStatus: 404, + }); + } finally { + await apiServer.close(); + } +}); + +function createPreflightRoute(pathname, requestedMethod) { + const request = { + headers: () => ({ "access-control-request-method": requestedMethod }), + method: () => "OPTIONS", + resourceType: () => "fetch", + url: () => `https://returnyoutubedislikeapi.com${pathname}`, + }; + return { + abort: jest.fn(async () => {}), + fulfill: jest.fn(async () => {}), + request: () => request, + }; +} + +function createWatchDocumentRoute() { + const request = { + resourceType: () => "document", + url: () => "https://www.youtube.com/watch?v=abcdefghijk", + }; + return { + fulfill: jest.fn(async () => {}), + request: () => request, + }; +} + +test("shared artifact fixtures do not preseed runtime roles or a Dislike count container", async () => { + const backendOptions = createSharedArtifactBackendOptions({ + countsByVideo: { abcdefghijk: { dislikes: 25, likes: 100 } }, + }); + expect(backendOptions.fixture).toEqual({ + nativeDislikeText: false, + roleAttribute: "data-fixture-role", + }); + + const backend = createFakeBackend(backendOptions); + const route = createWatchDocumentRoute(); + await backend.handle(route); + + expect(route.fulfill).toHaveBeenCalledTimes(1); + const [{ body }] = route.fulfill.mock.calls[0]; + const dislikeMarkup = body.match(//)?.[0]; + expect(body).toContain('data-fixture-role="buttons"'); + expect(body).not.toContain("data-ryd-role="); + expect(dislikeMarkup).toBeDefined(); + expect(dislikeMarkup).not.toMatch(/id="text"|role="text"|ytSpecButtonShapeNextButtonTextContent/); +}); + +test("the routed fake backend records a valid preflight and blocks an unknown one", async () => { + const backend = createFakeBackend(); + const valid = createPreflightRoute("/interact/confirmVote", "POST"); + const invalid = createPreflightRoute("/unexpected-preflight", "POST"); + + await backend.handle(valid); + await backend.handle(invalid); + + expect(valid.fulfill).toHaveBeenCalledWith(expect.objectContaining({ status: 204 })); + expect(valid.abort).not.toHaveBeenCalled(); + expect(backend.preflightRequests).toEqual([ + expect.objectContaining({ + method: "OPTIONS", + pathname: "/interact/confirmVote", + requestedMethod: "POST", + }), + ]); + expect(invalid.abort).toHaveBeenCalledWith("blockedbyclient"); + expect(backend.blockedRequests).toEqual([ + expect.objectContaining({ + method: "OPTIONS", + pathname: "/unexpected-preflight", + requestedMethod: "POST", + }), + ]); +}); + test("redirects eager MV3 background traffic while leaving routed content-script traffic intact", () => { const sourceDirectory = createExtensionFixture(); const prepared = prepareHermeticExtensionArtifact(sourceDirectory, "http://127.0.0.1:43127"); @@ -135,6 +361,10 @@ test("redirects eager MV3 background traffic while leaving routed content-script expect(fs.readFileSync(path.join(prepared.extensionDirectory, "ryd.background.js"), "utf8")).toContain( "http://127.0.0.1:43127/register", ); + expect(fs.readFileSync(path.join(prepared.extensionDirectory, "ryd.background.js"), "utf8")).toContain( + "__rydArtifactWorkerSignals", + ); + expect(prepared.workerSignalEndpoint).toBe(`http://127.0.0.1:43127${WORKER_SIGNAL_PATH}`); expect(fs.readFileSync(path.join(prepared.extensionDirectory, "ryd.background.js"), "utf8")).toContain( "api.runtime.onInstalled.addListener(() => {});", ); @@ -158,6 +388,18 @@ test("rejects an extension artifact whose injected auxiliary script was dropped ); }); +test("the direct MV3 adapter contract rejects a development bundle with an inline source map", () => { + const sourceDirectory = createExtensionFixture(); + fs.appendFileSync( + path.join(sourceDirectory, "ryd.content-script.js"), + "\n//# sourceMappingURL=data:application/json;base64,e30=", + ); + + expect(() => readGeneratedMv3Contract(sourceDirectory)).toThrow( + "ryd.content-script.js contains an inline source map and is not a production bundle.", + ); +}); + test.each(["userscript", "extension"])("runs one shared artifact scenario contract for %s", async (runtime) => { const events = []; const adapter = { @@ -167,9 +409,13 @@ test.each(["userscript", "extension"])("runs one shared artifact scenario contra runtime, start: jest.fn(async () => events.push("start")), waitForWatchResult: jest.fn(async (videoId) => ({ + actionSurfaceVisible: true, count: "25", + countVisible: true, fillVisible: true, + ownedByExpectedWatch: true, rateBarVisible: true, + sameActionSurface: true, videoId, })), }; @@ -183,6 +429,39 @@ test.each(["userscript", "extension"])("runs one shared artifact scenario contra expect(events).toEqual(["start", "open:abcdefghijk", "signals:watch-render", "close"]); }); +test.each([ + ["a hidden action surface", { actionSurfaceVisible: false }], + ["a hidden dislike count", { countVisible: false }], + ["an outgoing watch root", { ownedByExpectedWatch: false }], + ["a count and bar on different action surfaces", { sameActionSurface: false }], + ["a missing numeric count", { count: "" }], + ["a hidden ratio bar", { rateBarVisible: false }], + ["a hidden ratio fill", { fillVisible: false }], +])("the watch-render oracle rejects %s", async (_label, mutation) => { + const adapter = { + assertNoPageSignals: jest.fn(async () => {}), + close: jest.fn(async () => {}), + openWatch: jest.fn(async () => {}), + runtime: "extension", + start: jest.fn(async () => {}), + waitForWatchResult: jest.fn(async (videoId) => ({ + actionSurfaceVisible: true, + count: "25", + countVisible: true, + fillVisible: true, + ownedByExpectedWatch: true, + rateBarVisible: true, + sameActionSurface: true, + videoId, + ...mutation, + })), + }; + + await expect(runArtifactWatchRenderScenario(adapter, { videoId: "abcdefghijk" })).rejects.toThrow(); + expect(adapter.assertNoPageSignals).not.toHaveBeenCalled(); + expect(adapter.close).toHaveBeenCalledTimes(1); +}); + test.each(["userscript", "extension"])("runs the same continuous A-to-B SPA contract for %s", async (runtime) => { const events = []; const adapter = { @@ -219,6 +498,121 @@ test.each(["userscript", "extension"])("runs the same continuous A-to-B SPA cont expect(events).toEqual(["start", "open:abcdefghijk", "signals:watch-spa-side-panel", "close"]); }); +function createDelayedFailureAdapter(destinationRequestAt) { + const events = []; + const outgoingRequest = { seen: Promise.resolve(), release: jest.fn() }; + return { + events, + assertNoPageSignals: jest.fn(async () => {}), + assertSpaNetwork: jest.fn(async () => ({ fromVideoRequests: 1, interactionRequests: 0, toVideoRequests: 1 })), + close: jest.fn(async () => events.push("close")), + deferNextStatsRequest: jest.fn(() => { + events.push("defer"); + return outgoingRequest; + }), + navigateSpaWatchWhilePending: jest.fn(async () => ({ + destination: { destinationReplaced: true, nativeHydrationStartedAt: 1000, nativeHydrationDelay: 750 }, + outgoing: { beforeBarCount: 0 }, + })), + openSpaWatch: jest.fn(async () => events.push("open")), + readDestinationDislikeTextHistory: jest.fn(async () => ["", "65"]), + readSpaWatchSnapshot: jest.fn(async () => validSpaSnapshot()), + readStatsRequestTimings: jest.fn(() => [{ at: destinationRequestAt, query: { videoId: "zyxwvutsrqp" } }]), + runtime: "extension", + setPremiumTeaserHidden: jest.fn(async (hidden) => events.push(`hide-teaser:${hidden}`)), + start: jest.fn(async () => events.push("start")), + waitForWatchResult: jest.fn(), + }; +} + +test("isolates main initialization request timing before loading the outgoing fixture", async () => { + const adapter = createDelayedFailureAdapter(1800); + await expect(runExtensionDelayedOutgoingFailureScenario(adapter)).resolves.toMatchObject({ + destinationRequestReadyDelayMs: 50, + scenarioId: ARTIFACT_EXTENSION_DELAYED_FAILURE_SCENARIO_ID, + }); + expect(adapter.events).toEqual(["start", "hide-teaser:true", "defer", "open", "close"]); +}); + +test("waits for the initial background preference write before hiding the teaser", async () => { + const previousChrome = globalThis.chrome; + const listeners = new Set(); + let storedValue; + const sync = { + get: jest.fn(async () => ({ hidePremiumTeaser: storedValue })), + set: jest.fn(async ({ hidePremiumTeaser }) => { + storedValue = hidePremiumTeaser; + }), + }; + globalThis.chrome = { + storage: { + sync, + onChanged: { + addListener: (listener) => listeners.add(listener), + removeListener: (listener) => listeners.delete(listener), + }, + }, + }; + try { + const adapter = { worker: { evaluate: (callback, value) => callback(value) } }; + const pending = HermeticExtensionArtifactAdapter.prototype.setPremiumTeaserHidden.call(adapter, true); + await Promise.resolve(); + expect(sync.set).not.toHaveBeenCalled(); + storedValue = false; + for (const listener of listeners) listener({ hidePremiumTeaser: { newValue: false } }, "sync"); + await pending; + expect(sync.set).toHaveBeenCalledWith({ hidePremiumTeaser: true }); + expect(storedValue).toBe(true); + expect(listeners.size).toBe(0); + } finally { + if (previousChrome === undefined) delete globalThis.chrome; + else globalThis.chrome = previousChrome; + } +}); + +test("arms delayed destination controls before the real navigation click can expose them", async () => { + const events = []; + const destination = { destinationReplaced: true, nativeHydrationStartedAt: 1000, nativeHydrationDelay: 750 }; + const page = { + evaluate: jest + .fn() + .mockResolvedValueOnce({ beforeBarCount: 0 }) + .mockImplementationOnce(async (_callback, options) => { + expect(options).toMatchObject({ + beforeNavigation: true, + expectedVideoId: "zyxwvutsrqp", + nativeHydrationDelay: 750, + }); + events.push("arm"); + }) + .mockImplementationOnce(async () => { + events.push("read"); + return destination; + }) + .mockResolvedValueOnce(undefined), + locator: jest.fn(() => ({ click: async () => events.push("click") })), + waitForFunction: jest.fn(async () => {}), + }; + await expect( + HermeticExtensionArtifactAdapter.prototype.navigateSpaWatchWhilePending.call( + { page }, + "abcdefghijk", + "zyxwvutsrqp", + ), + ).resolves.toEqual({ destination, outgoing: { beforeBarCount: 0 } }); + expect(events).toEqual(["arm", "click", "read"]); +}); + +test.each([ + ["a request before native controls hydrate", 1749, /before its controls hydrated/], + ["a request beyond the initialization latency budget", 2001, /budget is 250ms/], +])("the isolated initialization scenario still rejects %s", async (_label, at, message) => { + const adapter = createDelayedFailureAdapter(at); + await expect(runExtensionDelayedOutgoingFailureScenario(adapter)).rejects.toThrow(message); + expect(adapter.assertSpaNetwork).not.toHaveBeenCalled(); + expect(adapter.close).toHaveBeenCalledTimes(1); +}); + test("recognizes only one ordered, successful destination dislike handshake", () => { const records = [ { method: "POST", pathname: "/puzzle/registration" }, @@ -226,6 +620,9 @@ test("recognizes only one ordered, successful destination dislike handshake", () body: { userId: ARTIFACT_USER_ID, value: -1, videoId: "zyxwvutsrqp" }, method: "POST", pathname: "/interact/vote", + respondedAt: 122, + responseBody: { challenge: "AAAAAAAAAAAAAAAAAAAAAA==", difficulty: 0 }, + responseStatus: 200, }, { body: { solution: "AAAAAA==", userId: ARTIFACT_USER_ID, videoId: "zyxwvutsrqp" }, @@ -261,7 +658,13 @@ test.each([ }), ], ["a malformed identity", (value) => ({ ...value, sharedUserId: "short-user" })], + ["an unfinished vote response", (value) => ({ ...value, vote: { ...value.vote, responded: false } })], + ["a failed vote status", (value) => ({ ...value, vote: { ...value.vote, responseStatus: 500 } })], ["a false confirmation", (value) => ({ ...value, confirmation: { ...value.confirmation, responseBody: false } })], + [ + "an unfinished confirmation response", + (value) => ({ ...value, confirmation: { ...value.confirmation, responded: false } }), + ], [ "a failed confirmation status", (value) => ({ ...value, confirmation: { ...value.confirmation, responseStatus: 500 } }), @@ -278,6 +681,15 @@ test.each([ expect(isArtifactVoteHandshakeValid(mutate(validVoteHandshake()))).toBe(false); }); +test("accepts successful 2xx vote and confirmation responses", () => { + const handshake = validVoteHandshake({ + confirmation: { ...validVoteHandshake().confirmation, responseStatus: 202 }, + vote: { ...validVoteHandshake().vote, responseStatus: 201 }, + }); + + expect(isArtifactVoteHandshakeValid(handshake)).toBe(true); +}); + test.each(["userscript", "extension"])( "runs one post-SPA dislike activation and confirmation contract for %s", async (runtime) => { @@ -323,7 +735,9 @@ test.each(["userscript", "extension"])( userId: ARTIFACT_USER_ID, value: -1, videoId: "zyxwvutsrqp", + voteResponded: true, voteRequests: 1, + voteStatus: 200, }, runtime, scenarioId: "watch-spa-dislike-activation", @@ -337,6 +751,62 @@ test.each(["userscript", "extension"])( }, ); +test.each(["userscript", "extension"])( + "requires the first immediate click on cloned initialized controls for %s", + async (runtime) => { + const events = []; + const adapter = { + activateClonedSpaDislike: jest.fn(async (videoId) => ({ + ariaPressedBefore: "false", + barCount: 1, + countAfterSynchronousClick: "66", + countBefore: "65", + countContainerCount: 1, + interactionStartIndex: 9, + presentationCloned: true, + videoId, + })), + assertNoPageSignals: jest.fn(async (scenarioId) => events.push(`signals:${scenarioId}`)), + assertSpaVoteNetwork: jest.fn(async () => ({ fromVideoRequests: 1, toVideoRequests: 1 })), + close: jest.fn(async () => events.push("close")), + navigateSpaWatch: jest.fn(async () => ({ + destination: { destinationReplaced: true }, + outgoing: { beforeBarCount: 1 }, + })), + openSpaWatch: jest.fn(async (videoId) => events.push(`open:${videoId}`)), + readSpaVoteHandshake: jest.fn(async () => validVoteHandshake()), + readSpaWatchSnapshot: jest.fn(async () => validSpaSnapshot()), + runtime, + start: jest.fn(async () => events.push("start")), + waitForWatchResult: jest.fn(async (videoId) => ({ count: "10", fillRatio: 0.9, videoId })), + }; + + await expect( + runArtifactWatchSpaClonedVoteScenario(adapter, { + handshakeStableForMs: 0, + handshakeTimeoutMs: 1, + intervalMs: 1, + stabilityDurationMs: 0, + stableForMs: 0, + timeoutMs: 1, + }), + ).resolves.toMatchObject({ + activation: { ariaPressedBefore: "false", presentationCloned: true, videoId: "zyxwvutsrqp" }, + handshake: { interactionRequests: 2, value: -1, videoId: "zyxwvutsrqp" }, + runtime, + scenarioId: "watch-spa-cloned-controls-immediate-dislike", + }); + expect(adapter.activateClonedSpaDislike).toHaveBeenCalledWith("zyxwvutsrqp"); + expect(adapter.readSpaVoteHandshake).toHaveBeenCalledWith(9, "zyxwvutsrqp", -1); + expect(events).toEqual([ + "start", + "open:abcdefghijk", + "signals:watch-spa-cloned-controls-immediate-dislike", + "close", + ]); + }, +); + test("rejects a duplicated post-SPA vote chain and still closes the adapter", async () => { const duplicatedHandshake = validVoteHandshake({ confirmationCount: 2, @@ -434,9 +904,13 @@ test("always closes an artifact adapter after a failed visual assertion", async runtime: "extension", start: jest.fn(), waitForWatchResult: jest.fn(async () => ({ + actionSurfaceVisible: true, count: "25", + countVisible: true, fillVisible: true, + ownedByExpectedWatch: true, rateBarVisible: false, + sameActionSurface: true, videoId: "abcdefghijk", })), }; @@ -457,9 +931,13 @@ test("turns an otherwise successful artifact result into a failure when the page runtime: "userscript", start: jest.fn(), waitForWatchResult: jest.fn(async (videoId) => ({ + actionSurfaceVisible: true, count: "25", + countVisible: true, fillVisible: true, + ownedByExpectedWatch: true, rateBarVisible: true, + sameActionSurface: true, videoId, })), }; @@ -492,13 +970,54 @@ function consoleMessage( }; } +function createWorkerDouble(signals = []) { + const worker = new EventEmitter(); + worker.evaluate = jest.fn(async (callback, argument) => { + globalThis.__rydArtifactWorkerSignals = signals; + try { + return await callback(argument); + } finally { + delete globalThis.__rydArtifactWorkerSignals; + } + }); + worker.url = () => "chrome-extension://abcdefghijklmnopabcdefghijklmnop/ryd.background.js"; + return worker; +} + +test("the worker collector fails on a captured unhandled rejection even without a console event", async () => { + const worker = createWorkerDouble([ + { + at: 123, + details: {}, + kind: "unhandledrejection", + message: "intentional worker promise rejection", + name: "Error", + stack: "Error: intentional worker promise rejection", + }, + ]); + const collector = createWorkerSignalCollector(worker, { workerSignals: [] }); + + await expect(collector.assertClean("worker-negative-control")).rejects.toThrow( + /extension MV3 worker emitted unexpected runtime signals.*intentional worker promise rejection/s, + ); +}); + +test("the worker collector includes early console failures reported by the worker probe", async () => { + const worker = createWorkerDouble(); + const collector = createWorkerSignalCollector(worker, { + workerSignals: [{ kind: "console-error", message: "startup exploded" }], + }); + + await expect(collector.assertClean("worker-startup")).rejects.toThrow(/startup exploded/); +}); + test.each(["userscript", "extension"])( "collects clean page signals through one shared %s collector", async (runtime) => { const page = createPageDouble(); const collector = await createPageSignalCollector(page, runtime); - page.emit("console", consoleMessage("warning", "harmless warning")); + page.emit("console", consoleMessage("info", "harmless information")); await expect(collector.assertClean("watch-render")).resolves.toEqual({ consoleErrors: [], @@ -513,6 +1032,11 @@ test.each(["userscript", "extension"])( test.each([ ["console error", (page) => page.emit("console", consoleMessage("error", "fixture exploded")), "fixture exploded"], + [ + "console warning", + (page) => page.emit("console", consoleMessage("warning", "initialization retry failed")), + "initialization retry failed", + ], [ "failed browser resource load", (page) => page.emit("console", consoleMessage("error", "Failed to load resource: net::ERR_FILE_NOT_FOUND")), diff --git a/Extensions/e2e/hide-clutter-tooltip-assets.spec.js b/Extensions/e2e/hide-clutter-tooltip-assets.spec.js new file mode 100644 index 0000000..bf06dd4 --- /dev/null +++ b/Extensions/e2e/hide-clutter-tooltip-assets.spec.js @@ -0,0 +1,26 @@ +const fs = require("node:fs"); +const { OUTPUTS } = require("./generate-hide-clutter-tooltip-assets"); + +function readPngDimensions(filePath) { + const bytes = fs.readFileSync(filePath); + expect(bytes.subarray(0, 8)).toEqual(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])); + expect(bytes.subarray(12, 16).toString("ascii")).toBe("IHDR"); + return { height: bytes.readUInt32BE(20), width: bytes.readUInt32BE(16) }; +} + +describe("hide-clutter tooltip assets", () => { + test.each(Object.entries(OUTPUTS))("%s is a readable owned screenshot with useful dimensions", (name, filePath) => { + expect(fs.existsSync(filePath)).toBe(true); + const dimensions = readPngDimensions(filePath); + expect(dimensions.width).toBeGreaterThanOrEqual(280); + expect(dimensions.width).toBeLessThanOrEqual(320); + expect(dimensions.height).toBeGreaterThanOrEqual(90); + expect(dimensions.height).toBeLessThanOrEqual(150); + expect(fs.statSync(filePath).size).toBeGreaterThan(3_000); + }); + + test("before and after are distinct images", () => { + expect(fs.readFileSync(OUTPUTS.before).equals(fs.readFileSync(OUTPUTS.after))).toBe(false); + expect(readPngDimensions(OUTPUTS.before).height).toBeGreaterThan(readPngDimensions(OUTPUTS.after).height); + }); +}); diff --git a/Extensions/e2e/live-runtime-adapter.js b/Extensions/e2e/live-runtime-adapter.js index 15572da..977a2b2 100644 --- a/Extensions/e2e/live-runtime-adapter.js +++ b/Extensions/e2e/live-runtime-adapter.js @@ -11,9 +11,13 @@ const LIVE_RUNTIME_PROFILES = deepFreeze({ capabilities: { backgroundVoteTransport: true, credentialStore: "browser.storage", - ownsShortsDislikeControl: false, - shortsControlModel: "native-youtube", - shortsVisualModel: "native-pair", + ownsShortsDislikeControl: true, + shortsControlModel: "synthetic-owned", + shortsControlModelBySurface: { + desktop: "synthetic-owned", + mobile: "native-youtube-required", + }, + shortsVisualModel: "strict-synthetic", }, buildMarkerAttribute: "data-ryd-extension-build", markerAttribute: "data-ryd-extension-version", @@ -21,7 +25,7 @@ const LIVE_RUNTIME_PROFILES = deepFreeze({ selectors: { rateBar: "#ryd-bar", rateBarContainer: "#ryd-bar-container", - shortsDislikeControl: null, + shortsDislikeControl: "[data-ryd-synthetic-shorts-dislike]", tooltipContent: "#ryd-dislike-tooltip", tooltipTrigger: ".ryd-tooltip", }, @@ -32,6 +36,10 @@ const LIVE_RUNTIME_PROFILES = deepFreeze({ credentialStore: "gm-storage", ownsShortsDislikeControl: true, shortsControlModel: "synthetic-owned", + shortsControlModelBySurface: { + desktop: "synthetic-owned", + mobile: "native-youtube-required", + }, shortsVisualModel: "strict-synthetic", }, buildMarkerAttribute: "data-ryd-userscript-build", @@ -58,7 +66,7 @@ function assertRuntimeArgument(value, expected, label) { assert.equal(value, expected, `${label} does not match the selected live runtime adapter.`); } -function createRuntimeBoundDriver(driver, runtime, expectedVersion, expectedBuildId) { +function createRuntimeBoundDriver(driver, runtime, expectedVersion, expectedBuildId, capabilities) { return new Proxy(driver, { get(target, property, receiver) { if (property === "assertRuntime") { @@ -72,10 +80,46 @@ function createRuntimeBoundDriver(driver, runtime, expectedVersion, expectedBuil } if (property === "assertCurrentShortsControl") { - return (videoId, requestedRuntime) => { + return (videoId, requestedRuntime, assertionOptions) => { assertRuntimeArgument(requestedRuntime, runtime, "The Shorts-control runtime"); requireDriverMethod(target, "assertCurrentShortsControl"); - return target.assertCurrentShortsControl(videoId, runtime); + return assertionOptions === undefined + ? target.assertCurrentShortsControl(videoId, runtime) + : target.assertCurrentShortsControl(videoId, runtime, assertionOptions); + }; + } + + if (property === "soakCurrentShortsControl") { + return (videoId, requestedRuntime, expectedDislikes, soakOptions) => { + assertRuntimeArgument(requestedRuntime, runtime, "The Shorts-control soak runtime"); + requireDriverMethod(target, "soakCurrentShortsControl"); + return target.soakCurrentShortsControl(videoId, runtime, expectedDislikes, soakOptions); + }; + } + + if (property === "assertCurrentWatchResult") { + return (videoId, requestedRuntime, expectedCounts, assertionOptions) => { + assertRuntimeArgument(requestedRuntime, runtime, "The Watch-result runtime"); + requireDriverMethod(target, "assertCurrentWatchResult"); + return assertionOptions === undefined + ? target.assertCurrentWatchResult(videoId, runtime, expectedCounts) + : target.assertCurrentWatchResult(videoId, runtime, expectedCounts, assertionOptions); + }; + } + + if (property === "assertRenderedDislikeCount") { + return (renderedCount, dislikes, requestedRuntime) => { + assertRuntimeArgument(requestedRuntime, runtime, "The rendered-count runtime"); + requireDriverMethod(target, "assertRenderedDislikeCount"); + return target.assertRenderedDislikeCount(renderedCount, dislikes, runtime); + }; + } + + if (property === "assertDislikeCountChangesObservable") { + return (changes, requestedRuntime) => { + assertRuntimeArgument(requestedRuntime, runtime, "The reaction count-observability runtime"); + requireDriverMethod(target, "assertDislikeCountChangesObservable"); + return target.assertDislikeCountChangesObservable(changes, runtime); }; } @@ -92,8 +136,17 @@ function createRuntimeBoundDriver(driver, runtime, expectedVersion, expectedBuil if (property === "captureReactionStateVisual") { return (request) => { assertRuntimeArgument(request?.runtime, runtime, "The reaction-visual runtime"); + assertRuntimeArgument( + request?.shortsVisualModel, + capabilities.shortsVisualModel, + "The reaction-visual Shorts model", + ); requireDriverMethod(target, "captureReactionStateVisual"); - return target.captureReactionStateVisual({ ...request, runtime }); + return target.captureReactionStateVisual({ + ...request, + runtime, + shortsVisualModel: capabilities.shortsVisualModel, + }); }; } @@ -117,8 +170,12 @@ class LiveRuntimeAdapter { throw new TypeError("The expected live build ID must be a 32-character lowercase hexadecimal value."); } + if (typeof driver.configureRequestAttributionRuntime === "function") { + driver.configureRequestAttributionRuntime(runtime); + } + this.capabilities = profile.capabilities; - this.driver = createRuntimeBoundDriver(driver, runtime, expectedVersion, expectedBuildId); + this.driver = createRuntimeBoundDriver(driver, runtime, expectedVersion, expectedBuildId, this.capabilities); this.expectedBuildId = expectedBuildId; this.expectedVersion = expectedVersion; this.profile = profile; @@ -135,6 +192,7 @@ class LiveRuntimeAdapter { assertRuntimeArgument(options.expectedVersion, this.expectedVersion, "The configured scenario runtime version"); return { ...options, + capabilities: this.capabilities, expectedBuildId: this.expectedBuildId, expectedVersion: this.expectedVersion, runtime: this.runtime, diff --git a/Extensions/e2e/navigation-runtime-contract.js b/Extensions/e2e/navigation-runtime-contract.js new file mode 100644 index 0000000..e23fd5b --- /dev/null +++ b/Extensions/e2e/navigation-runtime-contract.js @@ -0,0 +1,261 @@ +const { expect } = require("@playwright/test"); +const { isArtifactVoteHandshakeValid, readArtifactVoteHandshake } = require("./hermetic-artifact-smoke"); +const { + NAVIGATION_MATRIX, + NO_DESTINATION_DISLIKE_POSTCONDITION, + getDestinationDislikePostconditionTarget, + runNavigationMatrixScenario, +} = require("../UserScript/e2e/navigation-matrix"); + +const DEFAULT_POST_ACTIVATION_QUIET_MS = 650; +const INTERACTION_PATHS = new Set(["/interact/vote", "/interact/confirmVote"]); +const NON_CURRENT_DUPLICATE_SCENARIO_IDS = Object.freeze([ + "watch-sidebar-watch-same-root-hidden-first", + "watch-sidebar-watch-same-root-offscreen-first", + "watch-sidebar-watch-legacy-segmented-duplicate-ids", +]); +const SUPPORTED_RUNTIME_NAMES = Object.freeze(["userscript", "extension"]); + +function registerNavigationRuntimeContractScenarios({ register, runtimeName }) { + if (!SUPPORTED_RUNTIME_NAMES.includes(runtimeName)) { + throw new TypeError(`Unsupported navigation contract runtime: ${runtimeName}`); + } + if (typeof register !== "function") { + throw new TypeError("The navigation runtime contract requires a scenario register callback."); + } + for (const scenario of NAVIGATION_MATRIX) { + register({ + scenario, + title: `${runtimeName} navigation matrix: ${scenario.id}`, + }); + } +} + +function createNavigationRuntimeContractAdapter({ + backend, + expectedCredentials, + expectedUserId = expectedCredentials?.userId, + matrixRuntime, + page, + postActivationQuietMs = DEFAULT_POST_ACTIVATION_QUIET_MS, + readCredentials, + readInteractionRecords, + runtimeName = matrixRuntime?.name, +}) { + if (!backend || !page || !matrixRuntime) { + throw new TypeError("The navigation runtime contract requires a backend, page, and matrix runtime."); + } + if (typeof readInteractionRecords !== "function") { + throw new TypeError("The navigation runtime contract requires readInteractionRecords()."); + } + if (expectedCredentials !== undefined && typeof readCredentials !== "function") { + throw new TypeError("Expected credentials require a readCredentials() adapter."); + } + if (!Number.isFinite(postActivationQuietMs) || postActivationQuietMs < 0) { + throw new TypeError("postActivationQuietMs must be a non-negative finite number."); + } + + return Object.freeze({ + backend, + expectedCredentials, + expectedUserId, + matrixRuntime, + page, + postActivationQuietMs, + readCredentials, + readInteractionRecords, + runtimeName, + }); +} + +function interactionRecords(records) { + return records.filter((record) => record.method === "POST" && INTERACTION_PATHS.has(record.pathname)); +} + +async function expectCredentials(adapter) { + if (adapter.expectedCredentials === undefined) return; + expect(await adapter.readCredentials(), `${adapter.runtimeName} changed its persisted vote identity`).toEqual( + adapter.expectedCredentials, + ); +} + +async function expectNonCurrentDuplicateActivationIgnored(adapter, scenario, currentTarget) { + if (!NON_CURRENT_DUPLICATE_SCENARIO_IDS.includes(scenario.id)) return; + + const currentRoot = adapter.page.locator( + `ytd-watch-flexy[video-id="${scenario.destination.videoId}"], ` + + `ytd-watch-grid[video-id="${scenario.destination.videoId}"]`, + ); + const allDislikeButtons = currentRoot.locator('[data-fixture-role="dislike"] button'); + const outgoingButton = currentRoot.locator( + '[data-fixture-matrix-hidden-outgoing="true"] [data-fixture-role="dislike"] button', + ); + + await expect(allDislikeButtons).toHaveCount(2); + await expect(outgoingButton).toHaveCount(1); + expect( + await allDislikeButtons + .first() + .evaluate((button) => button.matches('[data-fixture-matrix-hidden-outgoing="true"] *')), + `${adapter.runtimeName} fixture must put the non-current duplicate before the visible destination control`, + ).toBe(true); + + const currentCount = await currentTarget.count.textContent(); + const currentDislikePressed = await currentTarget.button.getAttribute("aria-pressed"); + const currentLikePressed = await currentTarget.likeButton.getAttribute("aria-pressed"); + const interactionStartIndex = adapter.readInteractionRecords().length; + + // Dispatch a real bubbling click even though the stale duplicate is hidden or + // offscreen. This models an unscoped first-match locator without allowing the + // test framework's actionability checks to silently retarget the current UI. + await outgoingButton.evaluate((button) => button.click()); + await adapter.page.waitForTimeout(adapter.postActivationQuietMs); + + await expect(outgoingButton).toHaveAttribute("aria-pressed", "true"); + await expect(currentTarget.count).toHaveText(currentCount); + await expect(currentTarget.button).toHaveAttribute("aria-pressed", currentDislikePressed); + await expect(currentTarget.likeButton).toHaveAttribute("aria-pressed", currentLikePressed); + expect( + interactionRecords(adapter.readInteractionRecords().slice(interactionStartIndex)), + `${adapter.runtimeName} reacted to a non-current duplicate before the visible destination control`, + ).toEqual([]); + await expectCredentials(adapter); +} + +async function expectSuccessfulVoteActivation(adapter, videoId, value, activationTarget) { + const interactionStartIndex = adapter.readInteractionRecords().length; + await activationTarget.click(); + + const readHandshake = () => + readArtifactVoteHandshake(adapter.readInteractionRecords(), interactionStartIndex, videoId, value); + await expect + .poll( + () => { + const handshake = readHandshake(); + return handshake.interactionCount >= 2 && handshake.confirmation?.responded === true; + }, + { + message: `${adapter.runtimeName} must complete exactly one ${value} vote and successful confirmation for ${videoId}`, + }, + ) + .toBe(true); + + const handshake = readHandshake(); + expect( + isArtifactVoteHandshakeValid(handshake), + `${adapter.runtimeName} emitted a duplicate, malformed, or failed vote chain for ${videoId}: ` + + JSON.stringify(handshake), + ).toBe(true); + await adapter.page.waitForTimeout(adapter.postActivationQuietMs); + const settledHandshake = readHandshake(); + expect( + isArtifactVoteHandshakeValid(settledHandshake), + `${adapter.runtimeName} emitted a late vote request for ${videoId}: ${JSON.stringify(settledHandshake)}`, + ).toBe(true); + if (adapter.expectedUserId !== undefined) { + expect(settledHandshake.sharedUserId).toBe(adapter.expectedUserId); + } + return settledHandshake; +} + +async function expectDestinationReactionContract(adapter, scenario) { + expect( + interactionRecords(adapter.readInteractionRecords()), + `${adapter.runtimeName} submitted a vote during navigation without an activation`, + ).toEqual([]); + await expectCredentials(adapter); + + const target = getDestinationDislikePostconditionTarget(adapter.page, adapter.matrixRuntime, scenario); + await expect(target.control).toHaveCount(1); + await expect(target.control).toBeVisible(); + await expect(target.button).toHaveCount(1); + await expect(target.button).toBeVisible(); + await expect(target.button).toBeEnabled(); + await expect(target.count).toHaveCount(1); + await expect(target.count).toHaveText(target.expectedInitialDislikeCount); + await expect(target.likeButton).toHaveCount(1); + await expect(target.likeButton).toBeVisible(); + await expect(target.likeButton).toBeEnabled(); + await expect(target.likeCount).toHaveCount(1); + const initialNativeLikeCount = Number.parseInt(await target.likeCount.textContent(), 10); + expect(initialNativeLikeCount).toBeGreaterThanOrEqual(0); + + const dislike = await expectSuccessfulVoteActivation(adapter, scenario.destination.videoId, -1, target.button); + await expect(target.count).toHaveText(target.expectedCount); + await expect(target.control).toBeVisible(); + await expect(target.button).toHaveAttribute("aria-pressed", "true"); + await expect(target.control).toHaveClass(/style-default-active/); + await expect(target.likeButton).toHaveAttribute("aria-pressed", "false"); + await expect(target.likeControl).not.toHaveClass(/style-default-active/); + await expect(target.likeCount).toHaveText(String(initialNativeLikeCount)); + await expectCredentials(adapter); + + const like = await expectSuccessfulVoteActivation(adapter, scenario.destination.videoId, 1, target.likeButton); + expect(like.sharedUserId).toBe(dislike.sharedUserId); + await expect(target.count).toHaveText(target.expectedFinalDislikeCount); + await expect(target.control).toBeVisible(); + await expect(target.likeCount).toHaveText(String(initialNativeLikeCount + 1)); + await expect(target.likeButton).toHaveAttribute("aria-pressed", "true"); + await expect(target.likeControl).toHaveClass(/style-default-active/); + await expect(target.button).toHaveAttribute("aria-pressed", "false"); + await expect(target.control).not.toHaveClass(/style-default-active/); + await expectCredentials(adapter); + + expect(interactionRecords(adapter.readInteractionRecords()).map((record) => record.pathname)).toEqual([ + "/interact/vote", + "/interact/confirmVote", + "/interact/vote", + "/interact/confirmVote", + ]); + + return { + dislikeUserId: dislike.sharedUserId, + initialNativeLikeCount, + likeUserId: like.sharedUserId, + target, + }; +} + +async function runNavigationRuntimeContract({ adapter, afterNavigation, afterReactions, scenario }) { + let navigationHookCalled = false; + const runAfterNavigation = async () => { + if (navigationHookCalled) return; + navigationHookCalled = true; + await afterNavigation?.({ adapter, scenario }); + }; + + await runNavigationMatrixScenario({ + backend: adapter.backend, + beforeNonCurrentDuplicateDetach: async () => { + await runAfterNavigation(); + const target = getDestinationDislikePostconditionTarget(adapter.page, adapter.matrixRuntime, scenario); + await expectNonCurrentDuplicateActivationIgnored(adapter, scenario, target); + }, + page: adapter.page, + runtime: adapter.matrixRuntime, + scenario, + }); + await runAfterNavigation(); + if (scenario.postcondition === NO_DESTINATION_DISLIKE_POSTCONDITION) { + expect( + interactionRecords(adapter.readInteractionRecords()), + `${adapter.runtimeName} submitted a vote for an intentionally incomplete navigation surface`, + ).toEqual([]); + await expectCredentials(adapter); + await afterReactions?.({ adapter, result: null, scenario }); + return null; + } + const result = await expectDestinationReactionContract(adapter, scenario); + await afterReactions?.({ adapter, result, scenario }); + return result; +} + +module.exports = { + NON_CURRENT_DUPLICATE_SCENARIO_IDS, + createNavigationRuntimeContractAdapter, + expectNonCurrentDuplicateActivationIgnored, + expectDestinationReactionContract, + expectSuccessfulVoteActivation, + registerNavigationRuntimeContractScenarios, + runNavigationRuntimeContract, +}; diff --git a/Extensions/e2e/navigation-runtime-contract.spec.js b/Extensions/e2e/navigation-runtime-contract.spec.js new file mode 100644 index 0000000..0e278c4 --- /dev/null +++ b/Extensions/e2e/navigation-runtime-contract.spec.js @@ -0,0 +1,426 @@ +const { NAVIGATION_MATRIX, NAVIGATION_SCENARIO_RUNNER_IDS } = require("../UserScript/e2e/navigation-matrix"); +const { + NON_CURRENT_DUPLICATE_SCENARIO_IDS, + createNavigationRuntimeContractAdapter, + expectSuccessfulVoteActivation, + registerNavigationRuntimeContractScenarios, +} = require("./navigation-runtime-contract"); + +const USER_ID = "A".repeat(36); +const VIDEO_ID = "abcdefghijk"; + +function successfulInteractionRecords(value = -1) { + return [ + { + body: { userId: USER_ID, value, videoId: VIDEO_ID }, + method: "POST", + pathname: "/interact/vote", + respondedAt: Date.now(), + responseBody: { challenge: Buffer.alloc(16).toString("base64"), difficulty: 0 }, + responseStatus: 200, + }, + { + body: { solution: Buffer.alloc(4).toString("base64"), userId: USER_ID, videoId: VIDEO_ID }, + method: "POST", + pathname: "/interact/confirmVote", + respondedAt: Date.now(), + responseBody: true, + responseStatus: 200, + }, + ]; +} + +function activationAdapter(records) { + return createNavigationRuntimeContractAdapter({ + backend: {}, + expectedUserId: USER_ID, + matrixRuntime: { name: "userscript" }, + page: { waitForTimeout: jest.fn(async () => {}) }, + postActivationQuietMs: 0, + readInteractionRecords: () => records, + }); +} + +function collectWrapperNavigationTitles(wrapperPath, runtimeName) { + const mockSharedRegistrationCalls = []; + const mockTitles = []; + const mockTest = (title) => mockTitles.push(title); + mockTest.describe = (_title, registerTests) => registerTests(); + + jest.resetModules(); + jest.doMock("@playwright/test", () => ({ expect, test: mockTest })); + jest.doMock("./navigation-runtime-contract", () => { + const actualContract = jest.requireActual("./navigation-runtime-contract"); + return { + ...actualContract, + registerNavigationRuntimeContractScenarios(options) { + mockSharedRegistrationCalls.push(options.runtimeName); + return actualContract.registerNavigationRuntimeContractScenarios(options); + }, + }; + }); + try { + jest.isolateModules(() => require(wrapperPath)); + } finally { + jest.dontMock("@playwright/test"); + jest.dontMock("./navigation-runtime-contract"); + jest.resetModules(); + } + + return { + sharedRegistrationCalls: mockSharedRegistrationCalls, + titles: mockTitles.filter((title) => title.startsWith(`${runtimeName} navigation matrix:`)), + }; +} + +test.each(["userscript", "extension"])("registers every shared navigation scenario for %s", (runtimeName) => { + const registrations = []; + registerNavigationRuntimeContractScenarios({ + register: (registration) => registrations.push(registration), + runtimeName, + }); + + expect(registrations.map(({ scenario }) => scenario.id)).toEqual(NAVIGATION_MATRIX.map(({ id }) => id)); + expect(registrations.map(({ title }) => title)).toEqual( + NAVIGATION_MATRIX.map(({ id }) => `${runtimeName} navigation matrix: ${id}`), + ); +}); + +test.each([ + ["userscript", "../UserScript/e2e/userscript-navigation-matrix.e2e"], + ["extension", "./extension/extension-artifact-navigation.e2e"], +])("the %s Playwright wrapper registers the complete shared catalog", (runtimeName, wrapperPath) => { + const registration = collectWrapperNavigationTitles(wrapperPath, runtimeName); + expect(registration.sharedRegistrationCalls).toEqual([runtimeName]); + expect(registration.titles).toEqual(NAVIGATION_MATRIX.map(({ id }) => `${runtimeName} navigation matrix: ${id}`)); +}); + +test("keeps one navigation runner for every shared scenario and no unused runners", () => { + expect([...NAVIGATION_SCENARIO_RUNNER_IDS].sort()).toEqual(NAVIGATION_MATRIX.map(({ id }) => id).sort()); +}); + +test("keeps every hidden-first Watch duplicate in the shared runtime registrations", () => { + expect(NON_CURRENT_DUPLICATE_SCENARIO_IDS).toEqual([ + "watch-sidebar-watch-same-root-hidden-first", + "watch-sidebar-watch-same-root-offscreen-first", + "watch-sidebar-watch-legacy-segmented-duplicate-ids", + ]); + + for (const scenarioId of NON_CURRENT_DUPLICATE_SCENARIO_IDS) { + const scenario = NAVIGATION_MATRIX.find(({ id }) => id === scenarioId); + expect(scenario).toMatchObject({ destination: { kind: "watch" }, origin: { kind: "watch" } }); + expect(scenario.coverage.dom).toContain("rendered-destination-second"); + } +}); + +test("keeps the reused Shorts renderer without a finish event in both runtime registrations", () => { + const scenarioId = "short-next-short-reuse-renderer-start-no-finish"; + const scenario = NAVIGATION_MATRIX.find(({ id }) => id === scenarioId); + + expect(scenario).toMatchObject({ + coverage: { + dom: expect.arrayContaining([ + "reuse-exact-renderer", + "reuse-exact-action-root", + "exact-href-identity", + "no-is-active", + ]), + timing: expect.arrayContaining(["navigate-start-without-finish"]), + }, + destination: { + kind: "shorts", + shortIdentity: "exact-href-without-active-or-video-id", + }, + origin: { kind: "shorts" }, + }); + + for (const runtimeName of ["userscript", "extension"]) { + const registrations = []; + registerNavigationRuntimeContractScenarios({ + register: (registration) => registrations.push(registration), + runtimeName, + }); + + expect( + registrations.filter(({ scenario: registeredScenario }) => registeredScenario.id === scenarioId), + ).toHaveLength(1); + } +}); + +test("keeps the exact current Shorts link authoritative over an unrelated description cross-link", () => { + const scenarioId = "short-next-short-exact-href-with-description-crosslink"; + const scenario = NAVIGATION_MATRIX.find(({ id }) => id === scenarioId); + + expect(scenario).toMatchObject({ + coverage: { + dom: expect.arrayContaining([ + "reuse-exact-renderer", + "reuse-exact-action-root", + "exact-href-identity", + "unrelated-description-short-link", + "no-is-active", + "no-video-id", + ]), + timing: expect.arrayContaining(["navigate-start-without-finish"]), + }, + destination: { + kind: "shorts", + shortIdentity: "exact-href-without-active-or-video-id", + unrelatedDescriptionShortVideoId: "FupY92jTfho", + }, + origin: { kind: "shorts" }, + postcondition: "single-destination-dislike", + }); + + for (const runtimeName of ["userscript", "extension"]) { + const registrations = []; + registerNavigationRuntimeContractScenarios({ + register: (registration) => registrations.push(registration), + runtimeName, + }); + + expect( + registrations.filter(({ scenario: registeredScenario }) => registeredScenario.id === scenarioId), + ).toHaveLength(1); + } +}); + +test("keeps the persistent data-null exact-href Shorts recovery in both runtime registrations", () => { + const scenarioId = "short-next-short-reuse-renderer-replace-action-root-exact-href-persistent-data-null"; + const scenario = NAVIGATION_MATRIX.find(({ id }) => id === scenarioId); + + expect(scenario).toMatchObject({ + coverage: { + dom: expect.arrayContaining([ + "reuse-exact-renderer", + "replace-action-root", + "exact-href-identity", + "no-is-active", + "no-video-id", + "complete-native-inventory", + "persistent-data-null-action-root", + ]), + timing: expect.arrayContaining([ + "navigate-start-without-finish", + "data-null-past-watchdog", + "stable-native-inventory", + ]), + }, + destination: { + kind: "shorts", + shortIdentity: "exact-href-without-active-or-video-id", + }, + origin: { kind: "shorts" }, + timing: { maxFirstValidMs: 2_500, unsafeWindowMs: 520 }, + transition: { + actionRoot: "replace-complete-native-persistent-data-null", + navigateFinish: "none", + renderer: "reuse-exact-node", + }, + }); + + for (const runtimeName of ["userscript", "extension"]) { + const registrations = []; + registerNavigationRuntimeContractScenarios({ + register: (registration) => registrations.push(registration), + runtimeName, + }); + + expect( + registrations.filter(({ scenario: registeredScenario }) => registeredScenario.id === scenarioId), + ).toHaveLength(1); + } +}); + +test("keeps the incomplete rendered-inventory persistent data-null surface inert in both runtimes", () => { + const scenarioId = "short-next-short-persistent-data-null-nonrendered-native-stays-inert"; + const scenario = NAVIGATION_MATRIX.find(({ id }) => id === scenarioId); + + expect(scenario).toMatchObject({ + coverage: { + dom: expect.arrayContaining([ + "reuse-exact-renderer", + "replace-action-root", + "exact-href-identity", + "no-is-active", + "no-video-id", + "incomplete-rendered-native-inventory", + "non-rendered-native-action", + "persistent-data-null-action-root", + "stable-action-root-geometry", + ]), + timing: expect.arrayContaining([ + "navigate-start-without-finish", + "data-null-past-watchdog", + "inert-beyond-fallback-window", + ]), + }, + destination: { + kind: "shorts", + shortIdentity: "exact-href-without-active-or-video-id", + }, + origin: { kind: "shorts" }, + postcondition: "no-destination-dislike", + timing: { inertForMs: 1_700 }, + transition: { + actionRoot: "replace-incomplete-rendered-native-persistent-data-null", + navigateFinish: "none", + renderer: "reuse-exact-node", + }, + }); + + for (const runtimeName of ["userscript", "extension"]) { + const registrations = []; + registerNavigationRuntimeContractScenarios({ + register: (registration) => registrations.push(registration), + runtimeName, + }); + expect( + registrations.filter(({ scenario: registeredScenario }) => registeredScenario.id === scenarioId), + ).toHaveLength(1); + } +}); + +test("keeps the native-Dislike persistent data-null surface interactive in both runtimes", () => { + const scenarioId = "short-next-short-persistent-data-null-native-dislike"; + const scenario = NAVIGATION_MATRIX.find(({ id }) => id === scenarioId); + + expect(scenario).toMatchObject({ + coverage: { + dom: expect.arrayContaining([ + "reuse-exact-renderer", + "replace-action-root", + "exact-href-identity", + "no-is-active", + "no-video-id", + "native-dislike-present", + "no-synthetic-mutation", + "persistent-data-null-action-root", + ]), + timing: expect.arrayContaining([ + "navigate-start-without-finish", + "native-dislike-after-stability", + "native-dislike-without-synthetic-mutation", + ]), + }, + destination: { + kind: "shorts", + shortIdentity: "exact-href-without-active-or-video-id", + shortsDislikeControl: "native", + }, + origin: { kind: "shorts" }, + postcondition: "single-destination-dislike", + timing: { maxFirstValidMs: 2_500, unsafeWindowMs: 520 }, + transition: { + actionRoot: "replace-native-dislike-persistent-data-null", + navigateFinish: "none", + renderer: "reuse-exact-node", + }, + }); + + for (const runtimeName of ["userscript", "extension"]) { + const registrations = []; + registerNavigationRuntimeContractScenarios({ + register: (registration) => registrations.push(registration), + runtimeName, + }); + expect( + registrations.filter(({ scenario: registeredScenario }) => registeredScenario.id === scenarioId), + ).toHaveLength(1); + } +}); + +test.each([ + "short-next-short-replace-root-start-no-finish-staged-hydration", + "short-next-short-replace-root-repeated-start-no-finish-staged-hydration", +])("keeps the staged replacement-root deadlock regression in both runtime registrations: %s", (scenarioId) => { + const scenario = NAVIGATION_MATRIX.find(({ id }) => id === scenarioId); + + expect(scenario).toMatchObject({ + coverage: { + dom: expect.arrayContaining([ + "replace-shorts-root", + "destination-action-root-absent", + "staged-data-null-action-root", + "complete-native-inventory-before-hydration", + ]), + timing: expect.arrayContaining([ + "navigate-start-without-finish", + "no-controls-over-500ms", + "data-null-over-stability-window", + ]), + }, + destination: { kind: "shorts" }, + origin: { kind: "shorts" }, + transition: { + actionRoot: "absent-then-empty-then-native-then-hydrated", + navigateFinish: "none", + root: "replace", + }, + }); + + for (const runtimeName of ["userscript", "extension"]) { + const registrations = []; + registerNavigationRuntimeContractScenarios({ + register: (registration) => registrations.push(registration), + runtimeName, + }); + expect( + registrations.filter(({ scenario: registeredScenario }) => registeredScenario.id === scenarioId), + ).toHaveLength(1); + } +}); + +test("budgets the deliberately delayed Shorts hydration without loosening the default navigation gate", () => { + const delayedShorts = NAVIGATION_MATRIX.find(({ id }) => id === "watch-direct-short-delayed"); + const delayedWatch = NAVIGATION_MATRIX.find(({ id }) => id === "short-direct-watch-delayed"); + + expect(delayedShorts.timing).toEqual({ controlDelayMs: 600, maxFirstValidMs: 1_250 }); + expect(delayedWatch.timing).toEqual({ controlDelayMs: 600 }); +}); + +test("accepts one exact successful activation handshake", async () => { + const records = []; + const target = { + click: jest.fn(async () => records.push(...successfulInteractionRecords())), + }; + + await expect(expectSuccessfulVoteActivation(activationAdapter(records), VIDEO_ID, -1, target)).resolves.toMatchObject( + { + confirmationCount: 1, + interactionCount: 2, + sharedUserId: USER_ID, + voteCount: 1, + }, + ); +}); + +test.each([ + ["a duplicate chain", (records) => records.push(...successfulInteractionRecords())], + ["a false confirmation", (records) => (records[1].responseBody = false)], + ["the wrong video", (records) => (records[0].body.videoId = "zyxwvutsrqp")], +])("rejects %s", async (_label, corrupt) => { + const records = []; + const target = { + click: jest.fn(async () => { + records.push(...successfulInteractionRecords()); + corrupt(records); + }), + }; + + await expect(expectSuccessfulVoteActivation(activationAdapter(records), VIDEO_ID, -1, target)).rejects.toThrow( + /duplicate, malformed, or failed vote chain/, + ); +}); + +test("rejects an adapter that could not prove expected credential persistence", () => { + expect(() => + createNavigationRuntimeContractAdapter({ + backend: {}, + expectedCredentials: { registrationConfirmed: true, userId: USER_ID }, + matrixRuntime: { name: "userscript" }, + page: {}, + readInteractionRecords: () => [], + }), + ).toThrow("Expected credentials require a readCredentials() adapter."); +}); diff --git a/Extensions/e2e/playwright-extension-global-setup.js b/Extensions/e2e/playwright-extension-global-setup.js new file mode 100644 index 0000000..e8216a8 --- /dev/null +++ b/Extensions/e2e/playwright-extension-global-setup.js @@ -0,0 +1,35 @@ +const path = require("node:path"); +const { verifyRequestedExtensionArtifact } = require("./verify-extension-artifact"); + +const ALLOW_CUSTOM_ARTIFACT_ENV = "RYD_E2E_ALLOW_CUSTOM_EXTENSION_ARTIFACT"; +const REPOSITORY_EXTENSION_ARTIFACT = path.resolve(__dirname, "../combined/dist/chrome"); + +function selectedArtifact(env) { + const requested = env.RYD_EXTENSION_ARTIFACT?.trim(); + return requested ? path.resolve(requested) : REPOSITORY_EXTENSION_ARTIFACT; +} + +function verifyPlaywrightExtensionArtifact({ + env = process.env, + verifyArtifact = verifyRequestedExtensionArtifact, +} = {}) { + const artifact = selectedArtifact(env); + const custom = artifact !== REPOSITORY_EXTENSION_ARTIFACT; + if (custom && env[ALLOW_CUSTOM_ARTIFACT_ENV] !== "1") { + throw new Error( + `Refusing to run extension Playwright against custom artifact ${artifact}. ` + + `Set ${ALLOW_CUSTOM_ARTIFACT_ENV}=1 only for an intentional custom or negative-control fixture.`, + ); + } + return verifyArtifact(custom ? artifact : undefined); +} + +async function playwrightExtensionGlobalSetup() { + verifyPlaywrightExtensionArtifact(); +} + +module.exports = playwrightExtensionGlobalSetup; +module.exports.ALLOW_CUSTOM_ARTIFACT_ENV = ALLOW_CUSTOM_ARTIFACT_ENV; +module.exports.REPOSITORY_EXTENSION_ARTIFACT = REPOSITORY_EXTENSION_ARTIFACT; +module.exports.selectedArtifact = selectedArtifact; +module.exports.verifyPlaywrightExtensionArtifact = verifyPlaywrightExtensionArtifact; diff --git a/Extensions/e2e/playwright-extension-global-setup.spec.js b/Extensions/e2e/playwright-extension-global-setup.spec.js new file mode 100644 index 0000000..88840f4 --- /dev/null +++ b/Extensions/e2e/playwright-extension-global-setup.spec.js @@ -0,0 +1,69 @@ +/** + * @jest-environment node + */ + +const path = require("node:path"); +const extensionPlaywrightConfig = require("../../playwright.extension.config"); +const { + ALLOW_CUSTOM_ARTIFACT_ENV, + REPOSITORY_EXTENSION_ARTIFACT, + selectedArtifact, + verifyPlaywrightExtensionArtifact, +} = require("./playwright-extension-global-setup"); + +test("the extension Playwright config always runs the artifact freshness gate", () => { + expect(extensionPlaywrightConfig.globalSetup).toBe(require.resolve("./playwright-extension-global-setup")); +}); + +test("the default Playwright target receives full repository artifact verification", () => { + const verifyArtifact = jest.fn(() => ({ kind: "repository" })); + + expect(verifyPlaywrightExtensionArtifact({ env: {}, verifyArtifact })).toEqual({ kind: "repository" }); + expect(selectedArtifact({})).toBe(REPOSITORY_EXTENSION_ARTIFACT); + expect(verifyArtifact).toHaveBeenCalledWith(undefined); +}); + +test("a custom artifact cannot silently bypass the repository build receipt", () => { + const customArtifact = path.resolve("test-results", "intentional-broken-extension"); + const verifyArtifact = jest.fn(); + + expect(() => + verifyPlaywrightExtensionArtifact({ + env: { RYD_EXTENSION_ARTIFACT: customArtifact }, + verifyArtifact, + }), + ).toThrow(new RegExp(`${ALLOW_CUSTOM_ARTIFACT_ENV}=1`)); + expect(verifyArtifact).not.toHaveBeenCalled(); +}); + +test("an explicit custom-fixture opt-in still runs standalone artifact validation", () => { + const customArtifact = path.resolve("test-results", "intentional-broken-extension"); + const verifyArtifact = jest.fn(() => ({ kind: "custom" })); + + expect( + verifyPlaywrightExtensionArtifact({ + env: { + [ALLOW_CUSTOM_ARTIFACT_ENV]: "1", + RYD_EXTENSION_ARTIFACT: customArtifact, + }, + verifyArtifact, + }), + ).toEqual({ kind: "custom" }); + expect(verifyArtifact).toHaveBeenCalledWith(customArtifact); +}); + +test("truthy-looking custom-fixture values do not weaken the explicit gate", () => { + const customArtifact = path.resolve("test-results", "intentional-broken-extension"); + + for (const value of ["true", "yes", "0"]) { + expect(() => + verifyPlaywrightExtensionArtifact({ + env: { + [ALLOW_CUSTOM_ARTIFACT_ENV]: value, + RYD_EXTENSION_ARTIFACT: customArtifact, + }, + verifyArtifact: jest.fn(), + }), + ).toThrow(new RegExp(`${ALLOW_CUSTOM_ARTIFACT_ENV}=1`)); + } +}); diff --git a/Extensions/e2e/playwright-userscript-global-setup.js b/Extensions/e2e/playwright-userscript-global-setup.js new file mode 100644 index 0000000..502e6ce --- /dev/null +++ b/Extensions/e2e/playwright-userscript-global-setup.js @@ -0,0 +1,19 @@ +const { + resolveRequestedUserscriptArtifact, + verifyRequestedUserscriptArtifact, +} = require("./verify-userscript-artifact"); + +function verifyPlaywrightUserscriptArtifact({ + env = process.env, + verifyArtifact = verifyRequestedUserscriptArtifact, +} = {}) { + const artifactPath = resolveRequestedUserscriptArtifact(env.RYD_USERSCRIPT_ARTIFACT); + return verifyArtifact(artifactPath); +} + +async function playwrightUserscriptGlobalSetup() { + verifyPlaywrightUserscriptArtifact(); +} + +module.exports = playwrightUserscriptGlobalSetup; +module.exports.verifyPlaywrightUserscriptArtifact = verifyPlaywrightUserscriptArtifact; diff --git a/Extensions/e2e/playwright-userscript-global-setup.spec.js b/Extensions/e2e/playwright-userscript-global-setup.spec.js new file mode 100644 index 0000000..92fa18c --- /dev/null +++ b/Extensions/e2e/playwright-userscript-global-setup.spec.js @@ -0,0 +1,108 @@ +/** + * @jest-environment node + */ + +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const userscriptPlaywrightConfig = require("../../playwright.userscript.config"); +const { + REPOSITORY_USERSCRIPT_ARTIFACT, + receiptPathForUserscriptArtifact, + resolveRequestedUserscriptArtifact, +} = require("./verify-userscript-artifact"); +const { verifyPlaywrightUserscriptArtifact } = require("./playwright-userscript-global-setup"); +const { createUserscriptBuildReceipt, writeUserscriptBuildReceipt } = require("../../userscript-build-receipt"); + +const REPOSITORY_ROOT = path.resolve(__dirname, "../.."); +let temporaryDirectory; + +function createCustomArtifact(name = "Return Youtube Dislike.user.js") { + const directory = fs.mkdtempSync(path.join(temporaryDirectory, "artifact-")); + const artifactPath = path.join(directory, name); + fs.writeFileSync(artifactPath, "generated userscript"); + return artifactPath; +} + +function writeMatchingReceipt(artifactPath) { + const receipt = createUserscriptBuildReceipt(REPOSITORY_ROOT, { + artifactPath, + liveTestBuild: false, + mode: "production", + }); + return writeUserscriptBuildReceipt(receiptPathForUserscriptArtifact(artifactPath), artifactPath, receipt); +} + +beforeEach(() => { + temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "ryd-userscript-playwright-")); +}); + +afterEach(() => { + fs.rmSync(temporaryDirectory, { force: true, recursive: true }); +}); + +test("the userscript Playwright config always runs the artifact freshness gate", () => { + expect(userscriptPlaywrightConfig.globalSetup).toBe(require.resolve("./playwright-userscript-global-setup")); +}); + +test("the default Playwright target receives exact repository artifact verification", () => { + const verifyArtifact = jest.fn(() => ({ kind: "repository" })); + + expect(verifyPlaywrightUserscriptArtifact({ env: {}, verifyArtifact })).toEqual({ kind: "repository" }); + expect(verifyArtifact).toHaveBeenCalledWith(REPOSITORY_USERSCRIPT_ARTIFACT); +}); + +test("the harness and freshness gate resolve the same custom artifact", () => { + const requestedArtifact = path.join(temporaryDirectory, "custom.user.js"); + const previousArtifact = process.env.RYD_USERSCRIPT_ARTIFACT; + + try { + process.env.RYD_USERSCRIPT_ARTIFACT = ` ${requestedArtifact} `; + jest.isolateModules(() => { + const { GENERATED_USERSCRIPT } = require("../UserScript/e2e/harness"); + expect(GENERATED_USERSCRIPT).toBe(resolveRequestedUserscriptArtifact(requestedArtifact)); + }); + } finally { + if (previousArtifact === undefined) delete process.env.RYD_USERSCRIPT_ARTIFACT; + else process.env.RYD_USERSCRIPT_ARTIFACT = previousArtifact; + } +}); + +test("a custom artifact with its own matching receipt passes", () => { + const artifactPath = createCustomArtifact(); + writeMatchingReceipt(artifactPath); + + expect(verifyPlaywrightUserscriptArtifact({ env: { RYD_USERSCRIPT_ARTIFACT: artifactPath } })).toMatchObject({ + artifactPath: expect.any(String), + artifactHash: expect.stringMatching(/^[a-f0-9]{64}$/), + }); +}); + +test("an unreceipted custom artifact is rejected", () => { + const artifactPath = createCustomArtifact(); + + expect(() => verifyPlaywrightUserscriptArtifact({ env: { RYD_USERSCRIPT_ARTIFACT: artifactPath } })).toThrow( + /Missing userscript build receipt/, + ); +}); + +test("a custom artifact changed after its receipt is rejected", () => { + const artifactPath = createCustomArtifact(); + writeMatchingReceipt(artifactPath); + fs.writeFileSync(artifactPath, "stale generated userscript"); + + expect(() => verifyPlaywrightUserscriptArtifact({ env: { RYD_USERSCRIPT_ARTIFACT: artifactPath } })).toThrow( + /differs from its build receipt/, + ); +}); + +test("a receipt copied from another artifact path is rejected", () => { + const firstArtifact = createCustomArtifact("first.user.js"); + const secondArtifact = createCustomArtifact("second.user.js"); + writeMatchingReceipt(firstArtifact); + fs.copyFileSync(receiptPathForUserscriptArtifact(firstArtifact), receiptPathForUserscriptArtifact(secondArtifact)); + + expect(() => verifyPlaywrightUserscriptArtifact({ env: { RYD_USERSCRIPT_ARTIFACT: secondArtifact } })).toThrow( + /belongs to a different artifact path/, + ); +}); diff --git a/Extensions/e2e/runtime-resilience-contract.js b/Extensions/e2e/runtime-resilience-contract.js new file mode 100644 index 0000000..7b1c780 --- /dev/null +++ b/Extensions/e2e/runtime-resilience-contract.js @@ -0,0 +1,256 @@ +const { expect } = require("@playwright/test"); +const { VIDEO_A, VIDEO_B } = require("../UserScript/e2e/harness"); +const { SPA_COUNTS, isArtifactVoteHandshakeValid, readArtifactVoteHandshake } = require("./hermetic-artifact-smoke"); + +const INTERACTION_PATHS = new Set(["/interact/vote", "/interact/confirmVote"]); +const SUPPORTED_RUNTIME_NAMES = Object.freeze(["userscript", "extension"]); +const RUNTIME_RESILIENCE_SCENARIOS = Object.freeze([ + Object.freeze({ id: "delayed-a-hydration-vs-b-activation", kind: "delayed-hydration" }), + Object.freeze({ id: "in-flight-a-vote-then-b-navigation", kind: "in-flight-vote" }), + Object.freeze({ id: "rejected-vote-and-confirmation-then-b-recovery", kind: "failure-recovery" }), + Object.freeze({ id: "signed-out-and-disabled-interaction-gate", kind: "interaction-gate" }), +]); + +function registerRuntimeResilienceContractScenarios({ register, runtimeName }) { + if (!SUPPORTED_RUNTIME_NAMES.includes(runtimeName)) { + throw new TypeError(`Unsupported resilience contract runtime: ${runtimeName}`); + } + if (typeof register !== "function") { + throw new TypeError("The runtime resilience contract requires a scenario register callback."); + } + for (const scenario of RUNTIME_RESILIENCE_SCENARIOS) { + register({ scenario, title: `${runtimeName} resilience contract: ${scenario.id}` }); + } +} + +function interactionRecords(adapter) { + return adapter + .readInteractionRecords() + .filter((record) => record.method === "POST" && INTERACTION_PATHS.has(record.pathname)); +} + +function currentDislikeButton(adapter, videoId) { + return adapter.page.locator( + `ytd-watch-flexy[video-id="${videoId}"] [data-fixture-role="dislike"] button:visible, ` + + `ytd-watch-grid[video-id="${videoId}"] [data-fixture-role="dislike"] button:visible`, + ); +} + +async function clickCurrentDislike(adapter, videoId) { + const button = currentDislikeButton(adapter, videoId); + await expect(button, `${adapter.runtime} must expose one current Dislike button for ${videoId}`).toHaveCount(1); + await expect(button).toBeVisible(); + await expect(button).toBeEnabled(); + await button.click(); +} + +async function waitForRecordResponse(record, label) { + await expect.poll(() => record.respondedAt, { message: `${label} never received a response` }).toBeTruthy(); +} + +async function expectSuccessfulHandshake(adapter, startIndex, videoId, value) { + const read = () => readArtifactVoteHandshake(adapter.readInteractionRecords(), startIndex, videoId, value); + await expect + .poll( + () => { + const handshake = read(); + return handshake.interactionCount >= 2 && handshake.confirmation?.responded === true; + }, + { message: `${adapter.runtime} did not complete the ${value} vote chain for ${videoId}` }, + ) + .toBe(true); + const handshake = read(); + expect(isArtifactVoteHandshakeValid(handshake), JSON.stringify(handshake)).toBe(true); + return handshake; +} + +async function assertRuntimeClean(adapter, scenarioId, { allowedConsoleErrors = [] } = {}) { + expect(adapter.backend.blockedRequests, `${adapter.runtime} escaped the hermetic page route set`).toEqual([]); + if (adapter.apiServer) { + expect(adapter.apiServer.unexpectedRequests, "the extension background escaped its hermetic API server").toEqual( + [], + ); + } + if (allowedConsoleErrors.length === 0) { + await adapter.assertNoPageSignals(scenarioId); + return; + } + + await adapter.page.evaluate(() => new Promise((resolve) => setTimeout(resolve, 0))); + await adapter.workerSignals?.refresh?.(); + const snapshot = adapter.pageSignals.snapshot(); + const pageSignals = snapshot.page ?? snapshot; + const workerSignals = snapshot.worker ?? null; + expect(pageSignals.pageErrors, `${adapter.runtime} emitted a page error during ${scenarioId}`).toEqual([]); + expect( + pageSignals.unhandledRejections, + `${adapter.runtime} emitted an unhandled rejection during ${scenarioId}`, + ).toEqual([]); + if (workerSignals) { + expect(workerSignals.evaluationFailure).toBeNull(); + expect(workerSignals.evaluatedSignals).toEqual([]); + expect(workerSignals.reportedSignals).toEqual([]); + } + const consoleSignals = [ + ...pageSignals.consoleErrors.map((signal) => signal.text), + ...(workerSignals?.consoleFailures ?? []).map((signal) => signal.text), + ]; + expect( + consoleSignals.every((text) => allowedConsoleErrors.some((pattern) => pattern.test(text))), + `${adapter.runtime} emitted an unexpected console error during ${scenarioId}: ${JSON.stringify(consoleSignals)}`, + ).toBe(true); +} + +async function withAdapter(createAdapter, options, run) { + const adapter = await createAdapter(options); + try { + await adapter.start(); + await run(adapter); + } finally { + await adapter.close().catch(() => {}); + } +} + +async function runDelayedHydrationContract(createAdapter, scenario) { + await withAdapter(createAdapter, { scenario }, async (adapter) => { + const outgoingStats = adapter.deferNextStatsRequest(); + await adapter.openSpaWatch(VIDEO_A); + const outgoingRecord = await outgoingStats.seen; + expect(outgoingRecord.query.videoId).toBe(VIDEO_A); + + await adapter.navigateSpaWatchWhilePending(VIDEO_A, VIDEO_B); + outgoingStats.release({ body: { ...SPA_COUNTS[VIDEO_A], rating: 4.5 }, status: 200 }); + + const destination = await adapter.waitForWatchResult(VIDEO_B); + expect(destination).toMatchObject({ count: String(SPA_COUNTS[VIDEO_B].dislikes), videoId: VIDEO_B }); + expect(await adapter.readDestinationDislikeTextHistory()).not.toContain(String(SPA_COUNTS[VIDEO_A].dislikes)); + + const interactionStartIndex = adapter.readInteractionRecords().length; + await clickCurrentDislike(adapter, VIDEO_B); + const handshake = await expectSuccessfulHandshake(adapter, interactionStartIndex, VIDEO_B, -1); + expect(handshake.sharedUserId).toMatch(/^[0-9A-Za-z-]{30,}$/); + expect(interactionRecords(adapter).map((record) => record.body.videoId)).toEqual([VIDEO_B, VIDEO_B]); + + const stats = adapter.readStatsRequestTimings(); + expect(stats.map((record) => record.query.videoId)).toEqual([VIDEO_A, VIDEO_B]); + expect(stats.every((record) => record.respondedAt)).toBe(true); + await assertRuntimeClean(adapter, scenario.id); + }); +} + +async function runInFlightVoteContract(createAdapter, scenario) { + await withAdapter(createAdapter, { scenario }, async (adapter) => { + await adapter.openSpaWatch(VIDEO_A); + await adapter.waitForWatchResult(VIDEO_A); + const outgoingConfirmation = adapter.deferInteractionResponse("/interact/confirmVote"); + + await clickCurrentDislike(adapter, VIDEO_A); + const outgoingConfirmationRecord = await outgoingConfirmation.seen; + expect(outgoingConfirmationRecord.body).toMatchObject({ videoId: VIDEO_A }); + expect(outgoingConfirmationRecord.respondedAt).toBeUndefined(); + + await adapter.navigateSpaWatch(VIDEO_A, VIDEO_B); + await adapter.waitForWatchResult(VIDEO_B); + const destinationStartIndex = adapter.readInteractionRecords().length; + await clickCurrentDislike(adapter, VIDEO_B); + const destinationHandshake = await expectSuccessfulHandshake(adapter, destinationStartIndex, VIDEO_B, -1); + expect(outgoingConfirmationRecord.respondedAt).toBeUndefined(); + + outgoingConfirmation.release({ body: true, status: 200 }); + await waitForRecordResponse(outgoingConfirmationRecord, `${adapter.runtime} video A confirmation`); + const records = interactionRecords(adapter); + expect(records.map((record) => record.pathname)).toEqual([ + "/interact/vote", + "/interact/confirmVote", + "/interact/vote", + "/interact/confirmVote", + ]); + expect(records.map((record) => record.body.videoId)).toEqual([VIDEO_A, VIDEO_A, VIDEO_B, VIDEO_B]); + expect(records[2].at).toBeLessThan(outgoingConfirmationRecord.respondedAt); + expect(new Set(records.map((record) => record.body.userId))).toEqual(new Set([destinationHandshake.sharedUserId])); + await assertRuntimeClean(adapter, scenario.id); + }); +} + +async function runFailureRecoveryContract(createAdapter, scenario) { + await withAdapter(createAdapter, { scenario }, async (adapter) => { + adapter.enqueueInteractionResponse("/interact/vote", { + body: { error: "intentional vote rejection" }, + status: 500, + }); + await adapter.openSpaWatch(VIDEO_A); + await adapter.waitForWatchResult(VIDEO_A); + + await clickCurrentDislike(adapter, VIDEO_A); + await expect.poll(() => interactionRecords(adapter).length).toBe(1); + const rejectedVote = interactionRecords(adapter)[0]; + await waitForRecordResponse(rejectedVote, `${adapter.runtime} rejected vote`); + expect(rejectedVote).toMatchObject({ pathname: "/interact/vote", responseStatus: 500 }); + expect(rejectedVote.body).toMatchObject({ videoId: VIDEO_A, value: -1 }); + + adapter.enqueueInteractionResponse("/interact/confirmVote", { body: false, status: 200 }); + await clickCurrentDislike(adapter, VIDEO_A); + await expect.poll(() => interactionRecords(adapter).length).toBe(3); + const rejectedConfirmation = interactionRecords(adapter)[2]; + await waitForRecordResponse(rejectedConfirmation, `${adapter.runtime} rejected confirmation`); + expect(rejectedConfirmation).toMatchObject({ + pathname: "/interact/confirmVote", + responseBody: false, + responseStatus: 200, + }); + expect(interactionRecords(adapter)[1].body).toMatchObject({ videoId: VIDEO_A, value: 0 }); + + await adapter.navigateSpaWatch(VIDEO_A, VIDEO_B); + await adapter.waitForWatchResult(VIDEO_B); + const destinationStartIndex = adapter.readInteractionRecords().length; + await clickCurrentDislike(adapter, VIDEO_B); + const destinationHandshake = await expectSuccessfulHandshake(adapter, destinationStartIndex, VIDEO_B, -1); + + const records = interactionRecords(adapter); + expect(records.map((record) => `${record.pathname}:${record.body.videoId}`)).toEqual([ + `/interact/vote:${VIDEO_A}`, + `/interact/vote:${VIDEO_A}`, + `/interact/confirmVote:${VIDEO_A}`, + `/interact/vote:${VIDEO_B}`, + `/interact/confirmVote:${VIDEO_B}`, + ]); + expect(new Set(records.map((record) => record.body.userId))).toEqual(new Set([destinationHandshake.sharedUserId])); + await assertRuntimeClean(adapter, scenario.id, { allowedConsoleErrors: [/status of 500/i] }); + }); +} + +async function runInteractionGateContract(createAdapter, scenario) { + for (const gateMode of ["signed-out", "disabled"]) { + await withAdapter(createAdapter, { gateMode, scenario }, async (adapter) => { + if (gateMode === "disabled") await adapter.setVoteSubmissionDisabled(true); + await adapter.openWatch(VIDEO_A); + await adapter.waitForWatchResult(VIDEO_A); + await clickCurrentDislike(adapter, VIDEO_A); + await adapter.page.waitForTimeout(250); + expect(interactionRecords(adapter), `${adapter.runtime} bypassed the ${gateMode} interaction gate`).toEqual([]); + await assertRuntimeClean(adapter, `${scenario.id}-${gateMode}`); + }); + } +} + +const SCENARIO_RUNNERS = Object.freeze({ + "delayed-hydration": runDelayedHydrationContract, + "failure-recovery": runFailureRecoveryContract, + "in-flight-vote": runInFlightVoteContract, + "interaction-gate": runInteractionGateContract, +}); + +async function runRuntimeResilienceContract({ createAdapter, scenario }) { + if (typeof createAdapter !== "function") { + throw new TypeError("The runtime resilience contract requires createAdapter()."); + } + const runner = SCENARIO_RUNNERS[scenario?.kind]; + if (!runner) throw new TypeError(`Unknown runtime resilience scenario: ${scenario?.kind}`); + await runner(createAdapter, scenario); +} + +module.exports = { + RUNTIME_RESILIENCE_SCENARIOS, + registerRuntimeResilienceContractScenarios, + runRuntimeResilienceContract, +}; diff --git a/Extensions/e2e/shared-browser-parity.spec.js b/Extensions/e2e/shared-browser-parity.spec.js new file mode 100644 index 0000000..c6dae43 --- /dev/null +++ b/Extensions/e2e/shared-browser-parity.spec.js @@ -0,0 +1,270 @@ +/** + * @jest-environment node + */ + +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const { NAVIGATION_MATRIX } = require("../UserScript/e2e/navigation-matrix"); +const { + ARTIFACT_BROWSER_SCENARIO_CATALOG, + SHARED_ARTIFACT_SCENARIO_IDS, + createArtifactBrowserScenarioPlan, +} = require("./hermetic-artifact-smoke"); +const { SHORTS_PLACEHOLDER_POOL_HOPS } = require("./shorts-placeholder-pool-contract"); +const { RUNTIME_RESILIENCE_SCENARIOS } = require("./runtime-resilience-contract"); +const { WATCH_VISUAL_SCENARIOS } = require("./watch-visual-scenarios"); + +const REPOSITORY_ROOT = path.resolve(__dirname, "../.."); +const SHARED_BROWSER_RUNTIMES = Object.freeze(["userscript", "extension"]); +const PLAYWRIGHT_CONFIGS = Object.freeze({ + extension: require(path.join(REPOSITORY_ROOT, "playwright.extension.config.js")), + userscript: require(path.join(REPOSITORY_ROOT, "playwright.userscript.config.js")), +}); +const WRAPPERS = Object.freeze({ + extension: Object.freeze({ + navigation: path.join(__dirname, "extension", "extension-artifact-navigation.e2e.js"), + resilience: path.join(__dirname, "extension", "extension-runtime-resilience.e2e.js"), + shorts: path.join(__dirname, "extension", "extension-artifact-navigation.e2e.js"), + watch: path.join(__dirname, "extension", "extension-artifact-visual.e2e.js"), + }), + userscript: Object.freeze({ + navigation: path.join(__dirname, "..", "UserScript", "e2e", "userscript-navigation-matrix.e2e.js"), + resilience: path.join(__dirname, "..", "UserScript", "e2e", "userscript-runtime-resilience.e2e.js"), + shorts: path.join(__dirname, "..", "UserScript", "e2e", "userscript-shorts-placeholder-pool.e2e.js"), + watch: path.join(__dirname, "..", "UserScript", "e2e", "userscript-state-visual-contract.e2e.js"), + }), +}); +const RENDER_ORACLES = Object.freeze({ + extension: path.join(__dirname, "extension", "extension-render-oracle.e2e.js"), + userscript: path.join(__dirname, "..", "UserScript", "e2e", "userscript-render-oracle.e2e.js"), +}); + +function wrapperSource(runtime, contract) { + return fs.readFileSync(WRAPPERS[runtime][contract], "utf8"); +} + +function expectOneSharedCall(source, moduleName, callName) { + expect(source).toMatch(new RegExp(`require\\(["'][^"']*${moduleName}["']\\)`)); + expect(source.match(new RegExp(`\\b${callName}\\s*\\(`, "g"))).toHaveLength(1); +} + +function registeredTestTitles(wrapperPath) { + const titles = []; + const fakeTest = (title) => titles.push(title); + fakeTest.describe = (_title, registerTests) => registerTests(); + fakeTest.describe.configure = jest.fn(); + + jest.resetModules(); + jest.doMock("@playwright/test", () => ({ expect, test: fakeTest })); + try { + jest.isolateModules(() => require(wrapperPath)); + } finally { + jest.dontMock("@playwright/test"); + jest.resetModules(); + } + return titles; +} + +function sharedCoreScenarioIds(runtime) { + if (!SHARED_BROWSER_RUNTIMES.includes(runtime)) { + throw new TypeError(`Unsupported shared browser runtime: ${runtime}`); + } + return [ + ...NAVIGATION_MATRIX.map(({ id }) => `navigation:${id}`), + ...WATCH_VISUAL_SCENARIOS.map(({ id }) => `watch-visual:${id}`), + ...RUNTIME_RESILIENCE_SCENARIOS.map(({ id }) => `resilience:${id}`), + `shorts-placeholder-pool:${SHORTS_PLACEHOLDER_POOL_HOPS}-hops`, + ...ARTIFACT_BROWSER_SCENARIO_CATALOG.filter(({ runtimes, shared }) => shared && runtimes.includes(runtime)).map( + ({ id }) => `artifact:${id}`, + ), + ]; +} + +function assertIdenticalSharedCoreCatalog(catalogByRuntime) { + for (const runtime of SHARED_BROWSER_RUNTIMES) { + const scenarioIds = catalogByRuntime[runtime]; + assert.ok(Array.isArray(scenarioIds), `Missing shared browser scenario catalog for ${runtime}.`); + assert.equal( + new Set(scenarioIds).size, + scenarioIds.length, + `${runtime} shared browser scenario catalog contains duplicate IDs.`, + ); + } + assert.deepEqual( + catalogByRuntime.extension, + catalogByRuntime.userscript, + "Userscript and extension must register the identical shared browser scenario catalog.", + ); + return catalogByRuntime; +} + +function assertWrapperDiscoveredByPlaywright(runtime, wrapperPath, configuration = PLAYWRIGHT_CONFIGS[runtime]) { + assert.ok(configuration, `Missing Playwright configuration for ${runtime}.`); + const testDirectory = path.resolve(REPOSITORY_ROOT, configuration.testDir); + const absoluteWrapperPath = path.resolve(wrapperPath); + const relativePath = path.relative(testDirectory, absoluteWrapperPath); + const normalizedRelativePath = relativePath.split(path.sep).join("/"); + + assert.ok( + relativePath && + relativePath !== ".." && + !relativePath.startsWith(`..${path.sep}`) && + !path.isAbsolute(relativePath), + `${runtime} shared browser wrapper is outside its configured Playwright test directory: ${absoluteWrapperPath}`, + ); + assert.equal( + configuration.testMatch, + "**/*.e2e.js", + `${runtime} Playwright testMatch no longer selects shared wrappers.`, + ); + assert.match( + normalizedRelativePath, + /(?:^|\/)\w[^/]*\.e2e\.js$/, + `${absoluteWrapperPath} is not a Playwright E2E spec.`, + ); + for (const ignoredPattern of [configuration.testIgnore].flat().filter(Boolean)) { + if (ignoredPattern === "**/live/**") { + assert.ok( + !normalizedRelativePath.split("/").includes("live"), + `${runtime} shared browser wrapper is excluded by Playwright testIgnore: ${absoluteWrapperPath}`, + ); + } + } + assert.ok(fs.statSync(absoluteWrapperPath).isFile(), `${runtime} shared browser wrapper does not exist.`); + return normalizedRelativePath; +} + +describe("shared browser-contract parity", () => { + test("userscript and extension expose one identical 50-scenario core browser catalog", () => { + const catalogByRuntime = Object.fromEntries( + SHARED_BROWSER_RUNTIMES.map((runtime) => [runtime, sharedCoreScenarioIds(runtime)]), + ); + + expect(assertIdenticalSharedCoreCatalog(catalogByRuntime)).toBe(catalogByRuntime); + expect(catalogByRuntime.userscript).toHaveLength(50); + expect(catalogByRuntime.userscript.filter((id) => id.startsWith("navigation:"))).toHaveLength( + NAVIGATION_MATRIX.length, + ); + expect(catalogByRuntime.userscript.filter((id) => id.startsWith("watch-visual:"))).toHaveLength(18); + expect(catalogByRuntime.userscript.filter((id) => id.startsWith("resilience:"))).toHaveLength(4); + expect(catalogByRuntime.userscript.filter((id) => id.startsWith("artifact:"))).toEqual( + SHARED_ARTIFACT_SCENARIO_IDS.map((id) => `artifact:${id}`), + ); + }); + + test("the parity gate fails when either artifact loses one shared core scenario", () => { + const catalogByRuntime = Object.fromEntries( + SHARED_BROWSER_RUNTIMES.map((runtime) => [runtime, sharedCoreScenarioIds(runtime)]), + ); + catalogByRuntime.extension = catalogByRuntime.extension.filter( + (scenarioId) => scenarioId !== `artifact:${SHARED_ARTIFACT_SCENARIO_IDS[0]}`, + ); + + expect(() => assertIdenticalSharedCoreCatalog(catalogByRuntime)).toThrow( + "Userscript and extension must register the identical shared browser scenario catalog.", + ); + }); + + test.each(SHARED_BROWSER_RUNTIMES)("%s Playwright config discovers every shared contract wrapper", (runtime) => { + const uniqueWrappers = [...new Set(Object.values(WRAPPERS[runtime]))]; + expect(uniqueWrappers.map((wrapperPath) => assertWrapperDiscoveredByPlaywright(runtime, wrapperPath))).toHaveLength( + uniqueWrappers.length, + ); + }); + + test("the wrapper-discovery gate rejects a shared wrapper from another runtime's test directory", () => { + expect(() => assertWrapperDiscoveredByPlaywright("extension", WRAPPERS.userscript.watch)).toThrow( + /outside its configured Playwright test directory/, + ); + }); + + test.each(SHARED_BROWSER_RUNTIMES)("%s has a discovered no-op-artifact render oracle", (runtime) => { + const oraclePath = RENDER_ORACLES[runtime]; + expect(assertWrapperDiscoveredByPlaywright(runtime, oraclePath)).toBe( + path + .relative(path.resolve(REPOSITORY_ROOT, PLAYWRIGHT_CONFIGS[runtime].testDir), oraclePath) + .split(path.sep) + .join("/"), + ); + expect(registeredTestTitles(oraclePath)).toEqual([ + `the render oracle rejects a generated ${runtime} whose ${ + runtime === "extension" ? "content script" : "runtime body" + } does nothing`, + ]); + }); + + test("the systematic command executes the catalogued artifact runner and both Playwright runtimes", () => { + const { scripts } = require(path.join(REPOSITORY_ROOT, "package.json")); + expect(scripts["test:e2e:artifacts"]).toBe( + "npm run check:extension-artifact && npm run check:userscript-artifact && node Extensions/e2e/hermetic-artifact-smoke.js", + ); + expect(scripts["test:e2e:systematic"]).toContain("npm run test:e2e:artifacts"); + expect(scripts["test:e2e:systematic"]).toContain("npm run test:e2e:extension:run"); + expect(scripts["test:e2e:systematic"]).toContain("npm run test:e2e:userscript:run"); + expect(scripts["test:all"]).toContain("npm run test:e2e:systematic"); + expect( + createArtifactBrowserScenarioPlan().filter(({ scenarioId }) => SHARED_ARTIFACT_SCENARIO_IDS.includes(scenarioId)), + ).toHaveLength(SHARED_ARTIFACT_SCENARIO_IDS.length * SHARED_BROWSER_RUNTIMES.length); + }); + + test.each(["userscript", "extension"])("%s registers the complete shared navigation matrix", (runtime) => { + expect(NAVIGATION_MATRIX.length).toBeGreaterThan(0); + expectOneSharedCall( + wrapperSource(runtime, "navigation"), + "navigation-runtime-contract", + "registerNavigationRuntimeContractScenarios", + ); + expect( + registeredTestTitles(WRAPPERS[runtime].navigation).filter((title) => + title.startsWith(`${runtime} navigation matrix:`), + ), + ).toEqual(NAVIGATION_MATRIX.map(({ id }) => `${runtime} navigation matrix: ${id}`)); + }); + + test.each(["userscript", "extension"])("%s registers the complete shared Watch visual matrix", (runtime) => { + expect(WATCH_VISUAL_SCENARIOS).toHaveLength(18); + expectOneSharedCall(wrapperSource(runtime, "watch"), "watch-visual-scenarios", "registerWatchVisualContract"); + expect(registeredTestTitles(WRAPPERS[runtime].watch)).toEqual(WATCH_VISUAL_SCENARIOS.map(({ id }) => id)); + }); + + test.each(["userscript", "extension"])("%s gates successful Watch screenshots as visual evidence", (runtime) => { + const source = wrapperSource(runtime, "watch"); + expectOneSharedCall(source, "visual-evidence", "captureOptionalVisualEvidence"); + expect(source).toMatch(/\bannotateVisualEvidence\s*\(/); + expect(source).toContain("scenario.id"); + if (runtime === "userscript") { + expect(source).toMatch(/["']visual-review["']\s*,\s*["']userscript["']/); + expect(source).toContain("getWatchNeutralBaselineId"); + } + }); + + test.each(["userscript", "extension"])("%s registers the complete shared resilience matrix", (runtime) => { + expect(RUNTIME_RESILIENCE_SCENARIOS).toHaveLength(4); + expectOneSharedCall( + wrapperSource(runtime, "resilience"), + "runtime-resilience-contract", + "registerRuntimeResilienceContractScenarios", + ); + expect(registeredTestTitles(WRAPPERS[runtime].resilience)).toEqual( + RUNTIME_RESILIENCE_SCENARIOS.map(({ id }) => `${runtime} resilience contract: ${id}`), + ); + }); + + test.each(["userscript", "extension"])( + "%s invokes the shared ten-hop Shorts placeholder-pool contract", + (runtime) => { + expect(SHORTS_PLACEHOLDER_POOL_HOPS).toBeGreaterThanOrEqual(10); + expectOneSharedCall( + wrapperSource(runtime, "shorts"), + "shorts-placeholder-pool-contract", + "runShortsPlaceholderPoolContract", + ); + expect( + registeredTestTitles(WRAPPERS[runtime].shorts).filter((title) => + title.includes("ten pre-rendered and recycled Next transitions"), + ), + ).toHaveLength(1); + }, + ); +}); diff --git a/Extensions/e2e/shared-live-scenarios.js b/Extensions/e2e/shared-live-scenarios.js index 4491913..be77c22 100644 --- a/Extensions/e2e/shared-live-scenarios.js +++ b/Extensions/e2e/shared-live-scenarios.js @@ -10,16 +10,22 @@ const SHARED_LIVE_SCENARIOS = Object.freeze([ "navigateFromColdChannelToShort", "navigateToNextShort", "pausePlayback", + "soakCurrentShortsControl", + "withExactVotesRequest", "withNoProductionInteractions", ], + driverMethodsByCapability: { + "native-pair": ["captureNativeShortsVisual"], + "strict-synthetic": ["captureSyntheticShortsVisual"], + }, id: "channel-shorts-navigation", implementation: "runChannelShortsNavigationScenario", }, { driverMethods: [ ...COMMON_PRECONDITION_METHODS, + "assertCurrentWatchResult", "navigateFromColdChannelToWatch", - "waitForDislikeText", "withNoProductionInteractions", ], id: "channel-watch-navigation", @@ -28,8 +34,8 @@ const SHARED_LIVE_SCENARIOS = Object.freeze([ { driverMethods: [ ...COMMON_PRECONDITION_METHODS, + "assertCurrentWatchResult", "openPlaylist", - "waitForDislikeText", "withNoProductionInteractions", ], id: "watch-render", @@ -38,9 +44,9 @@ const SHARED_LIVE_SCENARIOS = Object.freeze([ { driverMethods: [ ...COMMON_PRECONDITION_METHODS, + "assertCurrentWatchResult", "openPlaylist", "reload", - "waitForDislikeText", "withNoProductionInteractions", ], id: "reload", @@ -49,18 +55,34 @@ const SHARED_LIVE_SCENARIOS = Object.freeze([ { driverMethods: [ ...COMMON_PRECONDITION_METHODS, + "assertCurrentWatchResult", "navigateWithinPlaylist", "openPlaylist", - "waitForDislikeText", "withNoProductionInteractions", ], id: "spa-navigation", implementation: "runSpaNavigationScenario", }, + { + driverMethods: [ + ...COMMON_PRECONDITION_METHODS, + "assertRenderedDislikeCount", + "captureWatchActionTopologyVisual", + "navigateToRelatedWatch", + "openWatch", + "readViewportSize", + "setViewportSize", + "waitForDislikeText", + "withNoProductionInteractions", + ], + id: "watch-action-topology", + implementation: "runWatchActionTopologyScenario", + }, { driverMethods: [ ...COMMON_PRECONDITION_METHODS, "assertCurrentVideo", + "assertRenderedDislikeCount", "captureWatchRatioVisual", "navigateToRelatedWatch", "openWatch", @@ -71,7 +93,18 @@ const SHARED_LIVE_SCENARIOS = Object.freeze([ implementation: "runSidebarStressScenario", }, { - driverMethods: [...COMMON_PRECONDITION_METHODS, "openShort", "waitForDislikeText", "withNoProductionInteractions"], + driverMethods: [ + ...COMMON_PRECONDITION_METHODS, + "assertCurrentShortsControl", + "openShort", + "reload", + "soakCurrentShortsControl", + "withNoProductionInteractions", + ], + driverMethodsByCapability: { + "native-pair": ["captureNativeShortsVisual"], + "strict-synthetic": ["captureSyntheticShortsVisual"], + }, id: "shorts-render", implementation: "runShortsRenderScenario", }, @@ -79,6 +112,7 @@ const SHARED_LIVE_SCENARIOS = Object.freeze([ driverMethods: [ ...COMMON_PRECONDITION_METHODS, "assertCurrentShortsControl", + "assertRenderedDislikeCount", "captureWatchRatioVisual", "openWatch", "readViewportSize", @@ -97,6 +131,22 @@ const SHARED_LIVE_SCENARIOS = Object.freeze([ driverMethods: [ ...COMMON_PRECONDITION_METHODS, "assertCurrentVideo", + "clickAction", + "navigateWithinPlaylist", + "openPlaylist", + "readReactionState", + "waitForDislikeText", + "waitForReactionState", + ], + id: "post-navigation-vote", + implementation: "runPostNavigationVoteScenario", + }, + { + driverMethods: [ + ...COMMON_PRECONDITION_METHODS, + "assertCurrentVideo", + "assertDislikeCountChangesObservable", + "assertRenderedDislikeCount", "captureReactionStateVisual", "clickAction", "openShort", @@ -156,12 +206,26 @@ function createSharedLiveScenarioRunner({ implementations = defaultImplementatio const implementation = requireImplementation(implementations, scenario); const scenarioOptions = adapter.createScenarioOptions(options); + if (scenarioId === "channel-shorts-navigation") { + return implementation(adapter.driver, scenarioOptions, services.shortsNavigationOptions); + } if (scenarioId === "responsive-visual") { return implementation(adapter.driver, scenarioOptions, services.visualOptions); } if (scenarioId === "sidebar-navigation-stress") { return implementation(adapter.driver, scenarioOptions, services.sidebarOptions); } + if (scenarioId === "watch-action-topology") { + return implementation(adapter.driver, scenarioOptions, services.topologyOptions); + } + if (scenarioId === "post-navigation-vote") { + return implementation( + adapter.driver, + scenarioOptions, + requireService(services, "createRecorder", scenarioId), + requireService(services, "consumeVoteApproval", scenarioId), + ); + } if (scenarioId === "reaction-matrix") { return implementation( adapter.driver, diff --git a/Extensions/e2e/shared-live-scenarios.spec.js b/Extensions/e2e/shared-live-scenarios.spec.js index 521518b..084e09b 100644 --- a/Extensions/e2e/shared-live-scenarios.spec.js +++ b/Extensions/e2e/shared-live-scenarios.spec.js @@ -15,9 +15,11 @@ const EXPECTED_SCENARIO_IDS = [ "watch-render", "reload", "spa-navigation", + "watch-action-topology", "sidebar-navigation-stress", "shorts-render", "responsive-visual", + "post-navigation-vote", "reaction-matrix", ]; const EXPECTED_BUILD_ID = "0123456789abcdef0123456789abcdef"; @@ -25,13 +27,18 @@ const EXPECTED_BUILD_ID = "0123456789abcdef0123456789abcdef"; const ALL_DRIVER_METHODS = [ "assertCurrentShortsControl", "assertCurrentVideo", + "assertCurrentWatchResult", + "assertDislikeCountChangesObservable", + "assertRenderedDislikeCount", "assertRuntime", "assertSignedIn", "captureReactionStateVisual", "captureNativeShortsVisual", "captureSyntheticShortsVisual", + "captureWatchActionTopologyVisual", "captureWatchRatioVisual", "clickAction", + "configureRequestAttributionRuntime", "navigateFromColdChannelToShort", "navigateFromColdChannelToWatch", "navigateToNextShort", @@ -45,9 +52,11 @@ const ALL_DRIVER_METHODS = [ "readViewportSize", "reload", "setViewportSize", + "soakCurrentShortsControl", "soakWatchRatioVisual", "waitForDislikeText", "waitForReactionState", + "withExactVotesRequest", "withNoProductionInteractions", ]; @@ -59,7 +68,7 @@ function createDriver(overrides = {}) { } function createAdapter(runtime, driver = createDriver(), expectedBuildId = EXPECTED_BUILD_ID) { - const options = { driver, expectedBuildId, expectedVersion: runtime === "userscript" ? "3.2.0" : "4.0.4" }; + const options = { driver, expectedBuildId, expectedVersion: runtime === "userscript" ? "3.2.0" : "4.0.5" }; return runtime === "userscript" ? createUserscriptLiveRuntimeAdapter(options) : createExtensionLiveRuntimeAdapter(options); @@ -72,9 +81,11 @@ function createImplementations(events) { "runWatchRenderScenario", "runReloadScenario", "runSpaNavigationScenario", + "runWatchActionTopologyScenario", "runSidebarStressScenario", "runShortsRenderScenario", "runResponsiveVisualScenario", + "runPostNavigationVoteScenario", "runProductionReactionMatrixScenario", ]; return Object.fromEntries( @@ -96,6 +107,10 @@ describe("shared live runtime profiles", () => { credentialStore: "gm-storage", ownsShortsDislikeControl: true, shortsControlModel: "synthetic-owned", + shortsControlModelBySurface: { + desktop: "synthetic-owned", + mobile: "native-youtube-required", + }, shortsVisualModel: "strict-synthetic", }, buildMarkerAttribute: "data-ryd-userscript-build", @@ -110,16 +125,20 @@ describe("shared live runtime profiles", () => { capabilities: { backgroundVoteTransport: true, credentialStore: "browser.storage", - ownsShortsDislikeControl: false, - shortsControlModel: "native-youtube", - shortsVisualModel: "native-pair", + ownsShortsDislikeControl: true, + shortsControlModel: "synthetic-owned", + shortsControlModelBySurface: { + desktop: "synthetic-owned", + mobile: "native-youtube-required", + }, + shortsVisualModel: "strict-synthetic", }, buildMarkerAttribute: "data-ryd-extension-build", markerAttribute: "data-ryd-extension-version", selectors: { rateBar: "#ryd-bar", rateBarContainer: "#ryd-bar-container", - shortsDislikeControl: null, + shortsDislikeControl: "[data-ryd-synthetic-shorts-dislike]", }, }); expect(Object.isFrozen(LIVE_RUNTIME_PROFILES.userscript.capabilities)).toBe(true); @@ -130,23 +149,41 @@ describe("shared live runtime profiles", () => { const driver = createDriver(); const adapter = createAdapter(runtime, driver); + expect(driver.configureRequestAttributionRuntime).toHaveBeenCalledWith(runtime); + await adapter.driver.assertRuntime(runtime, adapter.expectedVersion, adapter.expectedBuildId); await adapter.driver.assertCurrentShortsControl("abcdefghijk", runtime); + const expectedCounts = { dislikes: 123, likes: 456 }; + await adapter.driver.assertCurrentWatchResult("abcdefghijk", runtime, expectedCounts); + await adapter.driver.assertDislikeCountChangesObservable([{ after: 124, before: 123 }], runtime); + await adapter.driver.assertRenderedDislikeCount("123", 123, runtime); + await adapter.driver.soakCurrentShortsControl("abcdefghijk", runtime, 123, { durationMs: 10 }); await adapter.driver.captureWatchRatioVisual(runtime, "watch.png"); await adapter.driver.captureReactionStateVisual({ + expectedCounts, expectedState: "neutral", isShort: false, runtime, screenshotPath: "reaction.png", + shortsVisualModel: adapter.capabilities.shortsVisualModel, videoId: "abcdefghijk", }); expect(driver.assertRuntime).toHaveBeenCalledWith(runtime, adapter.expectedVersion, adapter.expectedBuildId); expect(driver.assertCurrentShortsControl).toHaveBeenCalledWith("abcdefghijk", runtime); + expect(driver.assertCurrentWatchResult).toHaveBeenCalledWith("abcdefghijk", runtime, expectedCounts); + expect(driver.assertDislikeCountChangesObservable).toHaveBeenCalledWith([{ after: 124, before: 123 }], runtime); + expect(driver.assertRenderedDislikeCount).toHaveBeenCalledWith("123", 123, runtime); + expect(driver.soakCurrentShortsControl).toHaveBeenCalledWith("abcdefghijk", runtime, 123, { durationMs: 10 }); expect(driver.captureWatchRatioVisual).toHaveBeenCalledWith(runtime, "watch.png"); expect(driver.captureReactionStateVisual).toHaveBeenCalledWith( - expect.objectContaining({ runtime, videoId: "abcdefghijk" }), + expect.objectContaining({ + runtime, + shortsVisualModel: adapter.capabilities.shortsVisualModel, + videoId: "abcdefghijk", + }), ); + expect(adapter.createScenarioOptions()).toMatchObject({ capabilities: adapter.capabilities }); }); test("rejects scenario options and calls for another runtime", async () => { @@ -158,6 +195,18 @@ describe("shared live runtime profiles", () => { expect(() => adapter.driver.assertRuntime("extension", adapter.expectedVersion)).toThrow( "The asserted runtime does not match", ); + expect(() => adapter.driver.soakCurrentShortsControl("abcdefghijk", "extension", 123)).toThrow( + "The Shorts-control soak runtime does not match", + ); + expect(() => + adapter.driver.captureReactionStateVisual({ + expectedState: "neutral", + isShort: true, + runtime: "userscript", + shortsVisualModel: "native-pair", + videoId: "abcdefghijk", + }), + ).toThrow("The reaction-visual Shorts model does not match"); }); test.each([undefined, "", "stale", "A".repeat(32)])( @@ -199,23 +248,26 @@ describe("shared live scenario contract", () => { expect(assertSharedLiveScenarioAdapter(adapter)).toEqual(EXPECTED_SCENARIO_IDS); }); - test("requires each runtime's Shorts visual model from the shared responsive scenario", () => { - const driver = createDriver(); - delete driver.captureSyntheticShortsVisual; + test.each(["channel-shorts-navigation", "responsive-visual"])( + "requires each runtime's Shorts visual model from shared scenario %s", + (scenarioId) => { + const driver = createDriver(); + delete driver.captureSyntheticShortsVisual; - expect(() => assertSharedLiveScenarioAdapter(createAdapter("userscript", driver), ["responsive-visual"])).toThrow( - "must implement captureSyntheticShortsVisual()", - ); - expect(assertSharedLiveScenarioAdapter(createAdapter("extension", driver), ["responsive-visual"])).toEqual([ - "responsive-visual", - ]); + expect(() => assertSharedLiveScenarioAdapter(createAdapter("userscript", driver), [scenarioId])).toThrow( + "must implement captureSyntheticShortsVisual()", + ); + expect(() => assertSharedLiveScenarioAdapter(createAdapter("extension", driver), [scenarioId])).toThrow( + "must implement captureSyntheticShortsVisual()", + ); - const extensionDriver = createDriver(); - delete extensionDriver.captureNativeShortsVisual; - expect(() => - assertSharedLiveScenarioAdapter(createAdapter("extension", extensionDriver), ["responsive-visual"]), - ).toThrow("must implement captureNativeShortsVisual()"); - }); + const driverWithoutNativeCapture = createDriver(); + delete driverWithoutNativeCapture.captureNativeShortsVisual; + expect( + assertSharedLiveScenarioAdapter(createAdapter("extension", driverWithoutNativeCapture), [scenarioId]), + ).toEqual([scenarioId]); + }, + ); test("executes the same scenario IDs through both runtime adapters", async () => { const events = []; @@ -224,8 +276,10 @@ describe("shared live scenario contract", () => { const services = { consumeVoteApproval: jest.fn(), createRecorder: jest.fn(), + shortsNavigationOptions: { outputDirectory: "shorts-evidence" }, visualOptions: { outputDirectory: "evidence" }, sidebarOptions: { outputDirectory: "sidebar-evidence" }, + topologyOptions: { outputDirectory: "topology-evidence" }, }; const userscriptResults = await runner.runAll(createAdapter("userscript"), {}, services); @@ -240,6 +294,62 @@ describe("shared live scenario contract", () => { expect(Object.values(implementations).every((implementation) => implementation.mock.calls.length === 2)).toBe(true); }); + test.each(["userscript", "extension"])( + "routes the shared Shorts navigation evidence options through %s", + async (runtime) => { + const implementation = jest.fn(async () => "verified"); + const runner = createSharedLiveScenarioRunner({ + implementations: { runChannelShortsNavigationScenario: implementation }, + }); + const adapter = createAdapter(runtime); + const shortsNavigationOptions = { outputDirectory: "shorts-evidence" }; + + await expect(runner.run(adapter, "channel-shorts-navigation", {}, { shortsNavigationOptions })).resolves.toBe( + "verified", + ); + expect(implementation).toHaveBeenCalledWith( + adapter.driver, + expect.objectContaining({ runtime }), + shortsNavigationOptions, + ); + }, + ); + + test.each(["userscript", "extension"])( + "routes the shared post-navigation vote services through %s", + async (runtime) => { + const implementation = jest.fn(async () => "verified"); + const runner = createSharedLiveScenarioRunner({ + implementations: { runPostNavigationVoteScenario: implementation }, + }); + const adapter = createAdapter(runtime); + const createRecorder = jest.fn(); + const consumeVoteApproval = jest.fn(); + + await expect( + runner.run(adapter, "post-navigation-vote", {}, { createRecorder, consumeVoteApproval }), + ).resolves.toBe("verified"); + expect(implementation).toHaveBeenCalledWith( + adapter.driver, + expect.objectContaining({ runtime }), + createRecorder, + consumeVoteApproval, + ); + }, + ); + + test("requires both protected services before running the post-navigation vote", async () => { + const implementation = jest.fn(); + const runner = createSharedLiveScenarioRunner({ + implementations: { runPostNavigationVoteScenario: implementation }, + }); + + await expect( + runner.run(createAdapter("extension"), "post-navigation-vote", {}, { createRecorder: jest.fn() }), + ).rejects.toThrow("requires services.consumeVoteApproval()"); + expect(implementation).not.toHaveBeenCalled(); + }); + test("rejects unknown IDs before invoking a scenario implementation", async () => { const implementations = createImplementations([]); const runner = createSharedLiveScenarioRunner({ implementations }); diff --git a/Extensions/e2e/shorts-placeholder-pool-contract.js b/Extensions/e2e/shorts-placeholder-pool-contract.js new file mode 100644 index 0000000..caea9bc --- /dev/null +++ b/Extensions/e2e/shorts-placeholder-pool-contract.js @@ -0,0 +1,1335 @@ +const assert = require("node:assert/strict"); +const { assertExactSuccessfulVotesTraffic } = require("./hermetic-api-contract"); + +const SHORTS_PLACEHOLDER_POOL_SIZE = 10; +const SHORTS_PLACEHOLDER_POOL_HOPS = 10; +const SHORTS_PLACEHOLDER_POOL_VIDEO_IDS = Object.freeze( + Array.from({ length: SHORTS_PLACEHOLDER_POOL_HOPS + 1 }, (_, index) => `PoolVid${String(index).padStart(4, "0")}`), +); +const SHORTS_PLACEHOLDER_POOL_COUNTS = Object.freeze( + Object.fromEntries( + SHORTS_PLACEHOLDER_POOL_VIDEO_IDS.map((videoId, index) => [ + videoId, + Object.freeze({ dislikes: 210 + index * 17, likes: 1_100 + index * 31 }), + ]), + ), +); +const SHORTS_PLACEHOLDER_POOL_ACTION_ORDER = Object.freeze(["like", "dislike", "comments", "share", "remix", "pivot"]); +const SHORTS_PLACEHOLDER_POOL_MARKER = "rydShortsPlaceholderPool=1"; +const SHORTS_SYNTHETIC_DISLIKE_SELECTOR = "[data-ryd-synthetic-shorts-dislike]"; +const SHORTS_DUPLICATE_NORMALIZATION_HOP = 3; +const SHORTS_NATIVE_CLEANUP_HOP = 5; +const SHORTS_EVENTLESS_RETURN_HOP = 7; +const SHORTS_NATIVE_LIKE_PAINT_HOP = 1; +const SHORTS_PIXEL_ORACLE_HOPS = new Set([0, 1, SHORTS_PLACEHOLDER_POOL_HOPS]); +const SHORTS_FRESH_ROUTE_EPOCH_MS = 250; +const SHORTS_TRANSITION_OBSERVATION_PHASES = Object.freeze([ + "route-current-active-offscreen", + "partial-active-ambiguous", + "visible-active-data-null-after-timeout", +]); + +function createShortsPlaceholderPoolFixture() { + const fixtureConfig = JSON.stringify({ + actionOrder: SHORTS_PLACEHOLDER_POOL_ACTION_ORDER, + counts: SHORTS_PLACEHOLDER_POOL_COUNTS, + freshRouteEpochMs: SHORTS_FRESH_ROUTE_EPOCH_MS, + hops: SHORTS_PLACEHOLDER_POOL_HOPS, + marker: SHORTS_PLACEHOLDER_POOL_MARKER, + nativeLikePaintHop: SHORTS_NATIVE_LIKE_PAINT_HOP, + poolSize: SHORTS_PLACEHOLDER_POOL_SIZE, + syntheticSelector: SHORTS_SYNTHETIC_DISLIKE_SELECTOR, + videoIds: SHORTS_PLACEHOLDER_POOL_VIDEO_IDS, + }); + + return ` + + + + Shorts pre-rendered placeholder pool fixture + + + + + + +
    +
    + + +`; +} + +async function installShortsPlaceholderPoolRoute(context, { onUnexpectedRequest = () => {} } = {}) { + if (!context?.route) throw new TypeError("A Playwright browser context is required."); + await context.route("https://www.youtube.com/**", async (route) => { + const request = route.request(); + const url = new URL(request.url()); + if ( + request.resourceType() === "document" && + url.pathname === `/shorts/${SHORTS_PLACEHOLDER_POOL_VIDEO_IDS[0]}` && + url.searchParams.get("rydShortsPlaceholderPool") === "1" + ) { + await route.fulfill({ + body: createShortsPlaceholderPoolFixture(), + contentType: "text/html; charset=utf-8", + status: 200, + }); + return; + } + + onUnexpectedRequest({ method: request.method(), resourceType: request.resourceType(), url: request.url() }); + await route.abort("blockedbyclient"); + }); +} + +function shortsPlaceholderPoolUrl() { + return `https://www.youtube.com/shorts/${SHORTS_PLACEHOLDER_POOL_VIDEO_IDS[0]}?${SHORTS_PLACEHOLDER_POOL_MARKER}`; +} + +async function readShortsPlaceholderPoolSurface(page, videoId, logicalIndex) { + return page.evaluate( + ({ actionOrder, expectedVideoId, index, syntheticSelector }) => { + const visibleInViewport = (element) => { + if (!element?.isConnected || element.closest("[hidden], [aria-hidden='true'], [inert]")) return false; + let opacity = 1; + for (let current = element; current; current = current.parentElement) { + const style = getComputedStyle(current); + if (style.display === "none" || ["hidden", "collapse"].includes(style.visibility)) { + return false; + } + const currentOpacity = Number.parseFloat(style.opacity || "1"); + if (Number.isFinite(currentOpacity)) opacity *= currentOpacity; + } + if (opacity <= 0.01) return false; + const box = element.getBoundingClientRect(); + return ( + box.width > 0 && + box.height > 0 && + box.bottom > 0 && + box.right > 0 && + box.top < innerHeight && + box.left < innerWidth + ); + }; + const box = (element) => { + if (!element) return null; + const bounds = element.getBoundingClientRect(); + return { + bottom: bounds.bottom, + height: bounds.height, + left: bounds.left, + right: bounds.right, + top: bounds.top, + width: bounds.width, + }; + }; + const topHitAtCenter = (element) => { + if (!element) return false; + const bounds = element.getBoundingClientRect(); + if (bounds.width <= 0 || bounds.height <= 0) return null; + const x = Math.min(innerWidth - 1, Math.max(0, bounds.left + bounds.width / 2)); + const y = Math.min(innerHeight - 1, Math.max(0, bounds.top + bounds.height / 2)); + return typeof document.elementFromPoint === "function" + ? document.elementFromPoint(x, y) + : document.elementsFromPoint?.(x, y)?.[0] ?? null; + }; + const hitTestedAtCenter = (element) => { + const hit = topHitAtCenter(element); + return hit === element || element.contains(hit); + }; + const describeElement = (element) => + element + ? `${element.tagName.toLowerCase()}${element.id ? `#${element.id}` : ""}${[...element.classList] + .map((name) => `.${name}`) + .join("")}` + : null; + const fixture = globalThis.__shortsPlaceholderPoolFixture?.snapshot(); + const renderer = document.querySelector( + `.reel-video-in-sequence-new[data-fixture-logical-index="${index}"] > ytd-reel-video-renderer`, + ); + const actionBar = renderer?.querySelector("reel-action-bar-view-model") ?? null; + const actionHosts = [...(actionBar?.children ?? [])]; + const actions = actionHosts.map((host) => + host.matches(syntheticSelector) ? "dislike" : host.getAttribute("data-fixture-action"), + ); + const buttons = actionHosts.map((host) => + host.querySelector("button, a[href], a[role='button'][tabindex='0'], tp-yt-paper-button#button"), + ); + const synthetic = actionBar?.querySelector(syntheticSelector) ?? null; + const nativeDislikes = actionBar?.querySelectorAll("dislike-button-view-model, #dislike-button") ?? []; + const dislike = actionHosts.find( + (host) => host.matches(syntheticSelector) || host.matches("dislike-button-view-model, #dislike-button"), + ); + const visibleDocumentSynthetic = [...document.querySelectorAll(syntheticSelector)].filter(visibleInViewport); + const visibleDocumentActionButtons = [ + ...document.querySelectorAll( + "reel-action-bar-view-model button, reel-action-bar-view-model a[role='button'][tabindex='0']", + ), + ].filter(visibleInViewport); + const renderers = [...document.querySelectorAll("ytd-reel-video-renderer")]; + const staleSyntheticOwners = [...document.querySelectorAll(syntheticSelector)] + .filter((control) => { + const owner = control.closest("ytd-reel-video-renderer")?.getAttribute("video-id"); + return control.getAttribute("data-ryd-video-id") !== owner; + }) + .map((control) => ({ + controlVideoId: control.getAttribute("data-ryd-video-id"), + rendererVideoId: control.closest("ytd-reel-video-renderer")?.getAttribute("video-id") ?? null, + })); + return { + actionOrder, + actions, + actionBarBox: box(actionBar), + actionBarDataReady: Boolean(actionBar?.data), + actionBarPainted: actionBar?.getAttribute("data-fixture-painted") ?? null, + actionBarVisible: visibleInViewport(actionBar), + activeRenderers: renderers.filter((candidate) => candidate.hasAttribute("is-active")).length, + buttonBoxes: buttons.map(box), + buttonEnabled: buttons.map( + (button) => Boolean(button) && !button.disabled && button.getAttribute("aria-disabled") !== "true", + ), + buttonHitTested: buttons.map(hitTestedAtCenter), + buttonTopHits: buttons.map((button) => describeElement(topHitAtCenter(button))), + buttonVisible: buttons.map(visibleInViewport), + dislikeCount: dislike?.querySelector("#text, [role='text']")?.textContent?.trim() ?? null, + dislikeKind: dislike?.matches(syntheticSelector) ? "synthetic" : dislike ? "native" : null, + dislikePressed: dislike?.querySelector("button")?.getAttribute("aria-pressed") ?? null, + fixture, + hostBoxes: actionHosts.map(box), + hostVisible: actionHosts.map(visibleInViewport), + pathname: location.pathname, + rendererBox: box(renderer), + rendererHydrated: renderer?.getAttribute("data-fixture-hydrated") ?? null, + rendererVideoId: renderer?.getAttribute("video-id") ?? null, + rendererVisible: visibleInViewport(renderer), + staleSyntheticOwners, + nativeDislikes: nativeDislikes.length, + syntheticCount: synthetic?.querySelector("#text, [role='text']")?.textContent?.trim() ?? null, + syntheticElements: actionBar?.querySelectorAll(syntheticSelector).length ?? 0, + syntheticOwner: synthetic?.getAttribute("data-ryd-video-id") ?? null, + syntheticPressed: synthetic?.querySelector("button")?.getAttribute("aria-pressed") ?? null, + visibleDocumentActionButtons: visibleDocumentActionButtons.length, + visibleDocumentRenderers: renderers.filter(visibleInViewport).length, + visibleDocumentSynthetic: visibleDocumentSynthetic.length, + videoId: expectedVideoId, + }; + }, + { + actionOrder: SHORTS_PLACEHOLDER_POOL_ACTION_ORDER, + expectedVideoId: videoId, + index: logicalIndex, + syntheticSelector: SHORTS_SYNTHETIC_DISLIKE_SELECTOR, + }, + ); +} + +function assertShortsPlaceholderPoolTransitionSafety(fixture, { logicalIndex, videoId }) { + assert.deepEqual( + fixture?.prematureSyntheticInsertions, + [], + `hop ${logicalIndex} inserted Dislike before the managed action bar reached hydration/readiness`, + ); + assert.deepEqual( + fixture?.corruptedVideoIds, + [], + `hop ${logicalIndex} lost its native action stack after premature placeholder mutation`, + ); + assert.deepEqual( + fixture?.freshEpochViolations, + [], + `hop ${logicalIndex} reused stale stability evidence after an eventless route round trip`, + ); + assertNativeLikePaintHydrationSafety(fixture, { logicalIndex, videoId }); + if (logicalIndex === 0) return; + const observations = fixture?.phaseObservations?.filter( + (observation) => + observation.videoId === videoId && SHORTS_TRANSITION_OBSERVATION_PHASES.includes(observation.phase), + ); + assert.deepEqual( + observations?.map((observation) => observation.phase), + SHORTS_TRANSITION_OBSERVATION_PHASES, + `hop ${logicalIndex} skipped a placeholder readiness phase`, + ); + const [offscreen, partial, visibleDataNull] = observations; + const previousVideoId = SHORTS_PLACEHOLDER_POOL_VIDEO_IDS[logicalIndex - 1]; + assert.deepEqual(offscreen.candidateVideoIds, [previousVideoId, videoId].sort()); + assert.equal(offscreen.actionBarDataReady, false); + assert.equal(offscreen.hydrated, false); + assert.equal(offscreen.inViewport, false); + assert.equal(offscreen.meaningfulViewport, false); + assert.equal(offscreen.syntheticCount, 0, `hop ${logicalIndex} performed a premature control mutation`); + assert.deepEqual(partial.candidateVideoIds, [previousVideoId, videoId].sort()); + assert.equal(partial.actionBarDataReady, false); + assert.equal(partial.hydrated, false); + assert.equal(partial.inViewport, true); + assert.equal(partial.meaningfulViewport, false); + assert.ok(partial.viewportRatio > 0 && partial.viewportRatio < 0.5); + assert.equal(partial.syntheticCount, 0, `hop ${logicalIndex} performed a premature control mutation`); + assert.deepEqual(visibleDataNull.candidateVideoIds, [videoId]); + assert.equal(visibleDataNull.actionBarDataReady, false); + assert.equal(visibleDataNull.hydrated, false); + assert.equal(visibleDataNull.inViewport, true); + assert.equal(visibleDataNull.meaningfulViewport, true); + assert.ok(visibleDataNull.viewportRatio >= 0.5); + assert.equal(visibleDataNull.syntheticCount, 0, `hop ${logicalIndex} performed a premature control mutation`); +} + +function assertNativeLikePaintHydrationSafety(fixture, { logicalIndex, videoId }) { + if (logicalIndex !== SHORTS_NATIVE_LIKE_PAINT_HOP) return; + + const observations = fixture?.nativeLikePaintObservations?.filter((observation) => observation.videoId === videoId); + assert.deepEqual( + observations, + [ + { + actionBarDataReady: true, + nativeLikePaintedGraphicCount: 0, + nativeLikeSvgPresent: false, + phase: "native-like-button-shell-only", + syntheticCount: 0, + videoId, + }, + { + actionBarDataReady: true, + nativeLikePaintedGraphicCount: 0, + nativeLikeSvgPresent: true, + phase: "native-like-empty-svg", + syntheticCount: 0, + videoId, + }, + { + actionBarDataReady: true, + nativeLikePaintedGraphicCount: 1, + nativeLikeSvgPresent: true, + phase: "native-like-painted-svg", + syntheticCount: 0, + videoId, + }, + ], + `hop ${logicalIndex} inserted Dislike before the exact native Like icon painted`, + ); +} + +function assertShortsPlaceholderPoolSurface(snapshot, { counts, dislikeKind = "synthetic", logicalIndex, videoId }) { + assert.equal(snapshot.pathname, `/shorts/${videoId}`, `hop ${logicalIndex} URL targets a stale Short`); + assert.equal(snapshot.fixture?.transitioning, false, `hop ${logicalIndex} fixture transition did not settle`); + assert.equal(snapshot.fixture?.currentIndex, logicalIndex, `hop ${logicalIndex} fixture index is stale`); + assert.equal(snapshot.fixture?.currentVideoId, videoId, `hop ${logicalIndex} fixture video is stale`); + assert.deepEqual(snapshot.fixture?.activeVideoIds, [videoId], `hop ${logicalIndex} has an ambiguous active renderer`); + assert.equal(snapshot.fixture?.slotCount, SHORTS_PLACEHOLDER_POOL_SIZE, "the physical placeholder pool changed size"); + assertShortsPlaceholderPoolTransitionSafety(snapshot.fixture, { logicalIndex, videoId }); + assert.equal(snapshot.activeRenderers, 1, `hop ${logicalIndex} must have one active renderer`); + assert.equal(snapshot.visibleDocumentRenderers, 1, `hop ${logicalIndex} must have one visible renderer`); + assert.equal(snapshot.rendererVideoId, videoId, `hop ${logicalIndex} selected a stale renderer`); + assert.equal(snapshot.rendererHydrated, "true", `hop ${logicalIndex} renderer is not hydrated`); + assert.equal(snapshot.rendererVisible, true, `hop ${logicalIndex} renderer is not visible`); + assert.equal(snapshot.actionBarDataReady, true, `hop ${logicalIndex} actionBar.data is not hydrated`); + assert.equal(snapshot.actionBarPainted, "true", `hop ${logicalIndex} action stack never reached its painted state`); + assert.equal(snapshot.actionBarVisible, true, `hop ${logicalIndex} action stack is not effectively visible`); + assert.deepEqual( + snapshot.actions, + SHORTS_PLACEHOLDER_POOL_ACTION_ORDER, + `hop ${logicalIndex} must retain every native action and exactly one synthetic Dislike`, + ); + assert.deepEqual( + snapshot.hostVisible, + SHORTS_PLACEHOLDER_POOL_ACTION_ORDER.map(() => true), + ); + assert.deepEqual( + snapshot.buttonVisible, + SHORTS_PLACEHOLDER_POOL_ACTION_ORDER.map(() => true), + ); + assert.deepEqual( + snapshot.buttonHitTested, + SHORTS_PLACEHOLDER_POOL_ACTION_ORDER.map(() => true), + `hop ${logicalIndex} controls have rectangles but are not topmost/hit-testable in the active renderer; top hits: ${JSON.stringify( + snapshot.buttonTopHits, + )}`, + ); + assert.deepEqual( + snapshot.buttonEnabled, + SHORTS_PLACEHOLDER_POOL_ACTION_ORDER.map(() => true), + ); + assert.equal(snapshot.visibleDocumentActionButtons, SHORTS_PLACEHOLDER_POOL_ACTION_ORDER.length); + assert.equal(snapshot.dislikeKind, dislikeKind, `hop ${logicalIndex} rendered the wrong Dislike implementation`); + assert.equal(snapshot.dislikePressed, "false", `hop ${logicalIndex} Dislike has an invalid initial state`); + assert.equal(snapshot.dislikeCount, String(counts.dislikes), `hop ${logicalIndex} rendered a stale dislike count`); + if (dislikeKind === "synthetic") { + assert.equal(snapshot.visibleDocumentSynthetic, 1, `hop ${logicalIndex} must show exactly one synthetic Dislike`); + assert.equal(snapshot.syntheticElements, 1, `hop ${logicalIndex} did not normalize duplicate synthetic controls`); + assert.equal(snapshot.nativeDislikes, 0, `hop ${logicalIndex} unexpectedly retained a native Dislike control`); + assert.equal(snapshot.syntheticOwner, videoId, `hop ${logicalIndex} Dislike targets a stale video`); + assert.equal(snapshot.syntheticPressed, "false", `hop ${logicalIndex} synthetic Dislike has an invalid state`); + assert.equal( + snapshot.syntheticCount, + String(counts.dislikes), + `hop ${logicalIndex} rendered a stale dislike count`, + ); + } else { + assert.equal(snapshot.visibleDocumentSynthetic, 0, `hop ${logicalIndex} retained a visible synthetic Dislike`); + assert.equal(snapshot.syntheticElements, 0, `hop ${logicalIndex} retained duplicate synthetic controls`); + assert.equal(snapshot.nativeDislikes, 1, `hop ${logicalIndex} did not preserve exactly one native Dislike`); + } + assert.deepEqual(snapshot.staleSyntheticOwners, [], "an offscreen or current synthetic control has stale ownership"); + assert.ok(snapshot.rendererBox?.width > 0 && snapshot.rendererBox?.height > 0, "the current renderer collapsed"); + assert.ok( + snapshot.actionBarBox?.left >= snapshot.rendererBox.left - 1 && + snapshot.actionBarBox?.right <= snapshot.rendererBox.right + 1 && + snapshot.actionBarBox?.top >= snapshot.rendererBox.top - 1 && + snapshot.actionBarBox?.bottom <= snapshot.rendererBox.bottom + 1, + `hop ${logicalIndex} action stack is outside the active rendered container`, + ); + + for (let index = 0; index < SHORTS_PLACEHOLDER_POOL_ACTION_ORDER.length; index += 1) { + const host = snapshot.hostBoxes[index]; + const button = snapshot.buttonBoxes[index]; + assert.ok(host?.width >= 47 && host?.height >= 69, `hop ${logicalIndex} ${snapshot.actions[index]} host collapsed`); + assert.ok( + button?.width >= 47 && button?.height >= 47, + `hop ${logicalIndex} ${snapshot.actions[index]} button collapsed`, + ); + assert.ok(button.left >= host.left - 1 && button.right <= host.right + 1, "an action button escaped its host"); + if (index > 0) { + const previous = snapshot.hostBoxes[index - 1]; + assert.ok(host.top >= previous.bottom - 1, `hop ${logicalIndex} action controls overlap`); + assert.ok(host.top <= previous.bottom + 1, `hop ${logicalIndex} action controls have an unexpected gap`); + } + } +} + +function interactionRequests(records) { + return records.filter( + (record) => record.method === "POST" && ["/interact/vote", "/interact/confirmVote"].includes(record.pathname), + ); +} + +async function waitForFixtureIndex(page, logicalIndex) { + await page.waitForFunction((index) => { + const snapshot = globalThis.__shortsPlaceholderPoolFixture?.snapshot(); + return snapshot?.currentIndex === index && snapshot.transitioning === false; + }, logicalIndex); +} + +async function waitForValidSurface(page, videoId, logicalIndex, { dislikeKind = "synthetic" } = {}) { + const counts = SHORTS_PLACEHOLDER_POOL_COUNTS[videoId]; + await page.waitForFunction( + ({ expectedCount, expectedDislikeKind, expectedOrder, expectedVideoId, index, syntheticSelector }) => { + const renderer = document.querySelector( + `.reel-video-in-sequence-new[data-fixture-logical-index="${index}"] > ytd-reel-video-renderer[is-active]`, + ); + const actionBar = renderer?.querySelector("reel-action-bar-view-model"); + const actions = [...(actionBar?.children ?? [])].map((host) => + host.matches(syntheticSelector) ? "dislike" : host.getAttribute("data-fixture-action"), + ); + const synthetic = actionBar?.querySelector(syntheticSelector); + const nativeDislike = actionBar?.querySelector("dislike-button-view-model, #dislike-button"); + const dislike = expectedDislikeKind === "synthetic" ? synthetic : nativeDislike; + return ( + location.pathname === `/shorts/${expectedVideoId}` && + renderer?.getAttribute("video-id") === expectedVideoId && + Boolean(actionBar?.data) && + JSON.stringify(actions) === JSON.stringify(expectedOrder) && + (expectedDislikeKind === "synthetic" + ? actionBar?.querySelectorAll(syntheticSelector).length === 1 && + !nativeDislike && + synthetic?.getAttribute("data-ryd-video-id") === expectedVideoId + : actionBar?.querySelectorAll(syntheticSelector).length === 0 && Boolean(nativeDislike)) && + dislike?.querySelector("#text, [role='text']")?.textContent?.trim() === expectedCount + ); + }, + { + expectedCount: String(counts.dislikes), + expectedDislikeKind: dislikeKind, + expectedOrder: SHORTS_PLACEHOLDER_POOL_ACTION_ORDER, + expectedVideoId: videoId, + index: logicalIndex, + syntheticSelector: SHORTS_SYNTHETIC_DISLIKE_SELECTOR, + }, + ); +} + +async function assertSurfaceSoak( + page, + videoId, + logicalIndex, + { dislikeKind = "synthetic", durationMs = 240, intervalMs = 40 } = {}, +) { + const deadline = Date.now() + durationMs; + let samples = 0; + do { + const snapshot = await readShortsPlaceholderPoolSurface(page, videoId, logicalIndex); + assertShortsPlaceholderPoolSurface(snapshot, { + counts: SHORTS_PLACEHOLDER_POOL_COUNTS[videoId], + dislikeKind, + logicalIndex, + videoId, + }); + samples += 1; + if (Date.now() >= deadline && samples >= 2) break; + await page.waitForTimeout(intervalMs); + } while (true); + return samples; +} + +async function assertActionBarPixelOracle(page, videoId, logicalIndex) { + const actionBar = page.locator( + `.reel-video-in-sequence-new[data-fixture-logical-index="${logicalIndex}"] > ` + + `ytd-reel-video-renderer[video-id="${videoId}"] reel-action-bar-view-model`, + ); + const screenshot = await actionBar.screenshot({ animations: "disabled", scale: "css" }); + // Playwright already ships this decoder; keeping the oracle on its pinned + // dependency avoids platform-specific golden screenshots and another package. + const { PNG } = require("playwright-core/lib/utilsBundle"); + const image = PNG.sync.read(screenshot); + assert.ok(image.width >= 47, `hop ${logicalIndex} pixel oracle captured a collapsed action stack`); + assert.ok(image.height >= 419, `hop ${logicalIndex} pixel oracle captured a truncated action stack`); + + const brightPixelsByAction = SHORTS_PLACEHOLDER_POOL_ACTION_ORDER.map((action, actionIndex) => { + const startY = Math.floor((actionIndex * image.height) / SHORTS_PLACEHOLDER_POOL_ACTION_ORDER.length); + const endY = Math.floor(((actionIndex + 1) * image.height) / SHORTS_PLACEHOLDER_POOL_ACTION_ORDER.length); + let brightPixels = 0; + for (let y = startY; y < endY; y += 1) { + for (let x = 0; x < image.width; x += 1) { + const offset = (y * image.width + x) * 4; + const red = image.data[offset]; + const green = image.data[offset + 1]; + const blue = image.data[offset + 2]; + const alpha = image.data[offset + 3]; + if (alpha >= 200 && red >= 160 && green >= 160 && blue >= 160) brightPixels += 1; + } + } + assert.ok(brightPixels >= 12, `hop ${logicalIndex} ${action} has geometry but no painted high-contrast pixels`); + return brightPixels; + }); + + return { brightPixelsByAction, height: image.height, logicalIndex, videoId, width: image.width }; +} + +function assertNativeDislikeCleanupSurface(snapshot, { logicalIndex, videoId }) { + assert.deepEqual( + snapshot.fixture?.seededNormalizationStates?.find( + (state) => state.phase === "native-and-multiple-synthetics" && state.videoId === videoId, + ), + { + nativeDislikes: 1, + phase: "native-and-multiple-synthetics", + syntheticCount: 3, + videoId, + }, + `hop ${logicalIndex} did not seed the native-plus-duplicate cleanup state`, + ); + assertShortsPlaceholderPoolSurface(snapshot, { + counts: SHORTS_PLACEHOLDER_POOL_COUNTS[videoId], + dislikeKind: "native", + logicalIndex, + videoId, + }); +} + +function assertDuplicateSyntheticNormalization(snapshot, { logicalIndex, videoId }) { + assert.deepEqual( + snapshot.fixture?.seededNormalizationStates?.find( + (state) => state.phase === "duplicate-synthetics" && state.videoId === videoId, + ), + { + nativeDislikes: 0, + phase: "duplicate-synthetics", + syntheticCount: 2, + videoId, + }, + `hop ${logicalIndex} did not seed the duplicate-only synthetic state`, + ); + assertShortsPlaceholderPoolSurface(snapshot, { + counts: SHORTS_PLACEHOLDER_POOL_COUNTS[videoId], + logicalIndex, + videoId, + }); +} + +function assertEventlessSameVideoReturnSafety(fixture, { logicalIndex, videoId }) { + assert.deepEqual( + fixture?.freshEpochViolations, + [], + `hop ${logicalIndex} reused stale pre-route stability evidence for ${videoId}`, + ); + const observations = fixture?.phaseObservations?.filter( + (observation) => + observation.videoId === videoId && + ["eventless-pending-before-route-away", "eventless-same-video-return-fresh-epoch"].includes(observation.phase), + ); + assert.deepEqual( + observations?.map( + ({ actionBarDataReady, candidateVideoIds, hydrated, meaningfulViewport, phase, syntheticCount }) => ({ + actionBarDataReady, + candidateVideoIds, + hydrated, + meaningfulViewport, + phase, + syntheticCount, + }), + ), + [ + { + actionBarDataReady: true, + candidateVideoIds: [videoId], + hydrated: true, + meaningfulViewport: true, + phase: "eventless-pending-before-route-away", + syntheticCount: 0, + }, + { + actionBarDataReady: true, + candidateVideoIds: [videoId], + hydrated: true, + meaningfulViewport: true, + phase: "eventless-same-video-return-fresh-epoch", + syntheticCount: 0, + }, + ], + `hop ${logicalIndex} inserted before a fresh same-video stability epoch elapsed`, + ); +} + +async function runShortsPlaceholderPoolContract({ page, readRequests, runtimeName }) { + if (!page || typeof readRequests !== "function") { + throw new TypeError("The shared Shorts placeholder-pool contract requires page and readRequests()."); + } + assert.ok(["extension", "userscript"].includes(runtimeName), `Unsupported runtime ${runtimeName}`); + + const documentIdentity = await page.evaluate(() => { + const marker = `${Date.now()}-${Math.random()}`; + globalThis.__shortsPlaceholderPoolDocumentIdentity = marker; + return marker; + }); + const results = []; + + for (let logicalIndex = 0; logicalIndex <= SHORTS_PLACEHOLDER_POOL_HOPS; logicalIndex += 1) { + const videoId = SHORTS_PLACEHOLDER_POOL_VIDEO_IDS[logicalIndex]; + if (logicalIndex > 0) { + await page.locator("#fixture-next-short").click(); + await waitForFixtureIndex(page, logicalIndex); + } + const transitionFixture = await page.evaluate(() => globalThis.__shortsPlaceholderPoolFixture?.snapshot()); + assertShortsPlaceholderPoolTransitionSafety(transitionFixture, { logicalIndex, videoId }); + await waitForValidSurface(page, videoId, logicalIndex); + let snapshot = await readShortsPlaceholderPoolSurface(page, videoId, logicalIndex); + assertShortsPlaceholderPoolSurface(snapshot, { + counts: SHORTS_PLACEHOLDER_POOL_COUNTS[videoId], + logicalIndex, + videoId, + }); + const pixelOracle = SHORTS_PIXEL_ORACLE_HOPS.has(logicalIndex) + ? await assertActionBarPixelOracle(page, videoId, logicalIndex) + : null; + let soakSamples = await assertSurfaceSoak(page, videoId, logicalIndex); + + if (logicalIndex === SHORTS_DUPLICATE_NORMALIZATION_HOP) { + await page.evaluate(() => globalThis.__shortsPlaceholderPoolFixture.seedSyntheticDuplicates()); + await waitForValidSurface(page, videoId, logicalIndex); + snapshot = await readShortsPlaceholderPoolSurface(page, videoId, logicalIndex); + assertDuplicateSyntheticNormalization(snapshot, { logicalIndex, videoId }); + } + + if (logicalIndex === SHORTS_NATIVE_CLEANUP_HOP) { + await page.evaluate(() => globalThis.__shortsPlaceholderPoolFixture.seedNativeDislikeAndSyntheticDuplicates()); + await waitForValidSurface(page, videoId, logicalIndex, { dislikeKind: "native" }); + const nativeSnapshot = await readShortsPlaceholderPoolSurface(page, videoId, logicalIndex); + assertNativeDislikeCleanupSurface(nativeSnapshot, { logicalIndex, videoId }); + soakSamples += await assertSurfaceSoak(page, videoId, logicalIndex, { + dislikeKind: "native", + durationMs: 160, + }); + await page.evaluate(() => globalThis.__shortsPlaceholderPoolFixture.removeNativeDislikeForSyntheticRecovery()); + await waitForValidSurface(page, videoId, logicalIndex); + snapshot = await readShortsPlaceholderPoolSurface(page, videoId, logicalIndex); + assertShortsPlaceholderPoolSurface(snapshot, { + counts: SHORTS_PLACEHOLDER_POOL_COUNTS[videoId], + logicalIndex, + videoId, + }); + } + + if (logicalIndex === SHORTS_EVENTLESS_RETURN_HOP) { + await page.evaluate(() => globalThis.__shortsPlaceholderPoolFixture.eventlessSameVideoReturn()); + const eventlessFixture = await page.evaluate(() => globalThis.__shortsPlaceholderPoolFixture.snapshot()); + assertEventlessSameVideoReturnSafety(eventlessFixture, { logicalIndex, videoId }); + await waitForValidSurface(page, videoId, logicalIndex); + snapshot = await readShortsPlaceholderPoolSurface(page, videoId, logicalIndex); + assertShortsPlaceholderPoolSurface(snapshot, { + counts: SHORTS_PLACEHOLDER_POOL_COUNTS[videoId], + logicalIndex, + videoId, + }); + } + + assert.equal( + await page.evaluate(() => globalThis.__shortsPlaceholderPoolDocumentIdentity), + documentIdentity, + `hop ${logicalIndex} replaced the document instead of using SPA navigation`, + ); + const requests = readRequests(); + assertExactSuccessfulVotesTraffic( + requests, + SHORTS_PLACEHOLDER_POOL_VIDEO_IDS.slice(0, logicalIndex + 1), + `Shorts placeholder-pool hop ${logicalIndex}`, + ); + assert.deepEqual( + interactionRequests(requests), + [], + `hop ${logicalIndex} submitted a vote without a reaction click`, + ); + results.push({ logicalIndex, pixelOracle, soakSamples, snapshot, videoId }); + } + + assert.equal(results.length - 1, SHORTS_PLACEHOLDER_POOL_HOPS); + assert.ok(SHORTS_PLACEHOLDER_POOL_HOPS >= 10, "the placeholder-pool contract must exercise at least ten Next clicks"); + assert.deepEqual( + results.at(-1).snapshot.fixture.logicalIndexes, + Array.from({ length: SHORTS_PLACEHOLDER_POOL_SIZE }, (_, index) => index + 1), + "the final hop did not cross the pre-rendered pool boundary into a recycled physical slot", + ); + return results; +} + +module.exports = { + SHORTS_DUPLICATE_NORMALIZATION_HOP, + SHORTS_EVENTLESS_RETURN_HOP, + SHORTS_FRESH_ROUTE_EPOCH_MS, + SHORTS_NATIVE_CLEANUP_HOP, + SHORTS_NATIVE_LIKE_PAINT_HOP, + SHORTS_PLACEHOLDER_POOL_ACTION_ORDER, + SHORTS_PLACEHOLDER_POOL_COUNTS, + SHORTS_PLACEHOLDER_POOL_HOPS, + SHORTS_PLACEHOLDER_POOL_MARKER, + SHORTS_PLACEHOLDER_POOL_SIZE, + SHORTS_PLACEHOLDER_POOL_VIDEO_IDS, + assertActionBarPixelOracle, + assertDuplicateSyntheticNormalization, + assertEventlessSameVideoReturnSafety, + assertNativeLikePaintHydrationSafety, + assertNativeDislikeCleanupSurface, + assertShortsPlaceholderPoolSurface, + assertShortsPlaceholderPoolTransitionSafety, + createShortsPlaceholderPoolFixture, + installShortsPlaceholderPoolRoute, + readShortsPlaceholderPoolSurface, + runShortsPlaceholderPoolContract, + shortsPlaceholderPoolUrl, +}; diff --git a/Extensions/e2e/shorts-placeholder-pool-contract.spec.js b/Extensions/e2e/shorts-placeholder-pool-contract.spec.js new file mode 100644 index 0000000..b2646e5 --- /dev/null +++ b/Extensions/e2e/shorts-placeholder-pool-contract.spec.js @@ -0,0 +1,340 @@ +const { + SHORTS_PLACEHOLDER_POOL_ACTION_ORDER, + SHORTS_PLACEHOLDER_POOL_COUNTS, + SHORTS_PLACEHOLDER_POOL_HOPS, + SHORTS_NATIVE_LIKE_PAINT_HOP, + SHORTS_PLACEHOLDER_POOL_SIZE, + SHORTS_PLACEHOLDER_POOL_VIDEO_IDS, + assertDuplicateSyntheticNormalization, + assertEventlessSameVideoReturnSafety, + assertNativeLikePaintHydrationSafety, + assertNativeDislikeCleanupSurface, + assertShortsPlaceholderPoolSurface, + createShortsPlaceholderPoolFixture, +} = require("./shorts-placeholder-pool-contract"); + +function validSurface(logicalIndex = 2) { + const videoId = SHORTS_PLACEHOLDER_POOL_VIDEO_IDS[logicalIndex]; + const previousVideoId = SHORTS_PLACEHOLDER_POOL_VIDEO_IDS[logicalIndex - 1]; + const hostBoxes = SHORTS_PLACEHOLDER_POOL_ACTION_ORDER.map((_, index) => ({ + bottom: 150 + index * 70, + height: 70, + left: 400, + right: 448, + top: 80 + index * 70, + width: 48, + })); + const buttonBoxes = hostBoxes.map((host) => ({ ...host, bottom: host.top + 48, height: 48 })); + return { + actionBarBox: { bottom: 500, height: 420, left: 400, right: 448, top: 80, width: 48 }, + actionBarDataReady: true, + actionBarPainted: "true", + actionBarVisible: true, + actions: [...SHORTS_PLACEHOLDER_POOL_ACTION_ORDER], + activeRenderers: 1, + buttonBoxes, + buttonEnabled: SHORTS_PLACEHOLDER_POOL_ACTION_ORDER.map(() => true), + buttonHitTested: SHORTS_PLACEHOLDER_POOL_ACTION_ORDER.map(() => true), + buttonTopHits: SHORTS_PLACEHOLDER_POOL_ACTION_ORDER.map(() => "button"), + buttonVisible: SHORTS_PLACEHOLDER_POOL_ACTION_ORDER.map(() => true), + dislikeCount: String(SHORTS_PLACEHOLDER_POOL_COUNTS[videoId].dislikes), + dislikeKind: "synthetic", + dislikePressed: "false", + fixture: { + activeVideoIds: [videoId], + corruptedVideoIds: [], + currentIndex: logicalIndex, + currentVideoId: videoId, + freshEpochViolations: [], + phaseObservations: [ + { + actionBarDataReady: false, + candidateVideoIds: [previousVideoId, videoId].sort(), + hydrated: false, + inViewport: false, + meaningfulViewport: false, + phase: "route-current-active-offscreen", + syntheticCount: 0, + videoId, + viewportRatio: 0, + }, + { + actionBarDataReady: false, + candidateVideoIds: [previousVideoId, videoId].sort(), + hydrated: false, + inViewport: true, + meaningfulViewport: false, + phase: "partial-active-ambiguous", + syntheticCount: 0, + videoId, + viewportRatio: 0.1, + }, + { + actionBarDataReady: false, + candidateVideoIds: [videoId], + hydrated: false, + inViewport: true, + meaningfulViewport: true, + phase: "visible-active-data-null-after-timeout", + syntheticCount: 0, + videoId, + viewportRatio: 1, + }, + ], + prematureSyntheticInsertions: [], + seededNormalizationStates: [], + slotCount: SHORTS_PLACEHOLDER_POOL_SIZE, + transitioning: false, + }, + hostBoxes, + hostVisible: SHORTS_PLACEHOLDER_POOL_ACTION_ORDER.map(() => true), + pathname: `/shorts/${videoId}`, + rendererBox: { bottom: 620, height: 620, left: 0, right: 460, top: 0, width: 460 }, + rendererHydrated: "true", + rendererVideoId: videoId, + rendererVisible: true, + staleSyntheticOwners: [], + nativeDislikes: 0, + syntheticCount: String(SHORTS_PLACEHOLDER_POOL_COUNTS[videoId].dislikes), + syntheticElements: 1, + syntheticOwner: videoId, + syntheticPressed: "false", + visibleDocumentActionButtons: SHORTS_PLACEHOLDER_POOL_ACTION_ORDER.length, + visibleDocumentRenderers: 1, + visibleDocumentSynthetic: 1, + }; +} + +function validate(snapshot, logicalIndex = 2) { + const videoId = SHORTS_PLACEHOLDER_POOL_VIDEO_IDS[logicalIndex]; + assertShortsPlaceholderPoolSurface(snapshot, { + counts: SHORTS_PLACEHOLDER_POOL_COUNTS[videoId], + logicalIndex, + videoId, + }); +} + +function validNativeCleanupSurface() { + const logicalIndex = 5; + const videoId = SHORTS_PLACEHOLDER_POOL_VIDEO_IDS[logicalIndex]; + const snapshot = validSurface(logicalIndex); + snapshot.dislikeKind = "native"; + snapshot.nativeDislikes = 1; + snapshot.syntheticCount = null; + snapshot.syntheticElements = 0; + snapshot.syntheticOwner = null; + snapshot.syntheticPressed = null; + snapshot.visibleDocumentSynthetic = 0; + snapshot.fixture.seededNormalizationStates.push({ + nativeDislikes: 1, + phase: "native-and-multiple-synthetics", + syntheticCount: 3, + videoId, + }); + return { logicalIndex, snapshot, videoId }; +} + +function validDuplicateNormalizationSurface() { + const logicalIndex = 3; + const videoId = SHORTS_PLACEHOLDER_POOL_VIDEO_IDS[logicalIndex]; + const snapshot = validSurface(logicalIndex); + snapshot.fixture.seededNormalizationStates.push({ + nativeDislikes: 0, + phase: "duplicate-synthetics", + syntheticCount: 2, + videoId, + }); + return { logicalIndex, snapshot, videoId }; +} + +function validEventlessFixture() { + const logicalIndex = 7; + const videoId = SHORTS_PLACEHOLDER_POOL_VIDEO_IDS[logicalIndex]; + const fixture = validSurface(logicalIndex).fixture; + for (const phase of ["eventless-pending-before-route-away", "eventless-same-video-return-fresh-epoch"]) { + fixture.phaseObservations.push({ + actionBarDataReady: true, + candidateVideoIds: [videoId], + hydrated: true, + inViewport: true, + meaningfulViewport: true, + phase, + syntheticCount: 0, + videoId, + viewportRatio: 1, + }); + } + return { fixture, logicalIndex, videoId }; +} + +function validNativeLikePaintObservations() { + const logicalIndex = SHORTS_NATIVE_LIKE_PAINT_HOP; + const videoId = SHORTS_PLACEHOLDER_POOL_VIDEO_IDS[logicalIndex]; + return { + logicalIndex, + observations: [ + { + actionBarDataReady: true, + nativeLikePaintedGraphicCount: 0, + nativeLikeSvgPresent: false, + phase: "native-like-button-shell-only", + syntheticCount: 0, + videoId, + }, + { + actionBarDataReady: true, + nativeLikePaintedGraphicCount: 0, + nativeLikeSvgPresent: true, + phase: "native-like-empty-svg", + syntheticCount: 0, + videoId, + }, + { + actionBarDataReady: true, + nativeLikePaintedGraphicCount: 1, + nativeLikeSvgPresent: true, + phase: "native-like-painted-svg", + syntheticCount: 0, + videoId, + }, + ], + videoId, + }; +} + +describe("shared Shorts placeholder-pool regression contract", () => { + test("crosses a ten-slot pre-render pool with at least ten Next transitions", () => { + expect(SHORTS_PLACEHOLDER_POOL_SIZE).toBe(10); + expect(SHORTS_PLACEHOLDER_POOL_HOPS).toBeGreaterThanOrEqual(10); + expect(SHORTS_PLACEHOLDER_POOL_VIDEO_IDS).toHaveLength(SHORTS_PLACEHOLDER_POOL_HOPS + 1); + expect(new Set(SHORTS_PLACEHOLDER_POOL_VIDEO_IDS).size).toBe(SHORTS_PLACEHOLDER_POOL_VIDEO_IDS.length); + expect(SHORTS_PLACEHOLDER_POOL_VIDEO_IDS.every((videoId) => /^[A-Za-z0-9_-]{11}$/.test(videoId))).toBe(true); + }); + + test("fixture models route-first activation, premature mutation, and physical-slot recycling", () => { + const fixture = createShortsPlaceholderPoolFixture(); + expect(fixture).toContain('timeline.push({ phase: "navigate-finish-before-activation"'); + expect(fixture).toContain('observePhase("partial-active-ambiguous"'); + expect(fixture).toContain('observePhase("visible-active-data-null-after-timeout"'); + expect(fixture).toContain("actionBar.data = null"); + expect(fixture).toContain("setTimeout(resolve, 520)"); + expect(fixture).toContain("actionBar.innerHTML = nativeActionsMarkup(nextVideoId)"); + expect(fixture).toContain('phase: "native-like-button-shell-only"'); + expect(fixture).toContain('phase: "native-like-empty-svg"'); + expect(fixture).toContain('phase: "native-like-painted-svg"'); + expect(fixture).toContain("seedNativeDislikeAndSyntheticDuplicates"); + expect(fixture).toContain("seedSyntheticDuplicates"); + expect(fixture).toContain("eventlessSameVideoReturn"); + expect(fixture).toContain("futureIndex = nextIndex + config.poolSize - 1"); + expect(fixture).toContain('actionBar.style.opacity = "0"'); + }); + + test("holds synthetic insertion through native Like shell-only and empty-SVG hydration", () => { + const { logicalIndex, observations, videoId } = validNativeLikePaintObservations(); + const snapshot = validSurface(logicalIndex); + snapshot.fixture.nativeLikePaintObservations = observations; + + expect(() => assertNativeLikePaintHydrationSafety(snapshot.fixture, { logicalIndex, videoId })).not.toThrow(); + expect(() => validate(snapshot, logicalIndex)).not.toThrow(); + + observations[1].syntheticCount = 1; + expect(() => assertNativeLikePaintHydrationSafety(snapshot.fixture, { logicalIndex, videoId })).toThrow( + /before the exact native Like icon painted/, + ); + }); + + test("fixture inline lifecycle script is syntactically executable", () => { + const fixture = createShortsPlaceholderPoolFixture(); + const inlineScript = fixture.match(/