mirror of
https://github.com/Anarios/return-youtube-dislike.git
synced 2026-09-12 10:22:10 +02:00
Fix Shorts support and add userscript voting
Harden Shorts controls, reaction state, and navigation; share voting logic with the extension; improve ratio-bar rendering; and add comprehensive Jest, Playwright, artifact, and live-browser regression coverage.
This commit is contained in:
@@ -1,2 +1,5 @@
|
||||
# Auto detect text files and perform LF normalization
|
||||
* text=auto
|
||||
|
||||
# Generated output must remain byte-for-byte stable across platforms.
|
||||
"Extensions/UserScript/Return Youtube Dislike.user.js" text eol=lf
|
||||
|
||||
@@ -1,25 +1,45 @@
|
||||
name: Test Build Extension
|
||||
name: Test Builds
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- Extensions/combined/**
|
||||
- "Extensions/combined/**"
|
||||
- "Extensions/common/**"
|
||||
- "Extensions/UserScript/**"
|
||||
- "Extensions/e2e/**"
|
||||
- ".github/workflows/build.yaml"
|
||||
- ".babelrc"
|
||||
- ".gitattributes"
|
||||
- ".nvmrc"
|
||||
- "jest.config.js"
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
- "playwright*.js"
|
||||
- "webpack*.js"
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- Extensions/combined/**
|
||||
- "Extensions/combined/**"
|
||||
- "Extensions/common/**"
|
||||
- "Extensions/UserScript/**"
|
||||
- "Extensions/e2e/**"
|
||||
- ".github/workflows/build.yaml"
|
||||
- ".babelrc"
|
||||
- ".gitattributes"
|
||||
- ".nvmrc"
|
||||
- "jest.config.js"
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
- "playwright*.js"
|
||||
- "webpack*.js"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./Extensions/combined
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
@@ -27,14 +47,33 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20.x'
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run Jest tests
|
||||
run: npm test -- --runInBand
|
||||
|
||||
- name: Build extension
|
||||
run: npm run build
|
||||
|
||||
- name: Build userscript
|
||||
run: npm run build:userscript
|
||||
|
||||
- name: Check generated userscript
|
||||
run: git diff --exit-code -- "Extensions/UserScript/Return Youtube Dislike.user.js"
|
||||
|
||||
- name: Install Chromium
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: Run generated-artifact browser tests
|
||||
run: npm run test:e2e:artifacts
|
||||
|
||||
- name: Run userscript browser tests
|
||||
run: npx playwright test --config playwright.userscript.config.js
|
||||
|
||||
- name: Upload Chrome artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
@@ -52,3 +91,14 @@ jobs:
|
||||
with:
|
||||
name: ryd-safari
|
||||
path: ./Extensions/combined/dist/safari
|
||||
|
||||
- name: Upload Playwright failure artifacts
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: userscript-playwright-failure
|
||||
path: |
|
||||
./test-results
|
||||
./playwright-report
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
|
||||
@@ -40,3 +40,7 @@ Extensions/combined/bundled-content-script.js
|
||||
# Dist Files
|
||||
Extensions/combined/dist/*
|
||||
Website/package-lock.json
|
||||
|
||||
# Playwright output
|
||||
/playwright-report/
|
||||
/test-results/
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,645 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Userscript navigation browser-test fixture</title>
|
||||
<style>
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 768px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
#fixture-account,
|
||||
#fixture-navigation,
|
||||
#fixture-page {
|
||||
display: block;
|
||||
}
|
||||
|
||||
#fixture-navigation a,
|
||||
#fixture-page > a {
|
||||
display: inline-block;
|
||||
margin: 4px;
|
||||
min-height: 24px;
|
||||
min-width: 96px;
|
||||
}
|
||||
|
||||
ytd-watch-flexy,
|
||||
ytd-menu-renderer,
|
||||
segmented-like-dislike-button-view-model,
|
||||
like-button-view-model,
|
||||
dislike-button-view-model,
|
||||
ytd-shorts,
|
||||
ytd-reel-video-renderer,
|
||||
ytm-reel-video-renderer,
|
||||
ytm-reel-player-overlay-renderer,
|
||||
reel-action-bar-view-model,
|
||||
ytm-like-button-renderer {
|
||||
display: block;
|
||||
}
|
||||
|
||||
#top-row,
|
||||
ytd-menu-renderer.ytd-watch-metadata > div {
|
||||
width: min(320px, 100%);
|
||||
}
|
||||
|
||||
segmented-like-dislike-button-view-model {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
min-height: 48px;
|
||||
width: 320px;
|
||||
}
|
||||
|
||||
segmented-like-dislike-button-view-model button {
|
||||
min-height: 36px;
|
||||
min-width: 96px;
|
||||
}
|
||||
|
||||
ytd-reel-video-renderer {
|
||||
display: block;
|
||||
min-height: 640px;
|
||||
width: 420px;
|
||||
}
|
||||
|
||||
ytd-reel-video-renderer[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
ytm-reel-video-renderer[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.slim-video-action-bar-actions,
|
||||
.segmented-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
ytm-like-button-renderer button {
|
||||
min-height: 36px;
|
||||
min-width: 96px;
|
||||
}
|
||||
|
||||
reel-action-bar-view-model {
|
||||
align-items: flex-start;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 320px;
|
||||
width: 48px;
|
||||
}
|
||||
|
||||
.ytwReelActionBarViewModelHostDesktopActionButton,
|
||||
reel-action-bar-view-model > button-view-model {
|
||||
box-sizing: content-box;
|
||||
display: block;
|
||||
height: 70px;
|
||||
margin: 0;
|
||||
padding: 0 0 8px;
|
||||
width: 48px;
|
||||
}
|
||||
|
||||
.ytSpecButtonShapeWithLabelHost {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 70px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 48px;
|
||||
}
|
||||
|
||||
reel-action-bar-view-model button {
|
||||
align-items: center;
|
||||
border: 0;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
height: 48px;
|
||||
justify-content: center;
|
||||
margin: 0;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
padding: 12px;
|
||||
width: 48px;
|
||||
}
|
||||
|
||||
.ytSpecButtonShapeNextIcon,
|
||||
.ytSpecButtonShapeNextIcon svg {
|
||||
display: block;
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
}
|
||||
|
||||
.ytSpecButtonShapeWithLabelLabel {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
font:
|
||||
12px/18px Arial,
|
||||
sans-serif;
|
||||
height: 22px;
|
||||
justify-content: center;
|
||||
width: 48px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="fixture-account"><button id="avatar-btn" aria-label="Account menu">Account</button></div>
|
||||
<nav id="fixture-navigation" aria-label="Fixture navigation"></nav>
|
||||
<main id="fixture-page"></main>
|
||||
|
||||
<script>
|
||||
(() => {
|
||||
const firstVideoId = "__VIDEO_ID__";
|
||||
const secondVideoId = "__SECOND_VIDEO_ID__";
|
||||
const marker = "rydNavigationFixture=1";
|
||||
const navigation = document.getElementById("fixture-navigation");
|
||||
const page = document.getElementById("fixture-page");
|
||||
const isMobile = location.hostname === "m.youtube.com";
|
||||
let pendingDelayedRender = null;
|
||||
|
||||
if (!customElements.get("like-button-view-model")) {
|
||||
customElements.define(
|
||||
"like-button-view-model",
|
||||
class extends HTMLElement {
|
||||
get className() {
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const hrefFor = (pageKind, videoId) => {
|
||||
if (pageKind === "shorts") return `/shorts/${videoId}?${marker}`;
|
||||
if (pageKind === "watch") return `/watch?v=${videoId}&${marker}`;
|
||||
return `/@FixtureChannel?${marker}`;
|
||||
};
|
||||
|
||||
const linkMarkup = (id, label, pageKind, videoId, options = {}) => `
|
||||
<a
|
||||
id="${id}"
|
||||
href="${hrefFor(pageKind, videoId)}"
|
||||
data-fixture-page-kind="${pageKind}"
|
||||
data-fixture-video-id="${videoId || ""}"
|
||||
data-fixture-control-delay="${options.controlDelay || 0}"
|
||||
>${label}</a>
|
||||
`;
|
||||
|
||||
function channelMarkup() {
|
||||
const decoyControls = isMobile
|
||||
? `
|
||||
<div class="slim-video-action-bar-actions" data-fixture-decoy-controls>
|
||||
<div class="segmented-buttons" data-ryd-role="buttons">
|
||||
<like-button-view-model class="style-text" data-ryd-role="like">
|
||||
<button type="button" aria-label="Channel preview likes" aria-pressed="false">
|
||||
<span id="text" role="text">100</span>
|
||||
</button>
|
||||
</like-button-view-model>
|
||||
<dislike-button-view-model class="style-text" data-ryd-role="dislike">
|
||||
<button type="button" aria-label="Channel preview dislike" aria-pressed="false">
|
||||
<span id="text" role="text"></span>
|
||||
</button>
|
||||
</dislike-button-view-model>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
: `
|
||||
<div id="menu-container" style="display: none"></div>
|
||||
<ytd-menu-renderer class="ytd-watch-metadata" data-fixture-decoy-controls>
|
||||
<div id="top-level-buttons-computed">
|
||||
<segmented-like-dislike-button-view-model data-ryd-role="buttons">
|
||||
<like-button-view-model class="style-text" data-ryd-role="like">
|
||||
<button type="button" aria-label="Channel preview likes" aria-pressed="false">
|
||||
<span id="text" role="text">100</span>
|
||||
</button>
|
||||
</like-button-view-model>
|
||||
<dislike-button-view-model class="style-text" data-ryd-role="dislike">
|
||||
<button type="button" aria-label="Channel preview dislike" aria-pressed="false">
|
||||
<span id="text" role="text"></span>
|
||||
</button>
|
||||
</dislike-button-view-model>
|
||||
</segmented-like-dislike-button-view-model>
|
||||
</div>
|
||||
</ytd-menu-renderer>
|
||||
`;
|
||||
return `
|
||||
<section data-fixture-page-kind="channel">
|
||||
<h1>Fixture channel</h1>
|
||||
${decoyControls}
|
||||
${linkMarkup("channel-short", "Open Short", "shorts", firstVideoId, { controlDelay: 600 })}
|
||||
${linkMarkup("channel-watch", "Open video", "watch", firstVideoId, { controlDelay: 200 })}
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
function watchMarkup(videoId) {
|
||||
const nextVideoId = videoId === firstVideoId ? secondVideoId : firstVideoId;
|
||||
return `
|
||||
<section data-fixture-page-kind="watch" data-fixture-video-id="${videoId}">
|
||||
<div id="menu-container" style="display: none"></div>
|
||||
<div id="player" loading="false"></div>
|
||||
<ytd-watch-flexy video-id="${videoId}"></ytd-watch-flexy>
|
||||
<div id="top-row">
|
||||
<ytd-menu-renderer class="ytd-watch-metadata">
|
||||
<div id="top-level-buttons-computed">
|
||||
<segmented-like-dislike-button-view-model
|
||||
data-ryd-role="buttons"
|
||||
data-fixture-control-video-id="${videoId}"
|
||||
>
|
||||
<like-button-view-model class="style-text" data-ryd-role="like">
|
||||
<button type="button" aria-label="100 likes" aria-pressed="false">
|
||||
<span id="text" role="text">100</span>
|
||||
</button>
|
||||
</like-button-view-model>
|
||||
<dislike-button-view-model class="style-text" data-ryd-role="dislike">
|
||||
<button type="button" aria-label="Dislike this video" aria-pressed="false">
|
||||
<span id="text" role="text"></span>
|
||||
</button>
|
||||
</dislike-button-view-model>
|
||||
</segmented-like-dislike-button-view-model>
|
||||
</div>
|
||||
</ytd-menu-renderer>
|
||||
</div>
|
||||
${linkMarkup("watch-to-short", "Open Short", "shorts", nextVideoId, { controlDelay: 200 })}
|
||||
${linkMarkup("watch-related", "Open related video", "watch", nextVideoId)}
|
||||
${linkMarkup("watch-next", "Play next video", "watch", nextVideoId)}
|
||||
<video id="fixture-media" data-fixture-autoplay-kind="watch" data-fixture-autoplay-id="${nextVideoId}"></video>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
function shortRendererMarkup(videoId, active) {
|
||||
return `
|
||||
<ytd-reel-video-renderer video-id="${videoId}" ${active ? "is-active" : "hidden"}>
|
||||
<a href="/shorts/${videoId}" aria-label="Short ${videoId}"></a>
|
||||
<reel-action-bar-view-model data-ryd-role="buttons">
|
||||
<like-button-view-model
|
||||
class="ytLikeButtonViewModelHost ytwReelActionBarViewModelHostDesktopActionButton"
|
||||
data-ryd-role="like"
|
||||
>
|
||||
<label class="ytSpecButtonShapeWithLabelHost">
|
||||
<button class="ytSpecButtonShapeNextHost" type="button" aria-label="100 likes" aria-pressed="false">
|
||||
<span class="ytSpecButtonShapeNextIcon" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24"><path d="M12 21"></path></svg>
|
||||
</span>
|
||||
</button>
|
||||
<div class="ytSpecButtonShapeWithLabelLabel"><span id="text" role="text">100</span></div>
|
||||
</label>
|
||||
</like-button-view-model>
|
||||
<button-view-model data-fixture-control="comments">
|
||||
<button type="button" aria-label="View comments"><span role="text">12</span></button>
|
||||
</button-view-model>
|
||||
<button-view-model data-fixture-control="share">
|
||||
<button type="button" aria-label="Share">Share</button>
|
||||
</button-view-model>
|
||||
<button-view-model data-fixture-control="remix">
|
||||
<button type="button" aria-label="Remix">Remix</button>
|
||||
</button-view-model>
|
||||
</reel-action-bar-view-model>
|
||||
</ytd-reel-video-renderer>
|
||||
`;
|
||||
}
|
||||
|
||||
function mobileShortRendererMarkup(videoId, active) {
|
||||
return `
|
||||
<ytm-reel-video-renderer
|
||||
data-fixture-mobile-short="${videoId}"
|
||||
video-id="${videoId}"
|
||||
${active ? "is-active" : "hidden"}
|
||||
>
|
||||
<a href="/shorts/${videoId}" aria-label="Short ${videoId}"></a>
|
||||
<ytm-reel-player-overlay-renderer>
|
||||
<div id="like-button">
|
||||
<ytm-like-button-renderer data-ryd-role="buttons">
|
||||
<like-button-view-model class="style-text" data-ryd-role="like">
|
||||
<button type="button" aria-label="100 likes" aria-pressed="false">
|
||||
<span id="text" class="button-renderer-text" role="text">100</span>
|
||||
</button>
|
||||
</like-button-view-model>
|
||||
<dislike-button-view-model class="style-text" data-ryd-role="dislike">
|
||||
<button type="button" aria-label="Dislike this video" aria-pressed="false">
|
||||
<span id="text" class="button-renderer-text" role="text"></span>
|
||||
</button>
|
||||
</dislike-button-view-model>
|
||||
</ytm-like-button-renderer>
|
||||
</div>
|
||||
</ytm-reel-player-overlay-renderer>
|
||||
</ytm-reel-video-renderer>
|
||||
`;
|
||||
}
|
||||
|
||||
function shortsMarkup(videoId) {
|
||||
const nextVideoId = videoId === firstVideoId ? secondVideoId : firstVideoId;
|
||||
return `
|
||||
<section data-fixture-page-kind="shorts" data-fixture-video-id="${videoId}">
|
||||
${
|
||||
isMobile
|
||||
? `<div id="fixture-shorts-reels">
|
||||
${mobileShortRendererMarkup(videoId, true)}
|
||||
${mobileShortRendererMarkup(nextVideoId, false)}
|
||||
</div>`
|
||||
: `<ytd-shorts>
|
||||
<div id="fixture-shorts-reels">
|
||||
${shortRendererMarkup(videoId, true)}
|
||||
${shortRendererMarkup(nextVideoId, false)}
|
||||
</div>
|
||||
</ytd-shorts>`
|
||||
}
|
||||
${linkMarkup("short-to-watch", "Open video", "watch", nextVideoId, { controlDelay: 200 })}
|
||||
${linkMarkup("short-next", "Play next Short", "shorts", nextVideoId)}
|
||||
<video id="fixture-media" data-fixture-autoplay-kind="shorts" data-fixture-autoplay-id="${nextVideoId}"></video>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
function pageMarkup(pageKind, videoId) {
|
||||
if (pageKind === "shorts") return shortsMarkup(videoId);
|
||||
if (pageKind === "watch") return watchMarkup(videoId);
|
||||
return channelMarkup();
|
||||
}
|
||||
|
||||
function dispatchNavigation() {
|
||||
document.dispatchEvent(new Event("yt-navigate-finish", { bubbles: true }));
|
||||
}
|
||||
|
||||
function render(pageKind, videoId) {
|
||||
page.innerHTML = pageMarkup(pageKind, videoId);
|
||||
navigation.innerHTML =
|
||||
pageKind === "channel"
|
||||
? ""
|
||||
: linkMarkup("return-to-channel", "Return to channel", "channel", "", { controlDelay: 20 });
|
||||
}
|
||||
|
||||
function navigate(pageKind, videoId, { controlDelay = 0, dispatchEvent = true } = {}) {
|
||||
history.pushState({}, "", hrefFor(pageKind, videoId));
|
||||
if (controlDelay > 0) {
|
||||
page.dataset.fixtureTransitionPending = pageKind;
|
||||
if (dispatchEvent) dispatchNavigation();
|
||||
setTimeout(() => {
|
||||
delete page.dataset.fixtureTransitionPending;
|
||||
render(pageKind, videoId);
|
||||
}, controlDelay);
|
||||
return;
|
||||
}
|
||||
render(pageKind, videoId);
|
||||
if (dispatchEvent) dispatchNavigation();
|
||||
}
|
||||
|
||||
function recycleShort(videoId) {
|
||||
const section = page.querySelector('[data-fixture-page-kind="shorts"]');
|
||||
const activeRenderer = section?.querySelector(
|
||||
isMobile ? "ytm-reel-video-renderer[is-active]" : "ytd-reel-video-renderer[is-active]",
|
||||
);
|
||||
const nextRenderer = section?.querySelector(
|
||||
isMobile
|
||||
? `ytm-reel-video-renderer[video-id="${videoId}"]`
|
||||
: `ytd-reel-video-renderer[video-id="${videoId}"]`,
|
||||
);
|
||||
if (!section || !activeRenderer || !nextRenderer) return;
|
||||
history.pushState({}, "", hrefFor("shorts", videoId));
|
||||
section.dataset.fixtureVideoId = videoId;
|
||||
activeRenderer.removeAttribute("is-active");
|
||||
activeRenderer.hidden = true;
|
||||
nextRenderer.hidden = false;
|
||||
nextRenderer.setAttribute("is-active", "");
|
||||
const nextLink = section.querySelector("#short-next");
|
||||
nextLink?.setAttribute("data-fixture-video-id", firstVideoId);
|
||||
nextLink?.setAttribute("href", hrefFor("shorts", firstVideoId));
|
||||
section.querySelector("#fixture-media")?.setAttribute("data-fixture-autoplay-id", firstVideoId);
|
||||
}
|
||||
|
||||
function churnInactiveDesktopShortIdentity(sequence) {
|
||||
const renderer = page.querySelector("ytd-reel-video-renderer:not([is-active])");
|
||||
if (!renderer) return;
|
||||
const churnVideoId = sequence % 2 === 0 ? secondVideoId : "CCCCCCCCCCC";
|
||||
renderer.setAttribute("video-id", churnVideoId);
|
||||
renderer.querySelector('a[href*="/shorts/"]')?.setAttribute("href", `/shorts/${churnVideoId}`);
|
||||
}
|
||||
|
||||
function queueDelayedRender(pageKind, videoId) {
|
||||
page.dataset.fixtureTransitionPending = pageKind;
|
||||
pendingDelayedRender = () => {
|
||||
delete page.dataset.fixtureTransitionPending;
|
||||
render(pageKind, videoId);
|
||||
};
|
||||
}
|
||||
|
||||
function finishDelayedNavigation() {
|
||||
if (!pendingDelayedRender) throw new Error("No delayed fixture navigation is pending");
|
||||
const finish = pendingDelayedRender;
|
||||
pendingDelayedRender = null;
|
||||
finish();
|
||||
}
|
||||
|
||||
function navigateDelayedShort(videoId) {
|
||||
const rendererTag = isMobile ? "ytm-reel-video-renderer" : "ytd-reel-video-renderer";
|
||||
const activeRenderer = page.querySelector(`${rendererTag}[is-active]`);
|
||||
for (const renderer of page.querySelectorAll(`${rendererTag}:not([is-active])`)) {
|
||||
renderer.remove();
|
||||
}
|
||||
if (!activeRenderer) return;
|
||||
activeRenderer.querySelector('a[href*="/shorts/"]')?.setAttribute("href", `/shorts/${videoId}`);
|
||||
history.pushState({}, "", hrefFor("shorts", videoId));
|
||||
queueDelayedRender("shorts", videoId);
|
||||
dispatchNavigation();
|
||||
}
|
||||
|
||||
function anonymizeActiveDesktopShort() {
|
||||
const activeRenderer = page.querySelector("ytd-reel-video-renderer[is-active]");
|
||||
if (!activeRenderer) return;
|
||||
activeRenderer.removeAttribute("video-id");
|
||||
activeRenderer.querySelector('a[href*="/shorts/"]')?.removeAttribute("href");
|
||||
}
|
||||
|
||||
function navigateDelayedAnonymousShort(videoId) {
|
||||
const activeRenderer = page.querySelector("ytd-reel-video-renderer[is-active]");
|
||||
for (const renderer of page.querySelectorAll("ytd-reel-video-renderer:not([is-active])")) {
|
||||
renderer.remove();
|
||||
}
|
||||
if (!activeRenderer) return;
|
||||
anonymizeActiveDesktopShort();
|
||||
history.pushState({}, "", hrefFor("shorts", videoId));
|
||||
queueDelayedRender("shorts", videoId);
|
||||
dispatchNavigation();
|
||||
}
|
||||
|
||||
function navigateDelayedWatch(videoId) {
|
||||
const watch = page.querySelector("ytd-watch-flexy");
|
||||
if (!watch) return;
|
||||
history.pushState({}, "", hrefFor("watch", videoId));
|
||||
watch.setAttribute("video-id", videoId);
|
||||
queueDelayedRender("watch", videoId);
|
||||
dispatchNavigation();
|
||||
}
|
||||
|
||||
function beginSameNodeWatchNavigation(videoId) {
|
||||
const watch = page.querySelector("ytd-watch-flexy");
|
||||
if (!watch) return;
|
||||
document.dispatchEvent(new Event("yt-navigate-start", { bubbles: true }));
|
||||
history.pushState({}, "", hrefFor("watch", videoId));
|
||||
watch.setAttribute("video-id", videoId);
|
||||
}
|
||||
|
||||
function finishSameNodeWatchNavigation() {
|
||||
dispatchNavigation();
|
||||
}
|
||||
|
||||
function mutateOutgoingWatchDescendant() {
|
||||
const dislike = page.querySelector('[data-fixture-page-kind="watch"] [data-ryd-role="dislike"]');
|
||||
if (!dislike) return;
|
||||
const countMarker = document.createElement("span");
|
||||
countMarker.hidden = true;
|
||||
countMarker.setAttribute("data-fixture-irrelevant-watch-count-mutation", "true");
|
||||
dislike.querySelector("#text")?.appendChild(countMarker);
|
||||
|
||||
const tooltipMarker = document.createElement("span");
|
||||
tooltipMarker.hidden = true;
|
||||
tooltipMarker.setAttribute("data-fixture-irrelevant-watch-tooltip-mutation", "true");
|
||||
page.querySelector(".ryd-tooltip")?.appendChild(tooltipMarker);
|
||||
}
|
||||
|
||||
function replaceDelayedWatchControl(role) {
|
||||
const control = page.querySelector(`[data-fixture-page-kind="watch"] [data-ryd-role="${role}"]`);
|
||||
if (!control) return;
|
||||
const replacement = control.cloneNode(true);
|
||||
replacement.setAttribute("data-fixture-watch-replacement", role);
|
||||
replacement.classList.remove("style-default-active");
|
||||
replacement.classList.add("style-text");
|
||||
replacement.querySelector("button")?.setAttribute("aria-pressed", "false");
|
||||
if (role === "dislike") {
|
||||
const count = replacement.querySelector("#text");
|
||||
if (count) count.textContent = "";
|
||||
}
|
||||
control.replaceWith(replacement);
|
||||
}
|
||||
|
||||
function replaceCurrentWatchActions({ retainOutgoing = false } = {}) {
|
||||
const section = page.querySelector('[data-fixture-page-kind="watch"]');
|
||||
const currentActions = section?.querySelector("#top-level-buttons-computed");
|
||||
const videoId = section?.dataset.fixtureVideoId;
|
||||
if (!section || !currentActions || !videoId) return false;
|
||||
|
||||
const replacement = currentActions.cloneNode(true);
|
||||
replacement.setAttribute("data-fixture-watch-actions-replacement", videoId);
|
||||
replacement.querySelectorAll(".ryd-tooltip").forEach((tooltip) => tooltip.remove());
|
||||
|
||||
const controls = replacement.querySelector('[data-ryd-role="buttons"]');
|
||||
controls?.setAttribute("data-fixture-control-video-id", videoId);
|
||||
for (const role of ["like", "dislike"]) {
|
||||
const control = controls?.querySelector(`[data-ryd-role="${role}"]`);
|
||||
control?.classList.remove("style-default-active");
|
||||
control?.classList.add("style-text");
|
||||
const button = control?.querySelector("button");
|
||||
button?.setAttribute("aria-pressed", "false");
|
||||
button?.removeAttribute("disabled");
|
||||
button?.setAttribute("aria-disabled", "false");
|
||||
}
|
||||
const dislikeCount = controls?.querySelector('[data-ryd-role="dislike"] #text');
|
||||
if (dislikeCount) dislikeCount.textContent = "";
|
||||
|
||||
currentActions.replaceWith(replacement);
|
||||
globalThis.__fixtureReplacedWatchActions = currentActions;
|
||||
if (retainOutgoing) {
|
||||
const retained = document.createElement("div");
|
||||
retained.hidden = true;
|
||||
retained.setAttribute("data-fixture-retained-settling-watch-actions", videoId);
|
||||
retained.appendChild(currentActions);
|
||||
document.body.appendChild(retained);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function refreshReusedWatchControl(role, videoId) {
|
||||
const control = page.querySelector(`[data-fixture-page-kind="watch"] [data-ryd-role="${role}"]`);
|
||||
if (!control) return;
|
||||
control.querySelector("button")?.setAttribute("aria-label", `${role} refreshed for ${videoId}`);
|
||||
}
|
||||
|
||||
function driftWatchControlBeforeNavigation(role, videoId) {
|
||||
const button = page.querySelector(`[data-fixture-page-kind="watch"] [data-ryd-role="${role}"] button`);
|
||||
if (!button) return;
|
||||
button.setAttribute("aria-label", `${role} drift while ${videoId}`);
|
||||
button.setAttribute("title", `${role} title drift while ${videoId}`);
|
||||
}
|
||||
|
||||
function replaceActiveMobileOverlay() {
|
||||
const activeRenderer = page.querySelector("ytm-reel-video-renderer[is-active]");
|
||||
const overlay = activeRenderer?.querySelector("ytm-reel-player-overlay-renderer");
|
||||
if (!overlay) return;
|
||||
const replacement = overlay.cloneNode(true);
|
||||
replacement.setAttribute("data-fixture-replacement", "true");
|
||||
const dislike = replacement.querySelector('[data-ryd-role="dislike"]');
|
||||
dislike?.classList.remove("style-default-active");
|
||||
dislike?.classList.add("style-text");
|
||||
dislike?.querySelector("button")?.setAttribute("aria-pressed", "false");
|
||||
const dislikeCount = dislike?.querySelector("#text");
|
||||
if (dislikeCount) dislikeCount.textContent = "";
|
||||
overlay.replaceWith(replacement);
|
||||
}
|
||||
|
||||
function navigateToShortsDescendant(videoId) {
|
||||
history.pushState({}, "", `/shorts/${videoId}/extra?${marker}`);
|
||||
dispatchNavigation();
|
||||
}
|
||||
|
||||
document.addEventListener("click", (event) => {
|
||||
const link = event.target.closest("a[data-fixture-page-kind]");
|
||||
if (!link) return;
|
||||
event.preventDefault();
|
||||
if (link.id === "short-next") {
|
||||
recycleShort(link.dataset.fixtureVideoId);
|
||||
dispatchNavigation();
|
||||
return;
|
||||
}
|
||||
navigate(link.dataset.fixturePageKind, link.dataset.fixtureVideoId, {
|
||||
controlDelay: Number(link.dataset.fixtureControlDelay || 0),
|
||||
});
|
||||
});
|
||||
|
||||
document.addEventListener(
|
||||
"ended",
|
||||
(event) => {
|
||||
const media = event.target.closest("#fixture-media");
|
||||
if (!media) return;
|
||||
if (media.dataset.fixtureAutoplayKind === "shorts") {
|
||||
recycleShort(media.dataset.fixtureAutoplayId);
|
||||
return;
|
||||
}
|
||||
navigate(media.dataset.fixtureAutoplayKind, media.dataset.fixtureAutoplayId, {
|
||||
dispatchEvent: false,
|
||||
});
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
window.__navigationFixture = {
|
||||
anonymizeActiveDesktopShort,
|
||||
beginSameNodeWatchNavigation,
|
||||
churnInactiveDesktopShortIdentity,
|
||||
dispatchEnded() {
|
||||
page.querySelector("#fixture-media")?.dispatchEvent(new Event("ended"));
|
||||
},
|
||||
finishDelayedNavigation,
|
||||
finishSameNodeWatchNavigation,
|
||||
driftWatchControlBeforeNavigation,
|
||||
navigate,
|
||||
navigateDelayedAnonymousShort,
|
||||
navigateDelayedShort,
|
||||
navigateDelayedWatch,
|
||||
navigateToShortsDescendant,
|
||||
mutateOutgoingWatchDescendant,
|
||||
recycleShort,
|
||||
replaceActiveMobileOverlay,
|
||||
replaceCurrentWatchActions,
|
||||
replaceDelayedWatchControl,
|
||||
refreshReusedWatchControl,
|
||||
};
|
||||
|
||||
render("__INITIAL_PAGE_KIND__", "__VIDEO_ID__");
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,348 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Userscript Shorts browser-test fixture</title>
|
||||
<style>
|
||||
html,
|
||||
body {
|
||||
width: 100%;
|
||||
min-height: 768px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#shorts-root,
|
||||
[data-short-video],
|
||||
ytd-reel-video-renderer,
|
||||
reel-action-bar-view-model,
|
||||
ytd-like-button-renderer,
|
||||
ytm-like-button-renderer,
|
||||
like-button-view-model,
|
||||
dislike-button-view-model {
|
||||
display: block;
|
||||
}
|
||||
|
||||
[data-short-video][hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
ytd-like-button-renderer,
|
||||
ytm-like-button-renderer {
|
||||
width: 240px;
|
||||
min-height: 96px;
|
||||
}
|
||||
|
||||
ytd-reel-video-renderer {
|
||||
display: block;
|
||||
width: 420px;
|
||||
min-height: 640px;
|
||||
}
|
||||
|
||||
reel-action-bar-view-model {
|
||||
align-items: flex-start;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 48px;
|
||||
min-height: 320px;
|
||||
}
|
||||
|
||||
.ytwReelActionBarViewModelHostDesktopActionButton,
|
||||
reel-action-bar-view-model > button-view-model {
|
||||
box-sizing: content-box;
|
||||
display: block;
|
||||
height: 70px;
|
||||
margin: 0;
|
||||
padding: 0 0 8px;
|
||||
width: 48px;
|
||||
}
|
||||
|
||||
.ytSpecButtonShapeWithLabelHost {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 70px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 48px;
|
||||
}
|
||||
|
||||
reel-action-bar-view-model button {
|
||||
align-items: center;
|
||||
border: 0;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
height: 48px;
|
||||
justify-content: center;
|
||||
margin: 0;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
padding: 12px;
|
||||
width: 48px;
|
||||
}
|
||||
|
||||
like-button-view-model .ytSpecButtonShapeNextIcon {
|
||||
display: flex;
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
}
|
||||
|
||||
like-button-view-model .ytSpecButtonShapeNextIcon svg {
|
||||
display: block;
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
}
|
||||
|
||||
.ytSpecButtonShapeWithLabelLabel {
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 12px;
|
||||
height: 22px;
|
||||
justify-content: center;
|
||||
line-height: 18px;
|
||||
width: 48px;
|
||||
}
|
||||
|
||||
ytm-like-button-renderer button,
|
||||
ytd-like-button-renderer button {
|
||||
min-height: 36px;
|
||||
min-width: 96px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="fixture-account"></div>
|
||||
<div id="shorts-root"></div>
|
||||
|
||||
<script>
|
||||
(() => {
|
||||
const root = document.getElementById("shorts-root");
|
||||
const isMobile = location.hostname === "m.youtube.com";
|
||||
|
||||
if (!customElements.get("like-button-view-model")) {
|
||||
customElements.define(
|
||||
"like-button-view-model",
|
||||
class extends HTMLElement {
|
||||
get className() {
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
document.getElementById("fixture-account").innerHTML = __SIGNED_IN__
|
||||
? '<button id="avatar-btn" aria-label="Account menu">Account</button>'
|
||||
: '<a id="sign-in" href="https://accounts.google.com/ServiceLogin">Sign in</a>';
|
||||
|
||||
function controlsMarkup(videoId) {
|
||||
if (!isMobile) {
|
||||
return `
|
||||
<section data-short-video="${videoId}" hidden>
|
||||
<ytd-reel-video-renderer video-id="${videoId}">
|
||||
<a href="/shorts/${videoId}" aria-label="Short ${videoId}"></a>
|
||||
<reel-action-bar-view-model data-ryd-role="buttons">
|
||||
<like-button-view-model
|
||||
class="ytLikeButtonViewModelHost ytwReelActionBarViewModelHostDesktopActionButton"
|
||||
data-ryd-role="like"
|
||||
>
|
||||
<label class="ytSpecButtonShapeWithLabelHost">
|
||||
<button class="ytSpecButtonShapeNextHost" type="button" aria-label="100 likes" aria-pressed="false">
|
||||
<span class="ytSpecButtonShapeNextIcon" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M12 21"></path></svg></span>
|
||||
</button>
|
||||
<div class="ytSpecButtonShapeWithLabelLabel">
|
||||
<span
|
||||
id="text"
|
||||
class="ytAttributedStringHost ytAttributedStringTextAlignmentCenter"
|
||||
role="text"
|
||||
>100</span>
|
||||
</div>
|
||||
</label>
|
||||
</like-button-view-model>
|
||||
<button-view-model data-fixture-control="comments">
|
||||
<button type="button" aria-label="View comments"><span role="text">12</span></button>
|
||||
</button-view-model>
|
||||
<button-view-model data-fixture-control="share">
|
||||
<button type="button" aria-label="Share"><span role="text">Share</span></button>
|
||||
</button-view-model>
|
||||
<button-view-model data-fixture-control="remix">
|
||||
<button type="button" aria-label="Remix"><span role="text">Remix</span></button>
|
||||
</button-view-model>
|
||||
</reel-action-bar-view-model>
|
||||
</ytd-reel-video-renderer>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
return `
|
||||
<section data-short-video="${videoId}" hidden>
|
||||
<div id="like-button">
|
||||
<__SHORTS_RENDERER_TAG__ data-ryd-role="buttons">
|
||||
<like-button-view-model class="style-text" data-ryd-role="like">
|
||||
<button type="button" aria-label="100 likes" aria-pressed="false">
|
||||
<span id="text" class="button-renderer-text" role="text">100</span>
|
||||
</button>
|
||||
</like-button-view-model>
|
||||
<dislike-button-view-model class="style-text" data-ryd-role="dislike">
|
||||
<button type="button" aria-label="Dislike this video" aria-pressed="false">
|
||||
<span id="text" class="button-renderer-text" role="text"></span>
|
||||
</button>
|
||||
</dislike-button-view-model>
|
||||
</__SHORTS_RENDERER_TAG__>
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
function applyNativeState(section, state) {
|
||||
const like = section.querySelector('[data-ryd-role="like"]');
|
||||
const dislike = section.querySelector('[data-ryd-role="dislike"]');
|
||||
if (isMobile) {
|
||||
like.classList.toggle("style-default-active", state === "liked");
|
||||
like.classList.toggle("style-text", state !== "liked");
|
||||
dislike?.classList.toggle("style-default-active", state === "disliked");
|
||||
dislike?.classList.toggle("style-text", state !== "disliked");
|
||||
} else {
|
||||
like.classList.toggle("style-default-active", state === "liked");
|
||||
dislike?.classList.toggle("style-default-active", state === "disliked");
|
||||
}
|
||||
like.querySelector("button").setAttribute("aria-pressed", String(state === "liked"));
|
||||
dislike?.querySelector("button")?.setAttribute("aria-pressed", String(state === "disliked"));
|
||||
section.dataset.nativeState = state;
|
||||
}
|
||||
|
||||
function ensureVideo(videoId) {
|
||||
let section = root.querySelector(`[data-short-video="${videoId}"]`);
|
||||
if (!section) {
|
||||
root.insertAdjacentHTML("beforeend", controlsMarkup(videoId));
|
||||
section = root.querySelector(`[data-short-video="${videoId}"]`);
|
||||
applyNativeState(section, "neutral");
|
||||
}
|
||||
return section;
|
||||
}
|
||||
|
||||
function activate(videoId, { dispatchNavigation = true, state = "neutral" } = {}) {
|
||||
const active = ensureVideo(videoId);
|
||||
for (const section of root.querySelectorAll("[data-short-video]")) {
|
||||
section.hidden = section !== active;
|
||||
}
|
||||
applyNativeState(active, state);
|
||||
history.pushState({}, "", `/shorts/${videoId}`);
|
||||
if (dispatchNavigation) {
|
||||
document.dispatchEvent(new Event("yt-navigate-finish", { bubbles: true }));
|
||||
}
|
||||
}
|
||||
|
||||
function activeSection() {
|
||||
return root.querySelector("[data-short-video]:not([hidden])");
|
||||
}
|
||||
|
||||
function removeSyntheticDislike() {
|
||||
activeSection()?.querySelector("[data-ryd-synthetic-shorts-dislike]")?.remove();
|
||||
}
|
||||
|
||||
function replaceActionBar() {
|
||||
const section = activeSection();
|
||||
const actionBar = section?.querySelector("reel-action-bar-view-model");
|
||||
if (!actionBar) return;
|
||||
const replacement = actionBar.cloneNode(true);
|
||||
replacement.querySelector("[data-ryd-synthetic-shorts-dislike]")?.remove();
|
||||
actionBar.replaceWith(replacement);
|
||||
applyNativeState(section, section.dataset.nativeState || "neutral");
|
||||
}
|
||||
|
||||
function installNativeDislike() {
|
||||
const section = activeSection();
|
||||
const actionBar = section?.querySelector("reel-action-bar-view-model");
|
||||
if (!actionBar || actionBar.querySelector("dislike-button-view-model")) return;
|
||||
|
||||
const nativeDislike = document.createElement("dislike-button-view-model");
|
||||
nativeDislike.className = "style-text";
|
||||
nativeDislike.setAttribute("data-ryd-role", "dislike");
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.setAttribute("aria-label", "Dislike this video");
|
||||
button.setAttribute("aria-pressed", "false");
|
||||
const text = document.createElement("span");
|
||||
text.id = "text";
|
||||
text.setAttribute("role", "text");
|
||||
button.appendChild(text);
|
||||
nativeDislike.appendChild(button);
|
||||
actionBar.insertBefore(nativeDislike, actionBar.children[1] ?? null);
|
||||
}
|
||||
|
||||
function removeNativeDislike() {
|
||||
activeSection()?.querySelector("dislike-button-view-model")?.remove();
|
||||
}
|
||||
|
||||
function replaceInnerButton(role) {
|
||||
const control = activeSection()?.querySelector(`[data-ryd-role="${role}"]`);
|
||||
const button = control?.querySelector("button");
|
||||
if (!button) return;
|
||||
const replacement = button.cloneNode(true);
|
||||
if (control.matches("[data-ryd-synthetic-shorts-dislike]")) {
|
||||
replacement.disabled = true;
|
||||
replacement.setAttribute("aria-disabled", "true");
|
||||
}
|
||||
button.replaceWith(replacement);
|
||||
}
|
||||
|
||||
function recycleActiveRenderer(videoId, { state = "neutral" } = {}) {
|
||||
const section = activeSection();
|
||||
const renderer = section?.querySelector("ytd-reel-video-renderer");
|
||||
if (!section || !renderer) return;
|
||||
section.setAttribute("data-short-video", videoId);
|
||||
renderer.setAttribute("video-id", videoId);
|
||||
const link = renderer.querySelector('a[href*="/shorts/"]');
|
||||
link?.setAttribute("href", `/shorts/${videoId}`);
|
||||
applyNativeState(section, state);
|
||||
history.pushState({}, "", `/shorts/${videoId}`);
|
||||
document.dispatchEvent(new Event("yt-navigate-finish", { bubbles: true }));
|
||||
}
|
||||
|
||||
function setClassOnlyLiked() {
|
||||
const section = activeSection();
|
||||
if (!section) return;
|
||||
applyNativeState(section, "liked");
|
||||
section.querySelector('[data-ryd-role="like"]').classList.add("style-default-active");
|
||||
section.querySelector('[data-ryd-role="like"] button').setAttribute("aria-pressed", "false");
|
||||
}
|
||||
|
||||
root.addEventListener(
|
||||
"click",
|
||||
(event) => {
|
||||
const actionButton = event.target.closest("button");
|
||||
if (!actionButton) return;
|
||||
const section = event.target.closest("[data-short-video]");
|
||||
if (!section) return;
|
||||
const current = section.dataset.nativeState || "neutral";
|
||||
if (actionButton.closest('[data-ryd-role="like"]')) {
|
||||
applyNativeState(section, current === "liked" ? "neutral" : "liked");
|
||||
}
|
||||
if (
|
||||
actionButton.closest('[data-ryd-role="dislike"]') &&
|
||||
!actionButton.closest("[data-ryd-synthetic-shorts-dislike]")
|
||||
) {
|
||||
applyNativeState(section, current === "disliked" ? "neutral" : "disliked");
|
||||
}
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
window.__shortsFixture = {
|
||||
activate,
|
||||
activeSection,
|
||||
ensureVideo,
|
||||
installNativeDislike,
|
||||
recycleActiveRenderer,
|
||||
replaceInnerButton,
|
||||
removeNativeDislike,
|
||||
removeSyntheticDislike,
|
||||
replaceActionBar,
|
||||
setClassOnlyLiked,
|
||||
};
|
||||
activate("__VIDEO_ID__", { dispatchNavigation: false, state: "__INITIAL_STATE__" });
|
||||
ensureVideo("__SECOND_VIDEO_ID__");
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,250 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Userscript browser-test fixture</title>
|
||||
<style>
|
||||
:root {
|
||||
--yt-spec-10-percent-layer: rgba(255, 255, 255, 0.1);
|
||||
--yt-spec-base-background: rgb(15, 15, 15);
|
||||
--yt-spec-icon-disabled: rgba(255, 255, 255, 0.3);
|
||||
--yt-spec-text-primary: rgb(241, 241, 241);
|
||||
--yt-spec-text-secondary: rgb(170, 170, 170);
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
background: rgb(15, 15, 15);
|
||||
width: 100%;
|
||||
min-height: 768px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
ytd-watch-flexy,
|
||||
ytd-menu-renderer,
|
||||
.slim-video-action-bar-actions,
|
||||
.segmented-buttons,
|
||||
segmented-like-dislike-button-view-model,
|
||||
like-button-view-model,
|
||||
dislike-button-view-model {
|
||||
display: block;
|
||||
}
|
||||
|
||||
ytd-menu-renderer.ytd-watch-metadata > div {
|
||||
display: block;
|
||||
width: min(320px, 100%);
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
#top-row {
|
||||
width: min(320px, 100%);
|
||||
}
|
||||
|
||||
segmented-like-dislike-button-view-model {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.segmented-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
width: 320px;
|
||||
min-height: 48px;
|
||||
}
|
||||
|
||||
[data-fixture-shell][hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[data-ryd-role="like"],
|
||||
[data-ryd-role="dislike"] {
|
||||
box-sizing: border-box;
|
||||
flex: 0 0 96px;
|
||||
height: 36px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 96px;
|
||||
}
|
||||
|
||||
[data-ryd-role="like"] button,
|
||||
[data-ryd-role="dislike"] button {
|
||||
align-items: center;
|
||||
background: rgb(39, 39, 39);
|
||||
border: 0;
|
||||
border-radius: 18px;
|
||||
box-sizing: border-box;
|
||||
color: rgb(241, 241, 241);
|
||||
display: inline-flex;
|
||||
font:
|
||||
500 14px/20px Arial,
|
||||
sans-serif;
|
||||
gap: 6px;
|
||||
height: 36px;
|
||||
justify-content: center;
|
||||
margin: 0;
|
||||
min-height: 36px;
|
||||
min-width: 96px;
|
||||
padding: 0 12px;
|
||||
width: 96px;
|
||||
}
|
||||
|
||||
[data-ryd-role].style-default-active button {
|
||||
background: rgb(241, 241, 241);
|
||||
color: rgb(15, 15, 15);
|
||||
}
|
||||
|
||||
[data-fixture-icon] {
|
||||
display: block;
|
||||
flex: 0 0 20px;
|
||||
height: 20px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 20px;
|
||||
}
|
||||
|
||||
[data-ryd-role] #text {
|
||||
display: block;
|
||||
font:
|
||||
500 14px/20px Arial,
|
||||
sans-serif;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- A hidden menu-container selects the desktop metadata layout used by the real page. -->
|
||||
<div id="menu-container" style="display: none"></div>
|
||||
<div id="fixture-account"></div>
|
||||
<div id="player" loading="false"></div>
|
||||
<ytd-watch-flexy id="fixture-watch" video-id="__VIDEO_ID__"></ytd-watch-flexy>
|
||||
<div id="top-row" data-fixture-shell="desktop">
|
||||
<ytd-menu-renderer class="ytd-watch-metadata">
|
||||
<div id="top-level-buttons-computed"></div>
|
||||
</ytd-menu-renderer>
|
||||
</div>
|
||||
<div class="slim-video-action-bar-actions" data-fixture-shell="mobile">
|
||||
<div id="fixture-mobile-buttons-host" class="segmented-buttons"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(() => {
|
||||
const account = document.getElementById("fixture-account");
|
||||
const watch = document.getElementById("fixture-watch");
|
||||
const isMobile = location.hostname === "m.youtube.com";
|
||||
const desktopHost = document.getElementById("top-level-buttons-computed");
|
||||
const mobileHost = document.getElementById("fixture-mobile-buttons-host");
|
||||
const host = isMobile ? mobileHost : desktopHost;
|
||||
|
||||
document.querySelector('[data-fixture-shell="desktop"]').hidden = isMobile;
|
||||
document.querySelector('[data-fixture-shell="mobile"]').hidden = !isMobile;
|
||||
|
||||
function setSignedIn(signedIn) {
|
||||
account.innerHTML = signedIn
|
||||
? '<button id="avatar-btn" aria-label="Account menu">Account</button>'
|
||||
: '<a id="sign-in" href="https://accounts.google.com/ServiceLogin">Sign in</a>';
|
||||
}
|
||||
|
||||
function desktopButtonMarkup() {
|
||||
return `
|
||||
<segmented-like-dislike-button-view-model data-ryd-role="buttons">
|
||||
<like-button-view-model class="style-text" data-ryd-role="like">
|
||||
<button type="button" aria-label="100 likes" aria-pressed="false">
|
||||
<svg data-fixture-icon="like" aria-hidden="true" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M8 21H5V9h3v12Zm2 0V9l4-7 2 1v5h5v5l-3 8h-8Z"></path>
|
||||
</svg>
|
||||
<span id="text" role="text">100</span>
|
||||
</button>
|
||||
</like-button-view-model>
|
||||
<dislike-button-view-model class="style-text" data-ryd-role="dislike">
|
||||
<button type="button" aria-label="Dislike this video" aria-pressed="false">
|
||||
<svg data-fixture-icon="dislike" aria-hidden="true" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M16 3h3v12h-3V3Zm-2 0v12l-4 7-2-1v-5H3v-5l3-8h8Z"></path>
|
||||
</svg>
|
||||
<span id="text" role="text"></span>
|
||||
</button>
|
||||
</dislike-button-view-model>
|
||||
</segmented-like-dislike-button-view-model>
|
||||
`;
|
||||
}
|
||||
|
||||
function mobileButtonMarkup() {
|
||||
return `
|
||||
<like-button-view-model class="style-text" data-ryd-role="like">
|
||||
<button type="button" aria-label="100 likes" aria-pressed="false">
|
||||
<svg data-fixture-icon="like" aria-hidden="true" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M8 21H5V9h3v12Zm2 0V9l4-7 2 1v5h5v5l-3 8h-8Z"></path>
|
||||
</svg>
|
||||
<span id="text" class="button-renderer-text" role="text">100</span>
|
||||
</button>
|
||||
</like-button-view-model>
|
||||
<dislike-button-view-model class="style-text" data-ryd-role="dislike">
|
||||
<button type="button" aria-label="Dislike this video" aria-pressed="false">
|
||||
<svg data-fixture-icon="dislike" aria-hidden="true" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M16 3h3v12h-3V3Zm-2 0v12l-4 7-2-1v-5H3v-5l3-8h8Z"></path>
|
||||
</svg>
|
||||
<span id="text" class="button-renderer-text" role="text"></span>
|
||||
</button>
|
||||
</dislike-button-view-model>
|
||||
`;
|
||||
}
|
||||
|
||||
function applyNativeState(state) {
|
||||
const like = host.querySelector('[data-ryd-role="like"]');
|
||||
const dislike = host.querySelector('[data-ryd-role="dislike"]');
|
||||
if (!like || !dislike) return;
|
||||
|
||||
like.classList.toggle("style-default-active", state === "liked");
|
||||
like.classList.toggle("style-text", state !== "liked");
|
||||
dislike.classList.toggle("style-default-active", state === "disliked");
|
||||
dislike.classList.toggle("style-text", state !== "disliked");
|
||||
like.querySelector("button").setAttribute("aria-pressed", String(state === "liked"));
|
||||
dislike.querySelector("button").setAttribute("aria-pressed", String(state === "disliked"));
|
||||
host.dataset.nativeState = state;
|
||||
}
|
||||
|
||||
function insertButtons(state = "neutral") {
|
||||
host.innerHTML = isMobile ? mobileButtonMarkup() : desktopButtonMarkup();
|
||||
applyNativeState(state);
|
||||
}
|
||||
|
||||
function removeButtons() {
|
||||
host.replaceChildren();
|
||||
host.dataset.nativeState = "neutral";
|
||||
}
|
||||
|
||||
function navigate(videoId, { state = "neutral", buttons = true } = {}) {
|
||||
history.pushState({}, "", `/watch?v=${videoId}`);
|
||||
watch.setAttribute("video-id", videoId);
|
||||
if (buttons) insertButtons(state);
|
||||
else removeButtons();
|
||||
document.dispatchEvent(new Event("yt-navigate-finish", { bubbles: true }));
|
||||
}
|
||||
|
||||
// Model the native YouTube toggle before userscript listeners observe the bubbled click.
|
||||
host.addEventListener(
|
||||
"click",
|
||||
(event) => {
|
||||
const like = event.target.closest('[data-ryd-role="like"]');
|
||||
const dislike = event.target.closest('[data-ryd-role="dislike"]');
|
||||
const current = host.dataset.nativeState || "neutral";
|
||||
if (like) applyNativeState(current === "liked" ? "neutral" : "liked");
|
||||
if (dislike) applyNativeState(current === "disliked" ? "neutral" : "disliked");
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
window.__youtubeFixture = {
|
||||
applyNativeState,
|
||||
insertButtons,
|
||||
navigate,
|
||||
removeButtons,
|
||||
setSignedIn,
|
||||
};
|
||||
|
||||
setSignedIn(__SIGNED_IN__);
|
||||
if (__INITIAL_BUTTONS__) insertButtons("__INITIAL_STATE__");
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,386 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const REPOSITORY_ROOT = path.resolve(__dirname, "../../..");
|
||||
const GENERATED_USERSCRIPT =
|
||||
process.env.RYD_USERSCRIPT_ARTIFACT ||
|
||||
path.join(REPOSITORY_ROOT, "Extensions", "UserScript", "Return Youtube Dislike.user.js");
|
||||
const WATCH_FIXTURE = fs.readFileSync(path.join(__dirname, "fixtures", "watch-page.html"), "utf8");
|
||||
const SHORTS_FIXTURE = fs.readFileSync(path.join(__dirname, "fixtures", "shorts-page.html"), "utf8");
|
||||
const NAVIGATION_FIXTURE = fs.readFileSync(path.join(__dirname, "fixtures", "navigation-page.html"), "utf8");
|
||||
|
||||
const API_ORIGIN = "https://returnyoutubedislikeapi.com";
|
||||
const CREDENTIAL_KEY = "rydVoteCredentials";
|
||||
const VIDEO_A = "abcdefghijk";
|
||||
const VIDEO_B = "zyxwvutsrqp";
|
||||
const ZERO_DIFFICULTY_PUZZLE = {
|
||||
challenge: Buffer.alloc(16).toString("base64"),
|
||||
difficulty: 0,
|
||||
};
|
||||
|
||||
const delay = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
|
||||
|
||||
function requestKey(method, pathname) {
|
||||
return `${method.toUpperCase()} ${pathname}`;
|
||||
}
|
||||
|
||||
function parseRequestBody(request) {
|
||||
const text = request.postData();
|
||||
if (!text) return null;
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
function jsonHeaders() {
|
||||
return {
|
||||
"access-control-allow-headers": "Accept, Content-Type",
|
||||
"access-control-allow-methods": "GET, POST, OPTIONS",
|
||||
"access-control-allow-origin": "*",
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
};
|
||||
}
|
||||
|
||||
function createFakeBackend({ countsByVideo = {}, countDelayByVideo = {}, fixture = {} } = {}) {
|
||||
const blockedRequests = [];
|
||||
const requests = [];
|
||||
const responsePlans = new Map();
|
||||
const fixtureOptions = {
|
||||
initialButtons: fixture.initialButtons !== false,
|
||||
initialState: fixture.initialState || "neutral",
|
||||
signedIn: fixture.signedIn !== false,
|
||||
};
|
||||
|
||||
function enqueue(method, pathname, response) {
|
||||
const key = requestKey(method, pathname);
|
||||
const queue = responsePlans.get(key) || [];
|
||||
queue.push(response);
|
||||
responsePlans.set(key, queue);
|
||||
}
|
||||
|
||||
function defer(method, pathname) {
|
||||
let releaseResponse;
|
||||
let resolveSeen;
|
||||
let released = false;
|
||||
const seen = new Promise((resolve) => {
|
||||
resolveSeen = resolve;
|
||||
});
|
||||
|
||||
enqueue(method, pathname, (record) => {
|
||||
resolveSeen(record);
|
||||
return new Promise((resolve) => {
|
||||
releaseResponse = resolve;
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
get released() {
|
||||
return released;
|
||||
},
|
||||
release(response) {
|
||||
if (released) return;
|
||||
if (!releaseResponse) {
|
||||
throw new Error(`Cannot release ${requestKey(method, pathname)} before its request is seen`);
|
||||
}
|
||||
released = true;
|
||||
releaseResponse(response);
|
||||
},
|
||||
seen,
|
||||
};
|
||||
}
|
||||
|
||||
function requestsFor(method, pathname) {
|
||||
return requests.filter((entry) => entry.method === method.toUpperCase() && entry.pathname === pathname);
|
||||
}
|
||||
|
||||
function takePlannedResponse(record) {
|
||||
const queue = responsePlans.get(requestKey(record.method, record.pathname));
|
||||
if (!queue?.length) return null;
|
||||
const planned = queue.shift();
|
||||
return typeof planned === "function" ? planned(record) : planned;
|
||||
}
|
||||
|
||||
function defaultApiResponse(record) {
|
||||
if (record.method === "GET" && record.pathname === "/configs/selectors") {
|
||||
return { body: {} };
|
||||
}
|
||||
|
||||
if (record.method === "GET" && record.pathname === "/votes") {
|
||||
const videoId = record.query.videoId;
|
||||
const counts = countsByVideo[videoId] || { dislikes: 25, likes: 100 };
|
||||
return {
|
||||
body: { ...counts, rating: 4.5 },
|
||||
delayMs: countDelayByVideo[videoId] || 0,
|
||||
};
|
||||
}
|
||||
|
||||
if (record.method === "GET" && record.pathname === "/puzzle/registration") {
|
||||
return { body: ZERO_DIFFICULTY_PUZZLE };
|
||||
}
|
||||
|
||||
if (record.method === "POST" && record.pathname === "/puzzle/registration") {
|
||||
return { body: true };
|
||||
}
|
||||
|
||||
if (record.method === "POST" && record.pathname === "/interact/vote") {
|
||||
return { body: ZERO_DIFFICULTY_PUZZLE };
|
||||
}
|
||||
|
||||
if (record.method === "POST" && record.pathname === "/interact/confirmVote") {
|
||||
return { body: true };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function handle(route) {
|
||||
const request = route.request();
|
||||
const url = new URL(request.url());
|
||||
|
||||
if (["www.youtube.com", "m.youtube.com"].includes(url.hostname) && request.resourceType() === "document") {
|
||||
const isShorts = url.pathname.startsWith("/shorts/");
|
||||
const videoId = isShorts ? url.pathname.slice(8) || VIDEO_A : url.searchParams.get("v") || VIDEO_A;
|
||||
const isNavigationFixture = url.searchParams.get("rydNavigationFixture") === "1";
|
||||
const fixtureTemplate = isNavigationFixture ? NAVIGATION_FIXTURE : isShorts ? SHORTS_FIXTURE : WATCH_FIXTURE;
|
||||
const html = fixtureTemplate
|
||||
.replaceAll("__VIDEO_ID__", videoId)
|
||||
.replaceAll("__SECOND_VIDEO_ID__", VIDEO_B)
|
||||
.replaceAll("__INITIAL_PAGE_KIND__", isShorts ? "shorts" : url.pathname === "/watch" ? "watch" : "channel")
|
||||
.replaceAll(
|
||||
"__SHORTS_RENDERER_TAG__",
|
||||
url.hostname === "m.youtube.com" ? "ytm-like-button-renderer" : "ytd-like-button-renderer",
|
||||
)
|
||||
.replaceAll("__SIGNED_IN__", String(fixtureOptions.signedIn))
|
||||
.replaceAll("__INITIAL_BUTTONS__", String(fixtureOptions.initialButtons))
|
||||
.replaceAll("__INITIAL_STATE__", fixtureOptions.initialState);
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "text/html; charset=utf-8",
|
||||
body: html,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.origin !== API_ORIGIN) {
|
||||
blockedRequests.push({
|
||||
method: request.method(),
|
||||
resourceType: request.resourceType(),
|
||||
url: request.url(),
|
||||
});
|
||||
await route.abort("blockedbyclient");
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method() === "OPTIONS") {
|
||||
await route.fulfill({ status: 204, headers: jsonHeaders(), body: "" });
|
||||
return;
|
||||
}
|
||||
|
||||
const record = {
|
||||
at: Date.now(),
|
||||
body: parseRequestBody(request),
|
||||
method: request.method(),
|
||||
pathname: url.pathname,
|
||||
query: Object.fromEntries(url.searchParams.entries()),
|
||||
url: url.toString(),
|
||||
};
|
||||
requests.push(record);
|
||||
|
||||
const plannedResponse = takePlannedResponse(record);
|
||||
const response = (plannedResponse ? await plannedResponse : plannedResponse) || defaultApiResponse(record);
|
||||
if (!response) {
|
||||
blockedRequests.push({
|
||||
method: request.method(),
|
||||
resourceType: request.resourceType(),
|
||||
url: request.url(),
|
||||
});
|
||||
await route.abort("blockedbyclient");
|
||||
return;
|
||||
}
|
||||
if (response.delayMs) await delay(response.delayMs);
|
||||
|
||||
const body = response.body === undefined ? null : response.body;
|
||||
await route.fulfill({
|
||||
status: response.status || 200,
|
||||
headers: { ...jsonHeaders(), ...(response.headers || {}) },
|
||||
body: typeof body === "string" ? body : JSON.stringify(body),
|
||||
});
|
||||
record.respondedAt = Date.now();
|
||||
}
|
||||
|
||||
return {
|
||||
blockedRequests,
|
||||
defer,
|
||||
enqueue,
|
||||
handle,
|
||||
requests,
|
||||
requestsFor,
|
||||
};
|
||||
}
|
||||
|
||||
async function installGmEnvironment(context, initialValues = {}) {
|
||||
await context.addInitScript(
|
||||
({ initialValues: seededValues, storagePrefix }) => {
|
||||
if (!location.hostname.endsWith("youtube.com")) return;
|
||||
|
||||
const storageKey = (key) => `${storagePrefix}${key}`;
|
||||
const read = (key, fallbackValue) => {
|
||||
const stored = localStorage.getItem(storageKey(key));
|
||||
if (stored === null) return fallbackValue;
|
||||
try {
|
||||
return JSON.parse(stored);
|
||||
} catch {
|
||||
return fallbackValue;
|
||||
}
|
||||
};
|
||||
const write = (key, value) => {
|
||||
if (value === undefined) localStorage.removeItem(storageKey(key));
|
||||
else localStorage.setItem(storageKey(key), JSON.stringify(value));
|
||||
};
|
||||
|
||||
for (const [key, value] of Object.entries(seededValues)) {
|
||||
if (localStorage.getItem(storageKey(key)) === null) write(key, value);
|
||||
}
|
||||
|
||||
globalThis.__gmCalls = [];
|
||||
const getValue = async (key, fallbackValue) => {
|
||||
globalThis.__gmCalls.push({ operation: "get", key });
|
||||
return read(key, fallbackValue);
|
||||
};
|
||||
const setValue = async (key, value) => {
|
||||
globalThis.__gmCalls.push({ operation: "set", key, value });
|
||||
write(key, value);
|
||||
};
|
||||
const deleteValue = async (key) => {
|
||||
globalThis.__gmCalls.push({ operation: "delete", key });
|
||||
localStorage.removeItem(storageKey(key));
|
||||
};
|
||||
const addStyle = (css) => {
|
||||
const style = document.createElement("style");
|
||||
style.dataset.rydGmStyle = "true";
|
||||
style.textContent = css;
|
||||
(document.head || document.documentElement).appendChild(style);
|
||||
return style;
|
||||
};
|
||||
|
||||
globalThis.GM = { getValue, setValue, deleteValue, addStyle };
|
||||
globalThis.GM_getValue = getValue;
|
||||
globalThis.GM_setValue = setValue;
|
||||
globalThis.GM_deleteValue = deleteValue;
|
||||
globalThis.GM_addStyle = addStyle;
|
||||
},
|
||||
{
|
||||
initialValues,
|
||||
storagePrefix: "ryd-e2e-gm:",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function installHermeticRoutes(context, backend) {
|
||||
await context.route("**/*", (route) => backend.handle(route));
|
||||
}
|
||||
|
||||
async function openWatchFixture(page, videoId = VIDEO_A, { hostname = "www.youtube.com" } = {}) {
|
||||
await page.goto(`https://${hostname}/watch?v=${videoId}`, { waitUntil: "domcontentloaded" });
|
||||
}
|
||||
|
||||
async function openShortsFixture(page, videoId = VIDEO_A, { hostname = "www.youtube.com" } = {}) {
|
||||
await page.goto(`https://${hostname}/shorts/${videoId}`, { waitUntil: "domcontentloaded" });
|
||||
}
|
||||
|
||||
async function openNavigationFixture(
|
||||
page,
|
||||
{ hostname = "www.youtube.com", pageKind = "channel", videoId = VIDEO_A } = {},
|
||||
) {
|
||||
const marker = "rydNavigationFixture=1";
|
||||
const path =
|
||||
pageKind === "shorts"
|
||||
? `/shorts/${videoId}?${marker}`
|
||||
: pageKind === "watch"
|
||||
? `/watch?v=${videoId}&${marker}`
|
||||
: `/@FixtureChannel?${marker}`;
|
||||
await page.goto(`https://${hostname}${path}`, { waitUntil: "domcontentloaded" });
|
||||
}
|
||||
|
||||
async function forbidUnsafeHtmlSinks(page) {
|
||||
await page.evaluate(() => {
|
||||
const innerHtmlDescriptor = Object.getOwnPropertyDescriptor(Element.prototype, "innerHTML");
|
||||
if (!innerHtmlDescriptor?.get || !innerHtmlDescriptor?.set) {
|
||||
throw new Error("Element.innerHTML descriptor is unavailable");
|
||||
}
|
||||
|
||||
globalThis.__rydUnsafeHtmlSinkCalls = [];
|
||||
const reject = (sink) => {
|
||||
globalThis.__rydUnsafeHtmlSinkCalls.push(sink);
|
||||
throw new TypeError(`${sink} is forbidden by the Trusted Types fixture`);
|
||||
};
|
||||
|
||||
Object.defineProperty(Element.prototype, "innerHTML", {
|
||||
configurable: innerHtmlDescriptor.configurable,
|
||||
enumerable: innerHtmlDescriptor.enumerable,
|
||||
get: innerHtmlDescriptor.get,
|
||||
set() {
|
||||
reject("Element.innerHTML");
|
||||
},
|
||||
});
|
||||
Element.prototype.insertAdjacentHTML = function () {
|
||||
reject("Element.insertAdjacentHTML");
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function overrideBooleanOption(source, optionName, value) {
|
||||
const optionPattern = new RegExp(`${optionName}:\\s*(?:true|false)`, "g");
|
||||
const matches = source.match(optionPattern) || [];
|
||||
if (matches.length !== 1) {
|
||||
throw new Error(`Expected one ${optionName} option in generated userscript, found ${matches.length}`);
|
||||
}
|
||||
return source.replace(optionPattern, `${optionName}: ${value}`);
|
||||
}
|
||||
|
||||
async function injectGeneratedUserscript(page, { coloredThumbs, disableVoteSubmission = false, rateBarEnabled } = {}) {
|
||||
if (!fs.existsSync(GENERATED_USERSCRIPT)) {
|
||||
throw new Error(`Generated userscript is missing: ${GENERATED_USERSCRIPT}`);
|
||||
}
|
||||
|
||||
if (!disableVoteSubmission && rateBarEnabled === undefined && coloredThumbs === undefined) {
|
||||
await page.addScriptTag({ path: GENERATED_USERSCRIPT });
|
||||
return;
|
||||
}
|
||||
|
||||
let source = fs.readFileSync(GENERATED_USERSCRIPT, "utf8");
|
||||
if (disableVoteSubmission) {
|
||||
source = overrideBooleanOption(source, "disableVoteSubmission", true);
|
||||
}
|
||||
if (rateBarEnabled !== undefined) {
|
||||
source = overrideBooleanOption(source, "rateBarEnabled", rateBarEnabled);
|
||||
}
|
||||
if (coloredThumbs !== undefined) {
|
||||
source = overrideBooleanOption(source, "coloredThumbs", coloredThumbs);
|
||||
}
|
||||
await page.addScriptTag({ content: source });
|
||||
}
|
||||
|
||||
async function readGmValue(page, key) {
|
||||
return page.evaluate((storageKey) => globalThis.GM.getValue(storageKey, null), key);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
API_ORIGIN,
|
||||
CREDENTIAL_KEY,
|
||||
GENERATED_USERSCRIPT,
|
||||
VIDEO_A,
|
||||
VIDEO_B,
|
||||
ZERO_DIFFICULTY_PUZZLE,
|
||||
createFakeBackend,
|
||||
forbidUnsafeHtmlSinks,
|
||||
injectGeneratedUserscript,
|
||||
installGmEnvironment,
|
||||
installHermeticRoutes,
|
||||
openNavigationFixture,
|
||||
openShortsFixture,
|
||||
openWatchFixture,
|
||||
readGmValue,
|
||||
};
|
||||
@@ -0,0 +1,168 @@
|
||||
# Live YouTube smoke
|
||||
|
||||
This suite attaches over CDP to an already-running Brave profile. It is deliberately separate from `test:all` and CI.
|
||||
The same page-level checks run in either userscript or extension mode; installation, storage, and protocol failure
|
||||
coverage remain in the hermetic suites.
|
||||
|
||||
## Browser preparation
|
||||
|
||||
1. Open `brave://inspect/#remote-debugging` in the target Brave profile and enable **Allow remote debugging for this
|
||||
browser instance**. Set `RYD_CDP_ENDPOINT` to the explicit HTTP or WebSocket endpoint exposed by that Brave instance;
|
||||
`http://127.0.0.1:9222` is a common HTTP example, but use the actual port shown by Brave.
|
||||
2. Use a dedicated two-item playlist containing two allowlisted public or unlisted test videos. Both must be accessible
|
||||
to the signed-in profile and must have different rendered dislike counts.
|
||||
3. Enable exactly one runtime:
|
||||
- `userscript`: disable the browser extension and every other Return YouTube Dislike userscript, run
|
||||
`npm run build:live:userscript`, import
|
||||
`test-results/live-build/userscript/Return Youtube Dislike.user.js` into Tampermonkey, and enable it.
|
||||
- `extension`: disable every Return YouTube Dislike userscript and any store-installed copy of the extension, run
|
||||
`npm run build:live:extension`, then enable or reload only the unpacked extension at
|
||||
`Extensions/combined/dist/chrome`.
|
||||
Every live build receives a fresh random build ID even when its semantic version is unchanged. Import or reload the
|
||||
runtime **after the final live build command**; rebuilding again invalidates the installed copy for this smoke.
|
||||
4. Keep the Brave window open and do not interact with the tab created by the suite. The suite creates, uses, and closes
|
||||
only its own tab.
|
||||
|
||||
Only the explicit live-test builds expose page markers containing their runtime, version, and exact build ID. Normal
|
||||
production builds do not expose them. The suite reads the expected ID from the generated `live-build.json`, verifies
|
||||
that the installed runtime exposes that exact ID and expected version, and verifies that the other runtime marker is
|
||||
absent before every scenario. A stale installed script or extension therefore fails even when it has the same version.
|
||||
There is no environment override for the build ID.
|
||||
|
||||
The read-only smoke still loads real YouTube and allows the installed runtime to read from the production RYD API,
|
||||
including eager registration when the selected runtime has no confirmed identity. It does not mock API responses, but
|
||||
it blocks production `POST /interact/*` requests before transmission as described below.
|
||||
|
||||
## Non-voting smoke
|
||||
|
||||
Set the following in PowerShell. Video IDs are the 11-character values from YouTube URLs, and the playlist URL must
|
||||
start on `RYD_LIVE_WATCH_A`.
|
||||
|
||||
```powershell
|
||||
$env:RYD_LIVE_YOUTUBE="1"
|
||||
$env:RYD_LIVE_PRODUCTION_API="1"
|
||||
$env:RYD_CDP_ENDPOINT="http://127.0.0.1:9222"
|
||||
$env:RYD_LIVE_RUNTIME="userscript"
|
||||
$env:RYD_LIVE_EXPECTED_CHANNEL="@your-test-channel"
|
||||
$env:RYD_LIVE_WATCH_A="AAAAAAAAAAA"
|
||||
$env:RYD_LIVE_WATCH_B="BBBBBBBBBBB"
|
||||
$env:RYD_LIVE_SHORT="CCCCCCCCCCC"
|
||||
$env:RYD_LIVE_PLAYLIST_URL="https://www.youtube.com/watch?v=AAAAAAAAAAA&list=PLAYLIST_ID"
|
||||
# Optional overrides for the cold channel-navigation smoke. These are the defaults:
|
||||
$env:RYD_LIVE_NAV_CHANNEL_URL="https://www.youtube.com/@SmashTrash"
|
||||
$env:RYD_LIVE_NAV_SHORT="iKQhN7omLM4"
|
||||
# Optional; the consecutive watch-sidebar stress defaults to three hops and accepts 1 through 10.
|
||||
$env:RYD_LIVE_SIDEBAR_HOPS="3"
|
||||
npm run test:live:youtube
|
||||
```
|
||||
|
||||
The navigation smoke performs a real hard load and reload of `RYD_LIVE_NAV_CHANNEL_URL`, finds an exact visible link to
|
||||
`RYD_LIVE_NAV_SHORT`, and clicks that link. It fails rather than falling back to direct navigation when the configured
|
||||
channel no longer contains the exact card. It proves the channel-to-Short transition reused the same document, verifies
|
||||
that the selected runtime initialized the current Short (including exactly one visible synthetic control in userscript
|
||||
mode), clicks YouTube's visible **Next video** control, proves that transition also reused the document, and verifies a
|
||||
new current video ID and initialized control. The channel-to-Short check runs first in the fresh suite tab so earlier
|
||||
watch or Shorts visits cannot warm the page lifecycle it is meant to exercise.
|
||||
|
||||
YouTube can occasionally focus the visible **Next video** button without acting on its first trusted click. The live
|
||||
driver waits five seconds for that first click, prints `LIVE_CHECKPOINT shorts-next-control.retrying`, and makes exactly
|
||||
one more trusted click with the remaining 25-second navigation budget. If the URL still does not advance, the scenario
|
||||
fails; it never loops or clicks a reaction control as part of this retry.
|
||||
|
||||
After the Shorts URL advances, the driver deliberately leaves playback running while YouTube hydrates the new reel,
|
||||
the production `/votes` response arrives, and the current dislike control renders. It pauses only after those checks,
|
||||
so a newly selected Short may play briefly before the `LIVE_CHECKPOINT playback.paused` message appears.
|
||||
|
||||
The watch-sidebar stress starts with the allowlisted `RYD_LIVE_WATCH_A`, then takes the first eligible visible
|
||||
`#related` watch link on each page for the configured number of consecutive SPA hops. Previously visited IDs are
|
||||
skipped. Every hop waits for the exact production `/votes?videoId=<target>` response and a rendered dislike count,
|
||||
requires exactly one visible ratio bar for the selected runtime with valid reaction-control geometry, and samples that
|
||||
same bar and count for four seconds to catch delayed YouTube pruning. It never clicks Like or Dislike. Deterministic
|
||||
evidence paths are overwritten on each run at
|
||||
`test-results/live-youtube/sidebar-stress/{runtime}-sidebar-hop-{01..N}.png`. The same scenario runs in userscript and
|
||||
extension mode.
|
||||
|
||||
Every non-voting live scenario installs a BrowserContext deny route before it starts and aborts every production
|
||||
`POST /interact/*` request. The accompanying request observer intentionally does not require a page frame, so attempts
|
||||
from an extension or service worker also fail the scenario. The guard applies to the entire attached browser context;
|
||||
do not use another tab in that Brave profile to react to a video while the smoke is running.
|
||||
|
||||
Automatic media-ended transitions for both watch pages and Shorts stay in the deterministic hermetic Playwright suite.
|
||||
This production smoke intentionally exercises the visible Shorts **Next video** control only: live autoplay timing,
|
||||
recommendation queues, ads, and account experiments are not stable enough to make an exact production transition a
|
||||
reliable gate. The existing two-item playlist smoke continues to cover an explicit watch-page SPA transition.
|
||||
|
||||
The default channel dataset can drift as its public page changes. Override both variables with a channel URL and Short
|
||||
that are deliberately kept together. The channel URL is restricted to a plain HTTPS `youtube.com/@handle` page or its
|
||||
`featured`, `shorts`, or `videos` tab; the target must be an 11-character video ID.
|
||||
|
||||
An additional cold channel-to-watch scenario is available only when its exact link is deterministic on the configured
|
||||
channel page. Opt in with an 11-character ID; otherwise that scenario is skipped:
|
||||
|
||||
```powershell
|
||||
$env:RYD_LIVE_NAV_WATCH="DDDDDDDDDDD"
|
||||
```
|
||||
|
||||
For the interactive Brave run, use `npm run test:live:youtube:interactive`. Before requesting any reaction approval,
|
||||
it runs the read-only scenarios plus responsive visual checks. Enter `SKIP` at the prompt to finish without reactions.
|
||||
The visual pass checks the watch-page ratio bar at widths 1280, 768, and 390. In userscript mode it also checks the
|
||||
modern Shorts synthetic dislike control, its geometry, and its rendered count at those widths. In extension mode it
|
||||
checks the active reel's native Like/Dislike pair at all three widths: exact action-host, button, and icon sizes;
|
||||
typography; spacing; common reel ownership; ordering; and viewport containment. Cropped evidence images are written
|
||||
under `test-results/live-youtube/responsive/`. If YouTube's native Like/Dislike pill is horizontally clipped by its own
|
||||
mobile page overflow, the ratio bar may share only that same native left/right footprint; any extra overflow introduced
|
||||
by the RYD bar still fails.
|
||||
|
||||
The interactive runner prints `LIVE_STAGE_START`, `LIVE_STAGE_COMPLETE`, and `LIVE_CHECKPOINT` records while it works.
|
||||
In particular, `LIVE_CHECKPOINT playback.paused` means the runner deliberately paused the current YouTube video while
|
||||
validating it; a stationary frame after that message is expected and is not evidence that Brave froze. Navigation,
|
||||
account, runtime, control, and production `/votes` waits have their own checkpoints, so the last line identifies the
|
||||
pending operation.
|
||||
|
||||
If a scenario fails, the runner records page errors, unhandled promise rejections, console errors, and the latest RYD
|
||||
API request outcomes before closing its test tab. It writes a JSON snapshot under
|
||||
`test-results/live-youtube/diagnostics/` and prints its absolute path as `LIVE_FAILURE_SNAPSHOT`. The snapshot includes
|
||||
the URL, runtime markers, Shorts renderer IDs and links, action-bar and synthetic-control ownership, video paused state,
|
||||
and recent API paths/statuses. Anonymous identity and proof-related query values are redacted. Preserve this file when
|
||||
reporting a live-only failure; ordinary direct interactive runs otherwise have no Playwright trace.
|
||||
|
||||
Use `RYD_LIVE_RUNTIME="extension"` after manually switching the enabled runtime to execute the same smoke against the
|
||||
extension. The expected version defaults to the local userscript candidate version or root package version; set
|
||||
`RYD_LIVE_EXPECTED_VERSION` only when deliberately validating another installed build.
|
||||
|
||||
## Optional production reaction matrix
|
||||
|
||||
The reaction test is skipped by default. It covers all six Like/Dislike state transitions on `RYD_LIVE_WATCH_B` and the
|
||||
allowlisted Short, asserts one logical production handshake for every transition, and returns each video to its initial
|
||||
reaction state. A handshake contains one to three matching `/interact/vote` puzzle requests followed by exactly one
|
||||
matching successful `/interact/confirmVote`. Any fourth vote request, changed identity/video/value, extra confirmation,
|
||||
or other interaction traffic fails the run. It does not retry cleanup blindly if the state cannot be verified.
|
||||
If click dispatch, the post-click state wait, or a handshake fails after a vote attempt while the UI already appears to
|
||||
be back at its initial state, cleanup does not trust the UI alone: it confirms an away-and-back reaction round trip with
|
||||
the same anonymous identity. A failed cleanup confirmation or identity mismatch reports the exact video URL for manual
|
||||
restoration.
|
||||
|
||||
The matrix also captures the initial state and every post-transition state, for seven watch images and seven Shorts
|
||||
images. Before each screenshot it reads both `aria-pressed` values, verifies the exact expected mutually-exclusive
|
||||
state, and requires a numeric dislike count. Watch captures additionally require a visible, non-overlapping ratio bar
|
||||
with sane geometry. Userscript Shorts captures reuse the strict native-vs-synthetic control geometry checks, including
|
||||
the 48x78 action host, 24x24 icon, typography, spacing, active-reel ownership, and duplicate-control checks. Evidence is
|
||||
written with deterministic names under
|
||||
`test-results/live-youtube/reactions/{runtime}/{watch|short}-{0..6}-{neutral|liked|disliked}.png`. A failed visual check
|
||||
still enters the same verified restoration path before the scenario reports the failure.
|
||||
|
||||
The exact live-build marker proves that the freshly generated test build is present and that the opposite runtime is
|
||||
absent. It cannot detect an additional normal build of the same runtime, so the manual single-runtime preparation above
|
||||
is mandatory.
|
||||
|
||||
Only after approving those real YouTube and production RYD writes, create a runtime-and-video-specific token immediately
|
||||
before the run. It expires after two minutes and is consumed once locally, so the userscript token cannot silently
|
||||
authorize a later extension run.
|
||||
|
||||
```powershell
|
||||
$env:RYD_LIVE_VOTES="$env:RYD_LIVE_RUNTIME`:$env:RYD_LIVE_WATCH_B`:$([DateTimeOffset]::UtcNow.ToUnixTimeSeconds())"
|
||||
npm run test:live:youtube
|
||||
Remove-Item Env:RYD_LIVE_VOTES
|
||||
```
|
||||
|
||||
After the live run, turn off **Allow remote debugging for this browser instance** or restart the test Brave profile.
|
||||
@@ -0,0 +1,358 @@
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const API_ORIGIN = "https://returnyoutubedislikeapi.com";
|
||||
const UNHANDLED_REJECTION_PREFIX = "__RYD_LIVE_UNHANDLED_REJECTION__";
|
||||
const MAX_BROWSER_SIGNALS = 50;
|
||||
const MAX_API_REQUESTS = 50;
|
||||
|
||||
function serializeError(error) {
|
||||
if (!error) return null;
|
||||
if (typeof error === "string") return { message: error, name: "Error", stack: null };
|
||||
return {
|
||||
message: String(error.message ?? error),
|
||||
name: String(error.name ?? "Error"),
|
||||
stack: typeof error.stack === "string" ? error.stack : null,
|
||||
};
|
||||
}
|
||||
|
||||
function installUnhandledRejectionListener(prefix) {
|
||||
if (globalThis.__rydLiveUnhandledRejectionListenerInstalled) return;
|
||||
globalThis.__rydLiveUnhandledRejectionListenerInstalled = true;
|
||||
globalThis.addEventListener("unhandledrejection", (event) => {
|
||||
const reason = event.reason;
|
||||
let message;
|
||||
try {
|
||||
if (reason instanceof Error)
|
||||
message = `${reason.name}: ${reason.message}${reason.stack ? `\n${reason.stack}` : ""}`;
|
||||
else if (typeof reason === "string") message = reason;
|
||||
else message = JSON.stringify(reason);
|
||||
} catch {
|
||||
message = String(reason);
|
||||
}
|
||||
console.error(`${prefix}${message ?? "Unknown rejection"}`);
|
||||
});
|
||||
}
|
||||
|
||||
function readLivePageState() {
|
||||
const readRect = (element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return {
|
||||
bottom: rect.bottom,
|
||||
height: rect.height,
|
||||
left: rect.left,
|
||||
right: rect.right,
|
||||
top: rect.top,
|
||||
width: rect.width,
|
||||
};
|
||||
};
|
||||
const isVisible = (element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const style = getComputedStyle(element);
|
||||
return (
|
||||
rect.width > 0 &&
|
||||
rect.height > 0 &&
|
||||
rect.bottom > 0 &&
|
||||
rect.right > 0 &&
|
||||
rect.top < innerHeight &&
|
||||
rect.left < innerWidth &&
|
||||
style.display !== "none" &&
|
||||
style.visibility !== "hidden"
|
||||
);
|
||||
};
|
||||
const actionBarSelector = "reel-action-bar-view-model, .slim-video-action-bar-actions";
|
||||
const syntheticSelector = "[data-ryd-synthetic-shorts-dislike]";
|
||||
const renderers = [...document.querySelectorAll("ytd-reel-video-renderer, ytm-reel-video-renderer")];
|
||||
|
||||
return {
|
||||
document: {
|
||||
readyState: document.readyState,
|
||||
title: document.title,
|
||||
url: location.href,
|
||||
viewport: { height: innerHeight, width: innerWidth },
|
||||
},
|
||||
runtimeMarkers: {
|
||||
extensionBuild: document.documentElement.getAttribute("data-ryd-extension-build"),
|
||||
extension: document.documentElement.getAttribute("data-ryd-extension-version"),
|
||||
userscriptBuild: document.documentElement.getAttribute("data-ryd-userscript-build"),
|
||||
userscript: document.documentElement.getAttribute("data-ryd-userscript-version"),
|
||||
},
|
||||
renderers: renderers.map((renderer, index) => ({
|
||||
actionBars: renderer.querySelectorAll(actionBarSelector).length,
|
||||
ariaHidden: renderer.getAttribute("aria-hidden"),
|
||||
index,
|
||||
isActive: renderer.getAttribute("is-active"),
|
||||
links: [...renderer.querySelectorAll('a[href*="/shorts/"]')].map((link) => ({
|
||||
href: link.getAttribute("href"),
|
||||
visible: isVisible(link),
|
||||
})),
|
||||
rect: readRect(renderer),
|
||||
syntheticControls: renderer.querySelectorAll(syntheticSelector).length,
|
||||
tagName: renderer.tagName.toLowerCase(),
|
||||
videoId: renderer.getAttribute("video-id"),
|
||||
visible: isVisible(renderer),
|
||||
})),
|
||||
actionBars: [...document.querySelectorAll(actionBarSelector)].map((actionBar, index) => ({
|
||||
index,
|
||||
nativeDislikes: actionBar.querySelectorAll("dislike-button-view-model, #dislike-button").length,
|
||||
nativeLikes: actionBar.querySelectorAll("like-button-view-model, #like-button").length,
|
||||
rect: readRect(actionBar),
|
||||
syntheticControls: actionBar.querySelectorAll(syntheticSelector).length,
|
||||
tagName: actionBar.tagName.toLowerCase(),
|
||||
videoId: actionBar.closest("ytd-reel-video-renderer, ytm-reel-video-renderer")?.getAttribute("video-id") ?? null,
|
||||
visible: isVisible(actionBar),
|
||||
})),
|
||||
syntheticControls: [...document.querySelectorAll(syntheticSelector)].map((control, index) => ({
|
||||
ariaPressed: control.querySelector("button")?.getAttribute("aria-pressed") ?? null,
|
||||
index,
|
||||
rect: readRect(control),
|
||||
text: (control.textContent ?? "").replace(/\s+/g, " ").trim(),
|
||||
videoId: control.getAttribute("data-ryd-video-id"),
|
||||
visible: isVisible(control),
|
||||
})),
|
||||
videos: [...document.querySelectorAll("video")].map((video, index) => ({
|
||||
currentTime: video.currentTime,
|
||||
ended: video.ended,
|
||||
index,
|
||||
paused: video.paused,
|
||||
readyState: video.readyState,
|
||||
rect: readRect(video),
|
||||
visible: isVisible(video),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function diagnosticApiUrl(value) {
|
||||
let url;
|
||||
try {
|
||||
url = new URL(value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (url.origin !== API_ORIGIN) return null;
|
||||
|
||||
const query = {};
|
||||
for (const [name, requestValue] of url.searchParams) {
|
||||
query[name] = /auth|key|puzzle|secret|solution|token|user/i.test(name) ? "<redacted>" : requestValue;
|
||||
}
|
||||
return { pathname: url.pathname, query };
|
||||
}
|
||||
|
||||
function timestampForFilename(date) {
|
||||
return date.toISOString().replace(/[:.]/g, "-");
|
||||
}
|
||||
|
||||
class LiveRunDiagnostics {
|
||||
constructor(
|
||||
page,
|
||||
context,
|
||||
{
|
||||
clock = () => new Date(),
|
||||
fileSystem = fs,
|
||||
log = console.log,
|
||||
outputDirectory = path.resolve(__dirname, "../../../../test-results/live-youtube/diagnostics"),
|
||||
runtime = null,
|
||||
} = {},
|
||||
) {
|
||||
this.browserSignals = [];
|
||||
this.clock = clock;
|
||||
this.context = context;
|
||||
this.currentCheckpoint = null;
|
||||
this.currentStage = "startup";
|
||||
this.fileSystem = fileSystem;
|
||||
this.log = log;
|
||||
this.outputDirectory = outputDirectory;
|
||||
this.page = page;
|
||||
this.recentApiRequests = [];
|
||||
this.requestRecords = new WeakMap();
|
||||
this.runtime = runtime;
|
||||
this.started = false;
|
||||
|
||||
this.onConsole = this.onConsole.bind(this);
|
||||
this.onPageError = this.onPageError.bind(this);
|
||||
this.onRequest = this.onRequest.bind(this);
|
||||
this.onRequestFailed = this.onRequestFailed.bind(this);
|
||||
this.onResponse = this.onResponse.bind(this);
|
||||
}
|
||||
|
||||
now() {
|
||||
return this.clock().toISOString();
|
||||
}
|
||||
|
||||
appendCapped(collection, value, maximum) {
|
||||
collection.push(value);
|
||||
if (collection.length > maximum) collection.splice(0, collection.length - maximum);
|
||||
}
|
||||
|
||||
recordBrowserSignal(type, details) {
|
||||
const signal = {
|
||||
at: this.now(),
|
||||
checkpoint: this.currentCheckpoint,
|
||||
stage: this.currentStage,
|
||||
type,
|
||||
...details,
|
||||
};
|
||||
this.appendCapped(this.browserSignals, signal, MAX_BROWSER_SIGNALS);
|
||||
this.log(`LIVE_BROWSER_SIGNAL ${type} ${JSON.stringify(details)}`);
|
||||
}
|
||||
|
||||
onPageError(error) {
|
||||
this.recordBrowserSignal("pageerror", { error: serializeError(error) });
|
||||
}
|
||||
|
||||
onConsole(message) {
|
||||
if (message.type() !== "error") return;
|
||||
const text = message.text();
|
||||
const location = message.location?.() ?? {};
|
||||
if (text.startsWith(UNHANDLED_REJECTION_PREFIX)) {
|
||||
this.recordBrowserSignal("unhandledrejection", {
|
||||
location,
|
||||
message: text.slice(UNHANDLED_REJECTION_PREFIX.length),
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.recordBrowserSignal("console.error", { location, message: text });
|
||||
}
|
||||
|
||||
onRequest(request) {
|
||||
const apiUrl = diagnosticApiUrl(request.url());
|
||||
if (!apiUrl) return;
|
||||
const record = {
|
||||
...apiUrl,
|
||||
at: this.now(),
|
||||
failure: null,
|
||||
method: request.method(),
|
||||
resourceType: typeof request.resourceType === "function" ? request.resourceType() : null,
|
||||
checkpoint: this.currentCheckpoint,
|
||||
stage: this.currentStage,
|
||||
status: null,
|
||||
};
|
||||
this.appendCapped(this.recentApiRequests, record, MAX_API_REQUESTS);
|
||||
this.requestRecords.set(request, record);
|
||||
}
|
||||
|
||||
onResponse(response) {
|
||||
const record = this.requestRecords.get(response.request());
|
||||
if (!record) return;
|
||||
record.status = response.status();
|
||||
}
|
||||
|
||||
onRequestFailed(request) {
|
||||
const record = this.requestRecords.get(request);
|
||||
if (!record) return;
|
||||
record.failure = request.failure()?.errorText ?? "Unknown request failure";
|
||||
}
|
||||
|
||||
async start() {
|
||||
if (this.started) return;
|
||||
this.started = true;
|
||||
this.page.on("console", this.onConsole);
|
||||
this.page.on("pageerror", this.onPageError);
|
||||
this.context.on("request", this.onRequest);
|
||||
this.context.on("requestfailed", this.onRequestFailed);
|
||||
this.context.on("response", this.onResponse);
|
||||
await this.page.addInitScript(installUnhandledRejectionListener, UNHANDLED_REJECTION_PREFIX);
|
||||
if (!this.page.isClosed()) {
|
||||
await this.page.evaluate(installUnhandledRejectionListener, UNHANDLED_REJECTION_PREFIX);
|
||||
}
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (!this.started) return;
|
||||
this.started = false;
|
||||
this.page.off("console", this.onConsole);
|
||||
this.page.off("pageerror", this.onPageError);
|
||||
this.context.off("request", this.onRequest);
|
||||
this.context.off("requestfailed", this.onRequestFailed);
|
||||
this.context.off("response", this.onResponse);
|
||||
}
|
||||
|
||||
checkpoint(name, details = {}) {
|
||||
this.currentCheckpoint = name;
|
||||
this.log(`LIVE_CHECKPOINT ${name} ${JSON.stringify(details)}`);
|
||||
}
|
||||
|
||||
stageStarted(name) {
|
||||
this.currentCheckpoint = null;
|
||||
this.currentStage = name;
|
||||
this.log(`LIVE_STAGE_START ${name}`);
|
||||
}
|
||||
|
||||
stageCompleted(name, startedAt) {
|
||||
this.currentCheckpoint = null;
|
||||
this.currentStage = name;
|
||||
this.log(`LIVE_STAGE_COMPLETE ${name} durationMs=${Date.now() - startedAt}`);
|
||||
}
|
||||
|
||||
stageFailed(name, startedAt, error) {
|
||||
this.currentStage = name;
|
||||
this.log(
|
||||
`LIVE_STAGE_FAILED ${name} durationMs=${Date.now() - startedAt} error=${JSON.stringify(error?.message ?? String(error))}`,
|
||||
);
|
||||
}
|
||||
|
||||
async snapshot(error) {
|
||||
let pageState = null;
|
||||
let pageStateError = null;
|
||||
let url = null;
|
||||
try {
|
||||
url = this.page.url();
|
||||
} catch {
|
||||
// A disconnected page still produces a useful harness/request snapshot.
|
||||
}
|
||||
if (!this.page.isClosed()) {
|
||||
try {
|
||||
pageState = await this.page.evaluate(readLivePageState);
|
||||
} catch (snapshotError) {
|
||||
pageStateError = serializeError(snapshotError);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
browserSignals: this.browserSignals,
|
||||
capturedAt: this.now(),
|
||||
currentCheckpoint: this.currentCheckpoint,
|
||||
currentStage: this.currentStage,
|
||||
error: serializeError(error),
|
||||
pageClosed: this.page.isClosed(),
|
||||
pageState,
|
||||
pageStateError,
|
||||
recentApiRequests: this.recentApiRequests,
|
||||
runtime: this.runtime,
|
||||
url,
|
||||
};
|
||||
}
|
||||
|
||||
async persistFailureSnapshot(error) {
|
||||
const snapshot = await this.snapshot(error);
|
||||
this.fileSystem.mkdirSync(this.outputDirectory, { recursive: true });
|
||||
const fileName = `failure-${timestampForFilename(this.clock())}.json`;
|
||||
const snapshotPath = path.join(this.outputDirectory, fileName);
|
||||
this.fileSystem.writeFileSync(snapshotPath, `${JSON.stringify(snapshot, null, 2)}\n`, "utf8");
|
||||
return snapshotPath;
|
||||
}
|
||||
}
|
||||
|
||||
async function runLoggedStage(diagnostics, name, action) {
|
||||
const startedAt = Date.now();
|
||||
diagnostics.stageStarted(name);
|
||||
try {
|
||||
const result = await action();
|
||||
diagnostics.stageCompleted(name, startedAt);
|
||||
return result;
|
||||
} catch (error) {
|
||||
diagnostics.stageFailed(name, startedAt, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
API_ORIGIN,
|
||||
LiveRunDiagnostics,
|
||||
UNHANDLED_REJECTION_PREFIX,
|
||||
diagnosticApiUrl,
|
||||
installUnhandledRejectionListener,
|
||||
readLivePageState,
|
||||
runLoggedStage,
|
||||
serializeError,
|
||||
};
|
||||
@@ -0,0 +1,138 @@
|
||||
const readline = require("node:readline");
|
||||
const { chromium } = require("@playwright/test");
|
||||
const {
|
||||
createExtensionLiveRuntimeAdapter,
|
||||
createUserscriptLiveRuntimeAdapter,
|
||||
} = require("../../../e2e/live-runtime-adapter");
|
||||
const { createSharedLiveScenarioRunner } = require("../../../e2e/shared-live-scenarios");
|
||||
const { consumeLiveVoteApproval, hasFreshVoteApproval, readLiveOptions } = require("../../live/live-options");
|
||||
const { LiveRunDiagnostics, runLoggedStage } = require("./live-diagnostics");
|
||||
const { LiveYoutubeDriver, VoteTrafficRecorder } = require("./live-youtube-driver");
|
||||
|
||||
const scenarioRunner = createSharedLiveScenarioRunner();
|
||||
|
||||
function readApprovalLine() {
|
||||
const input = readline.createInterface({ input: process.stdin, terminal: false });
|
||||
return new Promise((resolve) => {
|
||||
input.once("line", (line) => {
|
||||
input.close();
|
||||
resolve(line.trim());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = readLiveOptions();
|
||||
if (!options) throw new Error("Set RYD_LIVE_YOUTUBE=1 and the documented allowlist variables to opt in.");
|
||||
|
||||
let browser;
|
||||
let diagnostics;
|
||||
let page;
|
||||
try {
|
||||
console.log("WAITING_FOR_BRAVE_DEBUG_APPROVAL");
|
||||
browser = await chromium.connectOverCDP(options.cdpEndpoint, {
|
||||
isLocal: true,
|
||||
noDefaults: true,
|
||||
timeout: 120_000,
|
||||
});
|
||||
const [context] = browser.contexts();
|
||||
if (!context) throw new Error("The attached Chromium browser has no default context.");
|
||||
|
||||
page = await context.newPage();
|
||||
diagnostics = new LiveRunDiagnostics(page, context, { runtime: options.runtime });
|
||||
await diagnostics.start();
|
||||
const driver = new LiveYoutubeDriver(page, context, {
|
||||
reportProgress: (name, details) => diagnostics.checkpoint(name, details),
|
||||
});
|
||||
const createAdapter =
|
||||
options.runtime === "extension" ? createExtensionLiveRuntimeAdapter : createUserscriptLiveRuntimeAdapter;
|
||||
const runtimeAdapter = createAdapter({
|
||||
driver,
|
||||
expectedBuildId: options.expectedBuildId,
|
||||
expectedVersion: options.expectedVersion,
|
||||
});
|
||||
console.log("BRAVE_CONNECTED");
|
||||
|
||||
const readOnly = {};
|
||||
readOnly.channelShorts = await runLoggedStage(diagnostics, "read-only.channel-to-shorts-and-next", () =>
|
||||
scenarioRunner.run(runtimeAdapter, "channel-shorts-navigation", options),
|
||||
);
|
||||
if (options.navigation.watch) {
|
||||
readOnly.channelWatch = await runLoggedStage(diagnostics, "read-only.channel-to-watch", () =>
|
||||
scenarioRunner.run(runtimeAdapter, "channel-watch-navigation", options),
|
||||
);
|
||||
} else {
|
||||
readOnly.channelWatch = null;
|
||||
diagnostics.checkpoint("read-only.channel-to-watch.skipped", {
|
||||
reason: "RYD_LIVE_NAV_WATCH is not configured",
|
||||
});
|
||||
}
|
||||
readOnly.reload = await runLoggedStage(diagnostics, "read-only.reload", () =>
|
||||
scenarioRunner.run(runtimeAdapter, "reload", options),
|
||||
);
|
||||
readOnly.responsive = await runLoggedStage(diagnostics, "read-only.responsive-visual", () =>
|
||||
scenarioRunner.run(runtimeAdapter, "responsive-visual", options),
|
||||
);
|
||||
readOnly.short = await runLoggedStage(diagnostics, "read-only.short-render", () =>
|
||||
scenarioRunner.run(runtimeAdapter, "shorts-render", options),
|
||||
);
|
||||
readOnly.spa = await runLoggedStage(diagnostics, "read-only.playlist-spa", () =>
|
||||
scenarioRunner.run(runtimeAdapter, "spa-navigation", options),
|
||||
);
|
||||
readOnly.sidebarStress = await runLoggedStage(diagnostics, "read-only.sidebar-stress", () =>
|
||||
scenarioRunner.run(runtimeAdapter, "sidebar-navigation-stress", options),
|
||||
);
|
||||
readOnly.watch = await runLoggedStage(diagnostics, "read-only.watch-render", () =>
|
||||
scenarioRunner.run(runtimeAdapter, "watch-render", options),
|
||||
);
|
||||
console.log(`READ_ONLY_COMPLETE ${JSON.stringify(readOnly)}`);
|
||||
console.log(
|
||||
`READY_FOR_REACTION_APPROVAL runtime=${options.runtime} watch=${options.watchB} short=${options.short}`,
|
||||
);
|
||||
|
||||
diagnostics.checkpoint("reaction-approval.waiting", {
|
||||
instruction: "Enter SKIP to close the live-test tab without production reactions",
|
||||
});
|
||||
const approval = await readApprovalLine();
|
||||
if (approval === "SKIP") {
|
||||
console.log("REACTION_MATRIX_SKIPPED");
|
||||
return;
|
||||
}
|
||||
if (!hasFreshVoteApproval(approval, options.runtime, options.watchB, Date.now())) {
|
||||
throw new Error("The supplied live reaction approval is missing, expired, or does not match this run.");
|
||||
}
|
||||
|
||||
const reaction = await runLoggedStage(diagnostics, "reaction-matrix", () =>
|
||||
scenarioRunner.run(runtimeAdapter, "reaction-matrix", options, {
|
||||
createRecorder: (videoId) => new VoteTrafficRecorder(context, videoId),
|
||||
consumeVoteApproval: async () => {
|
||||
driver.assertCurrentVideo(options.watchB);
|
||||
await driver.assertRuntime(options.runtime, options.expectedVersion, options.expectedBuildId);
|
||||
if (!consumeLiveVoteApproval(approval, options.runtime, options.watchB)) {
|
||||
throw new Error("The live reaction approval expired or was already used. No reaction was clicked.");
|
||||
}
|
||||
},
|
||||
}),
|
||||
);
|
||||
console.log(`REACTION_MATRIX_COMPLETE ${JSON.stringify(reaction)}`);
|
||||
} catch (error) {
|
||||
if (diagnostics) {
|
||||
try {
|
||||
const snapshotPath = await diagnostics.persistFailureSnapshot(error);
|
||||
console.error(`LIVE_FAILURE_SNAPSHOT ${snapshotPath}`);
|
||||
} catch (snapshotError) {
|
||||
console.error(`LIVE_FAILURE_SNAPSHOT_FAILED ${snapshotError.message}`);
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
diagnostics?.stop();
|
||||
if (page && !page.isClosed()) await page.close();
|
||||
if (browser) await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,568 @@
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { WATCH_RATIO_SOAK_DURATION_MS } = require("./live-youtube-driver");
|
||||
|
||||
const RESPONSIVE_VIEWPORTS = [
|
||||
{ height: 720, width: 1280 },
|
||||
{ height: 720, width: 768 },
|
||||
{ height: 844, width: 390 },
|
||||
];
|
||||
|
||||
const REACTION_CYCLES = {
|
||||
neutral: ["like", "like", "dislike", "like", "dislike", "dislike"],
|
||||
liked: ["like", "dislike", "like", "dislike", "dislike", "like"],
|
||||
disliked: ["like", "dislike", "dislike", "like", "like", "dislike"],
|
||||
};
|
||||
|
||||
function nextReactionState(state, action) {
|
||||
if (action === "like") return state === "liked" ? "neutral" : "liked";
|
||||
if (action === "dislike") return state === "disliked" ? "neutral" : "disliked";
|
||||
throw new Error(`Unsupported reaction action: ${action}`);
|
||||
}
|
||||
|
||||
function reactionValue(state) {
|
||||
if (state === "liked") return 1;
|
||||
if (state === "disliked") return -1;
|
||||
if (state === "neutral") return 0;
|
||||
throw new Error(`Unsupported reaction state: ${state}`);
|
||||
}
|
||||
|
||||
async function assertLivePreconditions(driver, options) {
|
||||
await driver.assertSignedIn(options.expectedChannel);
|
||||
await driver.assertRuntime(options.runtime, options.expectedVersion, options.expectedBuildId);
|
||||
}
|
||||
|
||||
async function runWatchRenderScenario(driver, options) {
|
||||
return driver.withNoProductionInteractions(async () => {
|
||||
await driver.openPlaylist(options.playlistUrl, options.watchA);
|
||||
await assertLivePreconditions(driver, options);
|
||||
return driver.waitForDislikeText();
|
||||
});
|
||||
}
|
||||
|
||||
async function runReloadScenario(driver, options) {
|
||||
return driver.withNoProductionInteractions(async () => {
|
||||
await driver.openPlaylist(options.playlistUrl, options.watchA);
|
||||
await assertLivePreconditions(driver, options);
|
||||
await driver.waitForDislikeText();
|
||||
await driver.reload(options.watchA);
|
||||
await assertLivePreconditions(driver, options);
|
||||
return driver.waitForDislikeText();
|
||||
});
|
||||
}
|
||||
|
||||
async function runSpaNavigationScenario(driver, options) {
|
||||
return driver.withNoProductionInteractions(async () => {
|
||||
await driver.openPlaylist(options.playlistUrl, options.watchA);
|
||||
await assertLivePreconditions(driver, options);
|
||||
const watchACount = await driver.waitForDislikeText();
|
||||
await driver.navigateWithinPlaylist(options.watchB);
|
||||
await assertLivePreconditions(driver, options);
|
||||
const watchBCount = await driver.waitForDislikeText({ differentFrom: watchACount });
|
||||
assert.match(watchACount, /\d/);
|
||||
assert.match(watchBCount, /\d/);
|
||||
assert.notEqual(
|
||||
watchBCount,
|
||||
watchACount,
|
||||
"Choose allowlisted playlist videos whose rendered dislike counts differ so stale SPA UI can be detected.",
|
||||
);
|
||||
return { watchACount, watchBCount };
|
||||
});
|
||||
}
|
||||
|
||||
async function runSidebarStressScenario(
|
||||
driver,
|
||||
options,
|
||||
{
|
||||
makeDirectory = (directory) => fs.mkdirSync(directory, { recursive: true }),
|
||||
outputDirectory = path.resolve(__dirname, "../../../../test-results/live-youtube/sidebar-stress"),
|
||||
readyTimeoutMs = 1_000,
|
||||
soakDurationMs = WATCH_RATIO_SOAK_DURATION_MS,
|
||||
} = {},
|
||||
) {
|
||||
const hopCount = options.sidebar?.hopCount;
|
||||
assert.ok(Number.isSafeInteger(hopCount) && hopCount > 0, "A positive sidebar stress hop count is required.");
|
||||
assert.ok(
|
||||
Number.isFinite(readyTimeoutMs) && readyTimeoutMs > 0,
|
||||
"A positive ratio-bar readiness budget is required.",
|
||||
);
|
||||
makeDirectory(outputDirectory);
|
||||
|
||||
return driver.withNoProductionInteractions(async () => {
|
||||
await driver.openWatch(options.watchA);
|
||||
await assertLivePreconditions(driver, options);
|
||||
const visitedVideoIds = [options.watchA];
|
||||
const hops = [];
|
||||
|
||||
for (let index = 0; index < hopCount; index += 1) {
|
||||
const { body, videoId } = await driver.navigateToRelatedWatch(visitedVideoIds);
|
||||
assert.equal(typeof body.dislikes, "number", `Sidebar hop ${index + 1} has no production dislike count.`);
|
||||
assert.ok(!visitedVideoIds.includes(videoId), `Sidebar hop ${index + 1} revisited ${videoId}.`);
|
||||
driver.assertCurrentVideo(videoId);
|
||||
await assertLivePreconditions(driver, options);
|
||||
|
||||
const screenshotPath = path.join(
|
||||
outputDirectory,
|
||||
`${options.runtime}-sidebar-hop-${String(index + 1).padStart(2, "0")}.png`,
|
||||
);
|
||||
const readinessStartedAt = Date.now();
|
||||
const visual = await driver.captureWatchRatioVisual(options.runtime, screenshotPath, {
|
||||
presenceTimeoutMs: readyTimeoutMs,
|
||||
});
|
||||
const readyLatencyMs = Date.now() - readinessStartedAt;
|
||||
assert.ok(
|
||||
readyLatencyMs <= readyTimeoutMs,
|
||||
`Sidebar hop ${index + 1} ratio bar took ${readyLatencyMs}ms; the budget is ${readyTimeoutMs}ms.`,
|
||||
);
|
||||
assert.match(visual.count, /\d/, `Sidebar hop ${index + 1} did not render a dislike count.`);
|
||||
const soak = await driver.soakWatchRatioVisual(options.runtime, {
|
||||
durationMs: soakDurationMs,
|
||||
expectedCount: visual.count,
|
||||
videoId,
|
||||
});
|
||||
|
||||
visitedVideoIds.push(videoId);
|
||||
hops.push({
|
||||
apiDislikes: body.dislikes,
|
||||
count: visual.count,
|
||||
readyLatencyMs,
|
||||
readyTimeoutMs,
|
||||
screenshotPath,
|
||||
soak,
|
||||
videoId,
|
||||
});
|
||||
}
|
||||
|
||||
return { hopCount, hops, outputDirectory, startVideoId: options.watchA };
|
||||
});
|
||||
}
|
||||
|
||||
async function runShortsRenderScenario(driver, options) {
|
||||
return driver.withNoProductionInteractions(async () => {
|
||||
await driver.openShort(options.short);
|
||||
await assertLivePreconditions(driver, options);
|
||||
return driver.waitForDislikeText();
|
||||
});
|
||||
}
|
||||
|
||||
async function runChannelShortsNavigationScenario(driver, options) {
|
||||
return driver.withNoProductionInteractions(async () => {
|
||||
await driver.navigateFromColdChannelToShort(options.navigation.channelUrl, options.navigation.short);
|
||||
await assertLivePreconditions(driver, options);
|
||||
const initial = await driver.assertCurrentShortsControl(options.navigation.short, options.runtime);
|
||||
|
||||
const nextVideoId = await driver.navigateToNextShort(options.navigation.short);
|
||||
await driver.assertRuntime(options.runtime, options.expectedVersion, options.expectedBuildId);
|
||||
const next = await driver.assertCurrentShortsControl(nextVideoId, options.runtime);
|
||||
await driver.pausePlayback();
|
||||
assert.notEqual(
|
||||
next.videoId,
|
||||
initial.videoId,
|
||||
"The Shorts Next video scenario did not change the current video ID.",
|
||||
);
|
||||
return { initial, next };
|
||||
});
|
||||
}
|
||||
|
||||
async function runChannelWatchNavigationScenario(driver, options) {
|
||||
if (!options.navigation.watch) {
|
||||
throw new Error("RYD_LIVE_NAV_WATCH is required for the optional channel-to-watch scenario.");
|
||||
}
|
||||
|
||||
return driver.withNoProductionInteractions(async () => {
|
||||
await driver.navigateFromColdChannelToWatch(options.navigation.channelUrl, options.navigation.watch);
|
||||
await assertLivePreconditions(driver, options);
|
||||
const count = await driver.waitForDislikeText();
|
||||
return { count, videoId: options.navigation.watch };
|
||||
});
|
||||
}
|
||||
|
||||
async function runResponsiveVisualScenario(
|
||||
driver,
|
||||
options,
|
||||
{
|
||||
makeDirectory = (directory) => fs.mkdirSync(directory, { recursive: true }),
|
||||
outputDirectory = path.resolve(__dirname, "../../../../test-results/live-youtube/responsive"),
|
||||
} = {},
|
||||
) {
|
||||
makeDirectory(outputDirectory);
|
||||
return driver.withNoProductionInteractions(async () => {
|
||||
const originalViewport = await driver.readViewportSize();
|
||||
const watch = [];
|
||||
const shorts = [];
|
||||
|
||||
try {
|
||||
await driver.setViewportSize(RESPONSIVE_VIEWPORTS[0]);
|
||||
await driver.openWatch(options.watchA);
|
||||
await assertLivePreconditions(driver, options);
|
||||
for (let index = 0; index < RESPONSIVE_VIEWPORTS.length; index += 1) {
|
||||
const viewport = RESPONSIVE_VIEWPORTS[index];
|
||||
if (index > 0) await driver.setViewportSize(viewport);
|
||||
await driver.waitForDislikeText();
|
||||
watch.push(
|
||||
await driver.captureWatchRatioVisual(
|
||||
options.runtime,
|
||||
path.join(outputDirectory, `${options.runtime}-watch-ratio-${viewport.width}.png`),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
await driver.setViewportSize(RESPONSIVE_VIEWPORTS[0]);
|
||||
await driver.openShort(options.short);
|
||||
await assertLivePreconditions(driver, options);
|
||||
for (let index = 0; index < RESPONSIVE_VIEWPORTS.length; index += 1) {
|
||||
const viewport = RESPONSIVE_VIEWPORTS[index];
|
||||
if (index > 0) await driver.setViewportSize(viewport);
|
||||
await driver.assertCurrentShortsControl(options.short, options.runtime);
|
||||
await driver.waitForDislikeText();
|
||||
const screenshotPath = path.join(outputDirectory, `${options.runtime}-shorts-control-${viewport.width}.png`);
|
||||
shorts.push(
|
||||
options.runtime === "userscript"
|
||||
? await driver.captureSyntheticShortsVisual(options.short, screenshotPath)
|
||||
: await driver.captureNativeShortsVisual(options.short, screenshotPath),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await driver.setViewportSize(originalViewport);
|
||||
}
|
||||
|
||||
return {
|
||||
outputDirectory,
|
||||
shorts,
|
||||
shortsSkipped: null,
|
||||
watch,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function restoreReactionStateUnchecked(
|
||||
driver,
|
||||
recorder,
|
||||
options,
|
||||
videoId,
|
||||
initialState,
|
||||
expectedUserId,
|
||||
isShort,
|
||||
failedAttempt,
|
||||
) {
|
||||
try {
|
||||
driver.assertCurrentVideo(videoId);
|
||||
} catch {
|
||||
if (isShort) await driver.openShort(videoId);
|
||||
else await driver.openWatch(videoId);
|
||||
await assertLivePreconditions(driver, options);
|
||||
}
|
||||
|
||||
const currentState = await driver.readReactionState();
|
||||
if (currentState === initialState && !failedAttempt) return;
|
||||
|
||||
const attemptedUserId = failedAttempt?.userId;
|
||||
const assertCleanupUserId = (userId, roundTripUserId) => {
|
||||
assert.equal(typeof userId, "string", "The cleanup reaction has no anonymous RYD identity.");
|
||||
if (expectedUserId) {
|
||||
assert.equal(userId, expectedUserId, "The cleanup reaction used a different anonymous RYD identity.");
|
||||
}
|
||||
if (attemptedUserId) {
|
||||
assert.equal(userId, attemptedUserId, "The cleanup reaction did not use the failed attempt's RYD identity.");
|
||||
}
|
||||
if (roundTripUserId) {
|
||||
assert.equal(userId, roundTripUserId, "The cleanup round trip changed anonymous RYD identity.");
|
||||
}
|
||||
};
|
||||
const performCleanupTransition = async (action, targetState, roundTripUserId) => {
|
||||
const mark = recorder.mark();
|
||||
await driver.clickAction(videoId, action);
|
||||
await driver.waitForReactionState(targetState);
|
||||
const userId = await recorder.waitForHandshake(reactionValue(targetState), mark);
|
||||
assertCleanupUserId(userId, roundTripUserId);
|
||||
return userId;
|
||||
};
|
||||
|
||||
if (currentState === initialState) {
|
||||
const action = initialState === "liked" ? "like" : "dislike";
|
||||
const awayState = initialState === "neutral" ? "disliked" : "neutral";
|
||||
const awayUserId = await performCleanupTransition(action, awayState);
|
||||
await performCleanupTransition(action, initialState, awayUserId);
|
||||
return;
|
||||
}
|
||||
|
||||
const action =
|
||||
initialState === "neutral"
|
||||
? currentState === "liked"
|
||||
? "like"
|
||||
: "dislike"
|
||||
: initialState === "liked"
|
||||
? "like"
|
||||
: "dislike";
|
||||
await performCleanupTransition(action, initialState);
|
||||
}
|
||||
|
||||
async function restoreReactionState(
|
||||
driver,
|
||||
recorder,
|
||||
options,
|
||||
videoId,
|
||||
initialState,
|
||||
expectedUserId,
|
||||
isShort,
|
||||
failedAttempt,
|
||||
) {
|
||||
try {
|
||||
await restoreReactionStateUnchecked(
|
||||
driver,
|
||||
recorder,
|
||||
options,
|
||||
videoId,
|
||||
initialState,
|
||||
expectedUserId,
|
||||
isShort,
|
||||
failedAttempt,
|
||||
);
|
||||
} catch (error) {
|
||||
const url = isShort ? `https://www.youtube.com/shorts/${videoId}` : `https://www.youtube.com/watch?v=${videoId}`;
|
||||
throw new Error(`Automatic cleanup could not be verified. Manually restore ${url}. ${error.message}`, {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function runReactionCycle(
|
||||
driver,
|
||||
recorder,
|
||||
options,
|
||||
{ beforeFirstAction, captureReactionVisual = null, isShort = false, videoId },
|
||||
) {
|
||||
if (isShort) await driver.openShort(videoId);
|
||||
else await driver.openWatch(videoId);
|
||||
await assertLivePreconditions(driver, options);
|
||||
await driver.waitForDislikeText();
|
||||
|
||||
const initialState = await driver.readReactionState();
|
||||
const actions = REACTION_CYCLES[initialState];
|
||||
assert.ok(actions, `Unsupported initial YouTube reaction state: ${initialState}`);
|
||||
|
||||
let authorized = false;
|
||||
let completed = false;
|
||||
let expectedUserId;
|
||||
let failedAttempt;
|
||||
let currentState = initialState;
|
||||
const evidencePaths = [];
|
||||
const captureEvidence = async (index) => {
|
||||
if (!captureReactionVisual) return;
|
||||
const screenshotPath = await captureReactionVisual({ index, state: currentState });
|
||||
assert.equal(typeof screenshotPath, "string", "The reaction visual capture did not return an evidence path.");
|
||||
evidencePaths.push(screenshotPath);
|
||||
};
|
||||
try {
|
||||
await captureEvidence(0);
|
||||
driver.assertCurrentVideo(videoId);
|
||||
await assertLivePreconditions(driver, options);
|
||||
if (beforeFirstAction) await beforeFirstAction();
|
||||
authorized = true;
|
||||
|
||||
for (let index = 0; index < actions.length; index += 1) {
|
||||
const action = actions[index];
|
||||
const expectedState = nextReactionState(currentState, action);
|
||||
const mark = recorder.mark();
|
||||
const value = reactionValue(expectedState);
|
||||
failedAttempt = { mark, userId: undefined, value };
|
||||
await driver.clickAction(videoId, action);
|
||||
await driver.waitForReactionState(expectedState);
|
||||
const userId = await recorder.waitForHandshake(value, mark);
|
||||
failedAttempt.userId = userId;
|
||||
if (expectedUserId) assert.equal(userId, expectedUserId, "The transition cycle changed anonymous RYD identity.");
|
||||
expectedUserId = userId;
|
||||
failedAttempt = undefined;
|
||||
currentState = expectedState;
|
||||
await driver.waitForDislikeText();
|
||||
await captureEvidence(index + 1);
|
||||
}
|
||||
|
||||
assert.equal(currentState, initialState, "The six-transition cycle did not return to its initial state.");
|
||||
completed = true;
|
||||
return { evidencePaths, initialState, userId: expectedUserId };
|
||||
} finally {
|
||||
if (authorized && !completed) {
|
||||
if (failedAttempt && !failedAttempt.userId && typeof recorder.voteUserId === "function") {
|
||||
try {
|
||||
failedAttempt.userId = recorder.voteUserId(failedAttempt.value, failedAttempt.mark);
|
||||
} catch {
|
||||
failedAttempt.userId = undefined;
|
||||
}
|
||||
}
|
||||
await restoreReactionState(
|
||||
driver,
|
||||
recorder,
|
||||
options,
|
||||
videoId,
|
||||
initialState,
|
||||
expectedUserId,
|
||||
isShort,
|
||||
failedAttempt,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function runProductionReactionMatrixScenario(
|
||||
driver,
|
||||
options,
|
||||
createRecorder,
|
||||
consumeVoteApproval,
|
||||
visualOptions = {},
|
||||
) {
|
||||
const makeDirectory = visualOptions.makeDirectory ?? ((directory) => fs.mkdirSync(directory, { recursive: true }));
|
||||
const outputDirectory =
|
||||
visualOptions.outputDirectory ??
|
||||
path.resolve(__dirname, "../../../../test-results/live-youtube/reactions", options.runtime);
|
||||
makeDirectory(outputDirectory);
|
||||
const captureFor =
|
||||
(kind, videoId, isShort) =>
|
||||
async ({ index, state }) => {
|
||||
const screenshotPath = path.join(outputDirectory, `${kind}-${index}-${state}.png`);
|
||||
const evidence = await driver.captureReactionStateVisual({
|
||||
expectedState: state,
|
||||
isShort,
|
||||
runtime: options.runtime,
|
||||
screenshotPath,
|
||||
videoId,
|
||||
});
|
||||
assert.equal(
|
||||
evidence?.screenshotPath,
|
||||
screenshotPath,
|
||||
`The ${kind} reaction capture did not write the requested evidence path.`,
|
||||
);
|
||||
return screenshotPath;
|
||||
};
|
||||
|
||||
const watchRecorder = createRecorder(options.watchB);
|
||||
let watchResult;
|
||||
try {
|
||||
watchResult = await runReactionCycle(driver, watchRecorder, options, {
|
||||
beforeFirstAction: consumeVoteApproval,
|
||||
captureReactionVisual: captureFor("watch", options.watchB, false),
|
||||
videoId: options.watchB,
|
||||
});
|
||||
} finally {
|
||||
watchRecorder.stop();
|
||||
}
|
||||
|
||||
const shortRecorder = createRecorder(options.short);
|
||||
try {
|
||||
const shortResult = await runReactionCycle(driver, shortRecorder, options, {
|
||||
captureReactionVisual: captureFor("short", options.short, true),
|
||||
isShort: true,
|
||||
videoId: options.short,
|
||||
});
|
||||
assert.equal(
|
||||
shortResult.userId,
|
||||
watchResult.userId,
|
||||
"Watch and Shorts votes used different anonymous RYD identities.",
|
||||
);
|
||||
return {
|
||||
evidencePaths: [...watchResult.evidencePaths, ...shortResult.evidencePaths],
|
||||
outputDirectory,
|
||||
short: shortResult,
|
||||
watch: watchResult,
|
||||
};
|
||||
} finally {
|
||||
shortRecorder.stop();
|
||||
}
|
||||
}
|
||||
|
||||
async function restoreNeutralStateUnchecked(driver, recorder, options, dislikeMark, expectedUserId) {
|
||||
let state;
|
||||
try {
|
||||
driver.assertCurrentVideo(options.watchB);
|
||||
state = await driver.readVoteState();
|
||||
} catch {
|
||||
await driver.openWatch(options.watchB);
|
||||
await assertLivePreconditions(driver, options);
|
||||
state = await driver.readVoteState();
|
||||
}
|
||||
|
||||
if (state === "false") {
|
||||
if (recorder.hasVote(-1, dislikeMark)) {
|
||||
throw new Error(
|
||||
`The YouTube button is neutral but the production -1 vote may remain. Manually verify and restore https://www.youtube.com/watch?v=${options.watchB}.`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (state !== "true") {
|
||||
throw new Error(
|
||||
`Could not verify the YouTube dislike state. Manually restore https://www.youtube.com/watch?v=${options.watchB}.`,
|
||||
);
|
||||
}
|
||||
|
||||
const neutralMark = recorder.mark();
|
||||
await driver.clickDislike(options.watchB);
|
||||
await driver.waitForVoteState(false);
|
||||
const neutralUserId = await recorder.waitForHandshake(0, neutralMark);
|
||||
if (expectedUserId && neutralUserId !== expectedUserId) {
|
||||
throw new Error(
|
||||
`The neutral vote used a different identity from the dislike vote. Manually verify and restore https://www.youtube.com/watch?v=${options.watchB}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function restoreNeutralState(driver, recorder, options, dislikeMark, expectedUserId) {
|
||||
try {
|
||||
await restoreNeutralStateUnchecked(driver, recorder, options, dislikeMark, expectedUserId);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Automatic cleanup could not be verified. Manually restore https://www.youtube.com/watch?v=${options.watchB}. ${error.message}`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function runReversibleVoteScenario(driver, recorder, options, consumeVoteApproval) {
|
||||
await driver.openWatch(options.watchB);
|
||||
await assertLivePreconditions(driver, options);
|
||||
await driver.waitForDislikeText();
|
||||
const initialLikeState = await driver.readLikeState();
|
||||
const initialDislikeState = await driver.readVoteState();
|
||||
assert.equal(initialLikeState, "false", "The allowlisted vote video must not already be liked.");
|
||||
assert.equal(initialDislikeState, "false", "The allowlisted vote video must not already be disliked.");
|
||||
|
||||
const dislikeMark = recorder.mark();
|
||||
let cleanupRequired = false;
|
||||
let dislikeUserId;
|
||||
try {
|
||||
driver.assertCurrentVideo(options.watchB);
|
||||
await assertLivePreconditions(driver, options);
|
||||
await consumeVoteApproval();
|
||||
cleanupRequired = true;
|
||||
await driver.clickDislike(options.watchB);
|
||||
await driver.waitForVoteState(true);
|
||||
dislikeUserId = await recorder.waitForHandshake(-1, dislikeMark);
|
||||
} finally {
|
||||
if (cleanupRequired) {
|
||||
await restoreNeutralState(
|
||||
driver,
|
||||
recorder,
|
||||
options,
|
||||
dislikeMark,
|
||||
dislikeUserId || recorder.voteUserId(-1, dislikeMark),
|
||||
);
|
||||
}
|
||||
}
|
||||
await driver.waitForDislikeText();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
RESPONSIVE_VIEWPORTS,
|
||||
runChannelShortsNavigationScenario,
|
||||
runChannelWatchNavigationScenario,
|
||||
runProductionReactionMatrixScenario,
|
||||
runReactionCycle,
|
||||
runReloadScenario,
|
||||
runReversibleVoteScenario,
|
||||
runResponsiveVisualScenario,
|
||||
runSidebarStressScenario,
|
||||
runShortsRenderScenario,
|
||||
runSpaNavigationScenario,
|
||||
runWatchRenderScenario,
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,113 @@
|
||||
const { chromium, test } = require("@playwright/test");
|
||||
const {
|
||||
createExtensionLiveRuntimeAdapter,
|
||||
createUserscriptLiveRuntimeAdapter,
|
||||
} = require("../../../e2e/live-runtime-adapter");
|
||||
const { createSharedLiveScenarioRunner } = require("../../../e2e/shared-live-scenarios");
|
||||
const { consumeLiveVoteApproval, hasFreshVoteApproval, readLiveOptions } = require("../../live/live-options");
|
||||
const { LiveYoutubeDriver, VoteTrafficRecorder } = require("./live-youtube-driver");
|
||||
|
||||
const options = readLiveOptions();
|
||||
const scenarioRunner = createSharedLiveScenarioRunner();
|
||||
|
||||
test.describe("live YouTube RYD smoke", () => {
|
||||
test.skip(options === null, "Set RYD_LIVE_YOUTUBE=1 and the documented allowlist variables to opt in.");
|
||||
test.describe.configure({ mode: "serial" });
|
||||
|
||||
let browser;
|
||||
let context;
|
||||
let page;
|
||||
let driver;
|
||||
let runtimeAdapter;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
try {
|
||||
browser = await chromium.connectOverCDP(options.cdpEndpoint, {
|
||||
isLocal: true,
|
||||
noDefaults: true,
|
||||
timeout: 15_000,
|
||||
});
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Could not attach to the running Brave/Chromium profile. Enable remote debugging at brave://inspect/#remote-debugging and set RYD_CDP_ENDPOINT to its explicit CDP endpoint, then retry. ${error.message}`,
|
||||
);
|
||||
}
|
||||
|
||||
[context] = browser.contexts();
|
||||
if (!context) throw new Error("The attached Brave/Chromium browser has no default context.");
|
||||
page = await context.newPage();
|
||||
driver = new LiveYoutubeDriver(page, context);
|
||||
const createAdapter =
|
||||
options.runtime === "extension" ? createExtensionLiveRuntimeAdapter : createUserscriptLiveRuntimeAdapter;
|
||||
runtimeAdapter = createAdapter({
|
||||
driver,
|
||||
expectedBuildId: options.expectedBuildId,
|
||||
expectedVersion: options.expectedVersion,
|
||||
});
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
if (page && !page.isClosed()) await page.close();
|
||||
if (browser) await browser.close();
|
||||
});
|
||||
|
||||
test(`${options?.runtime || "runtime"}: initializes after cold channel-to-Short and Next-video SPA navigation`, async () => {
|
||||
test.setTimeout(150_000);
|
||||
await scenarioRunner.run(runtimeAdapter, "channel-shorts-navigation", options);
|
||||
});
|
||||
|
||||
test(`${options?.runtime || "runtime"}: initializes after optional cold channel-to-watch SPA navigation`, async () => {
|
||||
test.setTimeout(120_000);
|
||||
test.skip(
|
||||
!options?.navigation.watch,
|
||||
"Set RYD_LIVE_NAV_WATCH only when the configured channel page contains an exact visible link to that video.",
|
||||
);
|
||||
await scenarioRunner.run(runtimeAdapter, "channel-watch-navigation", options);
|
||||
});
|
||||
|
||||
test(`${options?.runtime || "runtime"}: renders on a signed-in watch page`, async () => {
|
||||
await scenarioRunner.run(runtimeAdapter, "watch-render", options);
|
||||
});
|
||||
|
||||
test(`${options?.runtime || "runtime"}: reinitializes after reload`, async () => {
|
||||
await scenarioRunner.run(runtimeAdapter, "reload", options);
|
||||
});
|
||||
|
||||
test(`${options?.runtime || "runtime"}: reinitializes after real playlist SPA navigation`, async () => {
|
||||
await scenarioRunner.run(runtimeAdapter, "spa-navigation", options);
|
||||
});
|
||||
|
||||
test(`${options?.runtime || "runtime"}: survives consecutive real sidebar SPA navigations`, async () => {
|
||||
test.setTimeout(60_000 + options.sidebar.hopCount * 75_000);
|
||||
await scenarioRunner.run(runtimeAdapter, "sidebar-navigation-stress", options);
|
||||
});
|
||||
|
||||
test(`${options?.runtime || "runtime"}: renders on an allowlisted Short`, async () => {
|
||||
await scenarioRunner.run(runtimeAdapter, "shorts-render", options);
|
||||
});
|
||||
|
||||
test(`${options?.runtime || "runtime"}: preserves reaction UI geometry across responsive widths`, async () => {
|
||||
test.setTimeout(180_000);
|
||||
await scenarioRunner.run(runtimeAdapter, "responsive-visual", options);
|
||||
});
|
||||
|
||||
test(`${options?.runtime || "runtime"}: covers all reaction transitions on watch and Shorts`, async () => {
|
||||
test.setTimeout(0);
|
||||
const voteApproval = process.env.RYD_LIVE_VOTES;
|
||||
test.skip(
|
||||
!hasFreshVoteApproval(voteApproval, options.runtime, options.watchB, Date.now()),
|
||||
"Create the documented short-lived RYD_LIVE_VOTES token only after approving the real vote pair.",
|
||||
);
|
||||
|
||||
await scenarioRunner.run(runtimeAdapter, "reaction-matrix", options, {
|
||||
createRecorder: (videoId) => new VoteTrafficRecorder(context, videoId),
|
||||
consumeVoteApproval: async () => {
|
||||
driver.assertCurrentVideo(options.watchB);
|
||||
await driver.assertRuntime(options.runtime, options.expectedVersion, options.expectedBuildId);
|
||||
if (!consumeLiveVoteApproval(voteApproval, options.runtime, options.watchB)) {
|
||||
throw new Error("The live reaction approval expired or was already used. No reaction was clicked.");
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,418 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
const {
|
||||
CREDENTIAL_KEY,
|
||||
VIDEO_A,
|
||||
VIDEO_B,
|
||||
createFakeBackend,
|
||||
injectGeneratedUserscript,
|
||||
installGmEnvironment,
|
||||
installHermeticRoutes,
|
||||
openShortsFixture,
|
||||
readGmValue,
|
||||
} = require("./harness");
|
||||
|
||||
const EXISTING_CREDENTIALS = {
|
||||
userId: "ExistingUserscriptCredential000000000001",
|
||||
registrationConfirmed: true,
|
||||
};
|
||||
const SYNTHETIC_STATE_KEY = `rydSyntheticDislikedShort:${VIDEO_A}`;
|
||||
|
||||
function visibleVoteButton(page, role) {
|
||||
return page.locator(`[data-ryd-role="${role}"]:visible button`);
|
||||
}
|
||||
|
||||
async function installDelayedSyntheticStateRead(page) {
|
||||
await page.evaluate((delayedKey) => {
|
||||
const originalGetValue = globalThis.GM.getValue;
|
||||
let releaseRead;
|
||||
let reportReadStarted;
|
||||
let delayed = false;
|
||||
const readGate = new Promise((resolve) => {
|
||||
releaseRead = resolve;
|
||||
});
|
||||
globalThis.__rydDelayedGmReadStarted = new Promise((resolve) => {
|
||||
reportReadStarted = resolve;
|
||||
});
|
||||
globalThis.__releaseRydDelayedGmRead = releaseRead;
|
||||
globalThis.GM.getValue = async (key, fallbackValue) => {
|
||||
if (key === delayedKey) {
|
||||
if (!delayed) {
|
||||
delayed = true;
|
||||
reportReadStarted();
|
||||
await readGate;
|
||||
}
|
||||
}
|
||||
return originalGetValue(key, fallbackValue);
|
||||
};
|
||||
}, SYNTHETIC_STATE_KEY);
|
||||
}
|
||||
|
||||
async function launchDelayedShorts(
|
||||
{ context, page },
|
||||
{ backendOptions, configureBackend, nativeDislike = false } = {},
|
||||
) {
|
||||
const consoleErrors = [];
|
||||
const pageErrors = [];
|
||||
page.on("console", (message) => {
|
||||
if (message.type() === "error") consoleErrors.push(message.text());
|
||||
});
|
||||
page.on("pageerror", (error) => pageErrors.push(error.message));
|
||||
|
||||
const backend = createFakeBackend(backendOptions);
|
||||
configureBackend?.(backend);
|
||||
await installGmEnvironment(context, { [CREDENTIAL_KEY]: EXISTING_CREDENTIALS });
|
||||
await installHermeticRoutes(context, backend);
|
||||
await openShortsFixture(page, VIDEO_A);
|
||||
await page.evaluate(() => {
|
||||
globalThis.__unhandledRejections = [];
|
||||
addEventListener("unhandledrejection", (event) => {
|
||||
globalThis.__unhandledRejections.push(
|
||||
event.reason instanceof Error ? event.reason.message : String(event.reason),
|
||||
);
|
||||
});
|
||||
});
|
||||
if (nativeDislike) {
|
||||
await page.evaluate(() => window.__shortsFixture.installNativeDislike());
|
||||
}
|
||||
await installDelayedSyntheticStateRead(page);
|
||||
await injectGeneratedUserscript(page);
|
||||
await page.evaluate(() => globalThis.__rydDelayedGmReadStarted);
|
||||
|
||||
return { backend, consoleErrors, pageErrors };
|
||||
}
|
||||
|
||||
async function releaseDelayedRead(page) {
|
||||
await page.evaluate(() => globalThis.__releaseRydDelayedGmRead());
|
||||
}
|
||||
|
||||
async function expectCompletedHandshakes(backend, expectedValues) {
|
||||
await expect.poll(() => backend.requestsFor("POST", "/interact/confirmVote").length).toBe(expectedValues.length);
|
||||
await expect
|
||||
.poll(() => backend.requestsFor("POST", "/interact/confirmVote").every((request) => request.respondedAt))
|
||||
.toBe(true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
|
||||
const votes = backend.requestsFor("POST", "/interact/vote");
|
||||
const confirmations = backend.requestsFor("POST", "/interact/confirmVote");
|
||||
expect(votes).toHaveLength(expectedValues.length);
|
||||
expect(confirmations).toHaveLength(expectedValues.length);
|
||||
expect(votes.map((request) => request.body.value)).toEqual(expectedValues);
|
||||
for (let index = 0; index < expectedValues.length; index += 1) {
|
||||
expect(votes[index].body).toEqual({
|
||||
userId: EXISTING_CREDENTIALS.userId,
|
||||
videoId: VIDEO_A,
|
||||
value: expectedValues[index],
|
||||
});
|
||||
expect(confirmations[index].body).toMatchObject({
|
||||
userId: EXISTING_CREDENTIALS.userId,
|
||||
videoId: VIDEO_A,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function expectSingleCompletedHandshake(backend, { value, videoId }) {
|
||||
await expect.poll(() => backend.requestsFor("POST", "/interact/confirmVote").length).toBe(1);
|
||||
await expect.poll(() => backend.requestsFor("POST", "/interact/confirmVote")[0]?.respondedAt).toBeTruthy();
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
|
||||
const votes = backend.requestsFor("POST", "/interact/vote");
|
||||
const confirmations = backend.requestsFor("POST", "/interact/confirmVote");
|
||||
expect(votes).toHaveLength(1);
|
||||
expect(confirmations).toHaveLength(1);
|
||||
expect(votes[0].body).toEqual({ userId: EXISTING_CREDENTIALS.userId, videoId, value });
|
||||
expect(confirmations[0].body).toMatchObject({ userId: EXISTING_CREDENTIALS.userId, videoId });
|
||||
}
|
||||
|
||||
async function expectNoRuntimeFailures(page, harness) {
|
||||
expect(harness.backend.blockedRequests).toEqual([]);
|
||||
expect(harness.consoleErrors).toEqual([]);
|
||||
expect(harness.pageErrors).toEqual([]);
|
||||
expect(await page.evaluate(() => globalThis.__unhandledRejections)).toEqual([]);
|
||||
}
|
||||
|
||||
test("immediate native Like during synthetic Shorts initialization submits once and remains coherent", async ({
|
||||
context,
|
||||
page,
|
||||
}) => {
|
||||
const harness = await launchDelayedShorts({ context, page });
|
||||
const likeButton = visibleVoteButton(page, "like");
|
||||
|
||||
await likeButton.click();
|
||||
try {
|
||||
await expect.poll(() => harness.backend.requestsFor("POST", "/interact/confirmVote").length).toBe(1);
|
||||
} finally {
|
||||
await releaseDelayedRead(page);
|
||||
}
|
||||
|
||||
await expect(page.locator("[data-ryd-synthetic-shorts-dislike]:visible #text")).toHaveText("25");
|
||||
await expect(likeButton).toHaveAttribute("aria-pressed", "true");
|
||||
await expect(visibleVoteButton(page, "dislike")).toHaveAttribute("aria-pressed", "false");
|
||||
await likeButton.click();
|
||||
await expectCompletedHandshakes(harness.backend, [1, 0]);
|
||||
await expect(likeButton).toHaveAttribute("aria-pressed", "false");
|
||||
await expect(visibleVoteButton(page, "dislike")).toHaveAttribute("aria-pressed", "false");
|
||||
await expectNoRuntimeFailures(page, harness);
|
||||
});
|
||||
|
||||
test("immediate native Dislike during native Shorts initialization submits once and remains coherent", async ({
|
||||
context,
|
||||
page,
|
||||
}) => {
|
||||
const harness = await launchDelayedShorts({ context, page }, { nativeDislike: true });
|
||||
const dislikeButton = visibleVoteButton(page, "dislike");
|
||||
|
||||
await dislikeButton.click();
|
||||
try {
|
||||
await expect.poll(() => harness.backend.requestsFor("POST", "/interact/confirmVote").length).toBe(1);
|
||||
} finally {
|
||||
await releaseDelayedRead(page);
|
||||
}
|
||||
|
||||
await expect(dislikeButton.locator("#text")).toHaveText(/\d/);
|
||||
await expect(dislikeButton).toHaveAttribute("aria-pressed", "true");
|
||||
await expect(visibleVoteButton(page, "like")).toHaveAttribute("aria-pressed", "false");
|
||||
await dislikeButton.click();
|
||||
await expectCompletedHandshakes(harness.backend, [-1, 0]);
|
||||
await expect(dislikeButton).toHaveAttribute("aria-pressed", "false");
|
||||
await expect(visibleVoteButton(page, "like")).toHaveAttribute("aria-pressed", "false");
|
||||
await expectNoRuntimeFailures(page, harness);
|
||||
});
|
||||
|
||||
test("stale native Shorts hydration preserves video A's captured dislike across a recycled video B", async ({
|
||||
context,
|
||||
page,
|
||||
}) => {
|
||||
const harness = await launchDelayedShorts(
|
||||
{ context, page },
|
||||
{
|
||||
backendOptions: {
|
||||
countsByVideo: {
|
||||
[VIDEO_A]: { dislikes: 11, likes: 100 },
|
||||
[VIDEO_B]: { dislikes: 22, likes: 200 },
|
||||
},
|
||||
},
|
||||
nativeDislike: true,
|
||||
},
|
||||
);
|
||||
|
||||
await visibleVoteButton(page, "dislike").click();
|
||||
try {
|
||||
await expect.poll(() => harness.backend.requestsFor("POST", "/interact/confirmVote").length).toBe(1);
|
||||
expect(harness.backend.requestsFor("POST", "/interact/vote")[0].body).toEqual({
|
||||
userId: EXISTING_CREDENTIALS.userId,
|
||||
videoId: VIDEO_A,
|
||||
value: -1,
|
||||
});
|
||||
await page.evaluate((videoId) => window.__shortsFixture.recycleActiveRenderer(videoId), VIDEO_B);
|
||||
} finally {
|
||||
await releaseDelayedRead(page);
|
||||
}
|
||||
|
||||
await expect(page.locator('[data-ryd-role="dislike"]:visible #text')).toHaveText("22");
|
||||
await expect.poll(() => readGmValue(page, SYNTHETIC_STATE_KEY)).toBe(true);
|
||||
|
||||
await page.evaluate((videoId) => {
|
||||
window.__shortsFixture.recycleActiveRenderer(videoId);
|
||||
window.__shortsFixture.removeNativeDislike();
|
||||
}, VIDEO_A);
|
||||
await expect(page.locator('[data-ryd-role="dislike"]:visible #text')).toHaveText("11");
|
||||
const restoredSyntheticDislike = page.locator("[data-ryd-synthetic-shorts-dislike]:visible button");
|
||||
await expect(restoredSyntheticDislike).toHaveAttribute("aria-pressed", "true");
|
||||
await restoredSyntheticDislike.click();
|
||||
|
||||
await expectCompletedHandshakes(harness.backend, [-1, 0]);
|
||||
await expect(restoredSyntheticDislike).toHaveAttribute("aria-pressed", "false");
|
||||
await expect.poll(() => readGmValue(page, SYNTHETIC_STATE_KEY)).toBe(null);
|
||||
expect(
|
||||
harness.backend.requestsFor("POST", "/interact/vote").filter((request) => request.body.videoId === VIDEO_B),
|
||||
).toHaveLength(0);
|
||||
await expectNoRuntimeFailures(page, harness);
|
||||
});
|
||||
|
||||
test("video B Like remains bound while video A hydration is delayed", async ({ context, page }) => {
|
||||
const harness = await launchDelayedShorts(
|
||||
{ context, page },
|
||||
{
|
||||
backendOptions: {
|
||||
countsByVideo: {
|
||||
[VIDEO_A]: { dislikes: 11, likes: 100 },
|
||||
[VIDEO_B]: { dislikes: 22, likes: 200 },
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await page.evaluate((videoId) => window.__shortsFixture.recycleActiveRenderer(videoId), VIDEO_B);
|
||||
const videoBLike = visibleVoteButton(page, "like");
|
||||
await videoBLike.click();
|
||||
try {
|
||||
await expectSingleCompletedHandshake(harness.backend, { videoId: VIDEO_B, value: 1 });
|
||||
} finally {
|
||||
await releaseDelayedRead(page);
|
||||
}
|
||||
|
||||
await expect(page.locator('[data-ryd-role="dislike"]:visible #text')).toHaveText("22");
|
||||
await expect(videoBLike).toHaveAttribute("aria-pressed", "true");
|
||||
await expect(visibleVoteButton(page, "dislike")).toHaveAttribute("aria-pressed", "false");
|
||||
expect(
|
||||
harness.backend.requestsFor("POST", "/interact/vote").filter((request) => request.body.videoId === VIDEO_A),
|
||||
).toHaveLength(0);
|
||||
await expectNoRuntimeFailures(page, harness);
|
||||
});
|
||||
|
||||
for (const replacement of ["inner Like button", "action bar"]) {
|
||||
test(`a replacement ${replacement} receives the pending same-video hydration listener`, async ({ context, page }) => {
|
||||
const harness = await launchDelayedShorts({ context, page });
|
||||
|
||||
await page.evaluate((replacementKind) => {
|
||||
if (replacementKind === "inner Like button") {
|
||||
window.__shortsFixture.replaceInnerButton("like");
|
||||
} else {
|
||||
window.__shortsFixture.replaceActionBar();
|
||||
}
|
||||
}, replacement);
|
||||
const replacementLike = visibleVoteButton(page, "like");
|
||||
await replacementLike.click();
|
||||
try {
|
||||
await expectSingleCompletedHandshake(harness.backend, { videoId: VIDEO_A, value: 1 });
|
||||
} finally {
|
||||
await releaseDelayedRead(page);
|
||||
}
|
||||
|
||||
await expect(page.locator('[data-ryd-role="dislike"]:visible #text')).toHaveText("25");
|
||||
await expect(replacementLike).toHaveAttribute("aria-pressed", "true");
|
||||
await expect(page.locator("[data-ryd-synthetic-shorts-dislike]:visible")).toHaveCount(1);
|
||||
await expectNoRuntimeFailures(page, harness);
|
||||
});
|
||||
}
|
||||
|
||||
test("enabled synthetic Dislike clears native Like during a pending same-wrapper rehydration", async ({
|
||||
context,
|
||||
page,
|
||||
}) => {
|
||||
const harness = await launchDelayedShorts({ context, page });
|
||||
await releaseDelayedRead(page);
|
||||
const syntheticDislike = visibleVoteButton(page, "dislike");
|
||||
await expect(page.locator('[data-ryd-role="dislike"]:visible #text')).toHaveText("25");
|
||||
await expect(syntheticDislike).toHaveAttribute("aria-disabled", "false");
|
||||
|
||||
await installDelayedSyntheticStateRead(page);
|
||||
await page.evaluate((videoId) => window.__shortsFixture.activate(videoId, { state: "liked" }), VIDEO_A);
|
||||
await page.evaluate(() => globalThis.__rydDelayedGmReadStarted);
|
||||
await expect(visibleVoteButton(page, "like")).toHaveAttribute("aria-pressed", "true");
|
||||
|
||||
await syntheticDislike.click();
|
||||
try {
|
||||
await expectSingleCompletedHandshake(harness.backend, { videoId: VIDEO_A, value: -1 });
|
||||
} finally {
|
||||
await releaseDelayedRead(page);
|
||||
}
|
||||
|
||||
await expect(visibleVoteButton(page, "like")).toHaveAttribute("aria-pressed", "false");
|
||||
await expect(syntheticDislike).toHaveAttribute("aria-pressed", "true");
|
||||
await expect.poll(() => readGmValue(page, SYNTHETIC_STATE_KEY)).toBe(true);
|
||||
expect(harness.backend.requestsFor("GET", "/votes")).toHaveLength(1);
|
||||
await expectNoRuntimeFailures(page, harness);
|
||||
});
|
||||
|
||||
test("new same-video hydration wins after two native Dislike transitions complete before the old read", async ({
|
||||
context,
|
||||
page,
|
||||
}) => {
|
||||
const harness = await launchDelayedShorts(
|
||||
{ context, page },
|
||||
{
|
||||
backendOptions: {
|
||||
countsByVideo: {
|
||||
[VIDEO_A]: { dislikes: 25, likes: 100 },
|
||||
},
|
||||
},
|
||||
nativeDislike: true,
|
||||
},
|
||||
);
|
||||
|
||||
await expect(page.locator('[data-ryd-role="dislike"]:visible #text')).toHaveText("25");
|
||||
let readReleased = false;
|
||||
try {
|
||||
await visibleVoteButton(page, "dislike").click();
|
||||
await expectSingleCompletedHandshake(harness.backend, { videoId: VIDEO_A, value: -1 });
|
||||
await expect(page.locator('[data-ryd-role="dislike"]:visible #text')).toHaveText("26");
|
||||
|
||||
await page.evaluate(() => window.__shortsFixture.replaceActionBar());
|
||||
const replacementDislike = visibleVoteButton(page, "dislike");
|
||||
await replacementDislike.click();
|
||||
await expectCompletedHandshakes(harness.backend, [-1, 0]);
|
||||
await expect(replacementDislike).toHaveAttribute("aria-pressed", "false");
|
||||
await expect(page.locator('[data-ryd-role="dislike"]:visible #text')).toHaveText("25");
|
||||
await expect.poll(() => readGmValue(page, SYNTHETIC_STATE_KEY)).toBe(null);
|
||||
expect(harness.backend.requestsFor("GET", "/votes")).toHaveLength(1);
|
||||
} finally {
|
||||
await releaseDelayedRead(page);
|
||||
readReleased = true;
|
||||
}
|
||||
|
||||
expect(readReleased).toBe(true);
|
||||
const finalDislike = visibleVoteButton(page, "dislike");
|
||||
await expect(finalDislike).toHaveAttribute("aria-pressed", "false");
|
||||
await expect(page.locator('[data-ryd-role="dislike"]:visible #text')).toHaveText("25");
|
||||
await expect.poll(() => readGmValue(page, SYNTHETIC_STATE_KEY)).toBe(null);
|
||||
await page.waitForTimeout(200);
|
||||
expect(harness.backend.requestsFor("GET", "/votes")).toHaveLength(1);
|
||||
expect(harness.backend.requestsFor("POST", "/interact/vote").map((request) => request.body.value)).toEqual([-1, 0]);
|
||||
|
||||
await finalDislike.click();
|
||||
await expectCompletedHandshakes(harness.backend, [-1, 0, -1]);
|
||||
await expect(finalDislike).toHaveAttribute("aria-pressed", "true");
|
||||
await expect(page.locator('[data-ryd-role="dislike"]:visible #text')).toHaveText("26");
|
||||
await expect.poll(() => readGmValue(page, SYNTHETIC_STATE_KEY)).toBe(true);
|
||||
expect(harness.backend.requestsFor("GET", "/votes")).toHaveLength(1);
|
||||
await expectNoRuntimeFailures(page, harness);
|
||||
});
|
||||
|
||||
test("fresh same-video count state supersedes stale hydration correction", async ({ context, page }) => {
|
||||
const harness = await launchDelayedShorts(
|
||||
{ context, page },
|
||||
{
|
||||
configureBackend: (backend) => {
|
||||
backend.enqueue("GET", "/votes", { body: { dislikes: 25, likes: 100, rating: 4.5 } });
|
||||
backend.enqueue("GET", "/votes", { body: { dislikes: 22, likes: 200, rating: 4.5 } });
|
||||
backend.enqueue("GET", "/votes", { body: { dislikes: 26, likes: 100, rating: 4.5 } });
|
||||
},
|
||||
nativeDislike: true,
|
||||
},
|
||||
);
|
||||
|
||||
const dislikeCount = page.locator('[data-ryd-role="dislike"]:visible #text');
|
||||
await expect(dislikeCount).toHaveText("25");
|
||||
try {
|
||||
await visibleVoteButton(page, "dislike").click();
|
||||
await expectSingleCompletedHandshake(harness.backend, { videoId: VIDEO_A, value: -1 });
|
||||
await expect(dislikeCount).toHaveText("26");
|
||||
|
||||
await page.evaluate((videoId) => window.__shortsFixture.recycleActiveRenderer(videoId), VIDEO_B);
|
||||
await expect(dislikeCount).toHaveText("22");
|
||||
|
||||
await page.evaluate(
|
||||
(videoId) => window.__shortsFixture.recycleActiveRenderer(videoId, { state: "disliked" }),
|
||||
VIDEO_A,
|
||||
);
|
||||
await expect(dislikeCount).toHaveText("26");
|
||||
expect(
|
||||
harness.backend.requestsFor("GET", "/votes").filter((request) => request.query.videoId === VIDEO_A),
|
||||
).toHaveLength(2);
|
||||
} finally {
|
||||
await releaseDelayedRead(page);
|
||||
}
|
||||
|
||||
await page.waitForTimeout(250);
|
||||
await expect(dislikeCount).toHaveText("26");
|
||||
await expect(visibleVoteButton(page, "dislike")).toHaveAttribute("aria-pressed", "true");
|
||||
await expect.poll(() => readGmValue(page, SYNTHETIC_STATE_KEY)).toBe(true);
|
||||
expect(harness.backend.requestsFor("POST", "/interact/vote").map((request) => request.body.value)).toEqual([-1]);
|
||||
expect(harness.backend.requestsFor("POST", "/interact/confirmVote")).toHaveLength(1);
|
||||
expect(harness.backend.requestsFor("GET", "/votes")).toHaveLength(3);
|
||||
expect(
|
||||
harness.backend.requestsFor("GET", "/votes").filter((request) => request.query.videoId === VIDEO_B),
|
||||
).toHaveLength(1);
|
||||
await expectNoRuntimeFailures(page, harness);
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,67 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
const {
|
||||
CREDENTIAL_KEY,
|
||||
createFakeBackend,
|
||||
injectGeneratedUserscript,
|
||||
installGmEnvironment,
|
||||
installHermeticRoutes,
|
||||
openNavigationFixture,
|
||||
} = require("./harness");
|
||||
const {
|
||||
NAVIGATION_MATRIX,
|
||||
USERSCRIPT_MATRIX_RUNTIME,
|
||||
installNavigationMatrixFixture,
|
||||
runNavigationMatrixScenario,
|
||||
} = require("./navigation-matrix");
|
||||
|
||||
const EXISTING_CREDENTIALS = {
|
||||
registrationConfirmed: true,
|
||||
userId: "ExistingUserscriptCredential000000000001",
|
||||
};
|
||||
|
||||
for (const scenario of NAVIGATION_MATRIX) {
|
||||
test(`userscript navigation matrix: ${scenario.id}`, async ({ context, page }) => {
|
||||
await page.setViewportSize(scenario.viewport);
|
||||
const consoleErrors = [];
|
||||
const pageErrors = [];
|
||||
page.on("console", (message) => {
|
||||
if (message.type() === "error") consoleErrors.push(message.text());
|
||||
});
|
||||
page.on("pageerror", (error) => pageErrors.push(error.message));
|
||||
|
||||
await context.addInitScript(() => {
|
||||
globalThis.__unhandledRejections = [];
|
||||
addEventListener("unhandledrejection", (event) => {
|
||||
const reason = event.reason;
|
||||
globalThis.__unhandledRejections.push(reason instanceof Error ? reason.message : String(reason));
|
||||
});
|
||||
});
|
||||
|
||||
const backend = createFakeBackend({
|
||||
countsByVideo: {
|
||||
[scenario.destination.videoId]: scenario.destination.counts,
|
||||
[scenario.origin.videoId]: scenario.origin.counts,
|
||||
},
|
||||
});
|
||||
await installGmEnvironment(context, { [CREDENTIAL_KEY]: EXISTING_CREDENTIALS });
|
||||
await installNavigationMatrixFixture(context, scenario);
|
||||
await installHermeticRoutes(context, backend);
|
||||
await openNavigationFixture(page, {
|
||||
pageKind: scenario.origin.kind,
|
||||
videoId: scenario.origin.videoId,
|
||||
});
|
||||
await injectGeneratedUserscript(page);
|
||||
|
||||
await runNavigationMatrixScenario({
|
||||
backend,
|
||||
page,
|
||||
runtime: USERSCRIPT_MATRIX_RUNTIME,
|
||||
scenario,
|
||||
});
|
||||
|
||||
expect(backend.blockedRequests).toEqual([]);
|
||||
expect(consoleErrors).toEqual([]);
|
||||
expect(pageErrors).toEqual([]);
|
||||
expect(await page.evaluate(() => globalThis.__unhandledRejections)).toEqual([]);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
const {
|
||||
CREDENTIAL_KEY,
|
||||
VIDEO_A,
|
||||
createFakeBackend,
|
||||
injectGeneratedUserscript,
|
||||
installGmEnvironment,
|
||||
installHermeticRoutes,
|
||||
openWatchFixture,
|
||||
} = require("./harness");
|
||||
|
||||
const EXISTING_CREDENTIALS = {
|
||||
userId: "ExistingUserscriptCredential000000000001",
|
||||
registrationConfirmed: true,
|
||||
};
|
||||
const COUNTS = { likes: 300, dislikes: 100 };
|
||||
const VIEWPORTS = [
|
||||
{ name: "wide desktop", width: 1280, height: 720 },
|
||||
{ name: "narrow desktop", width: 768, height: 720 },
|
||||
{ name: "mobile-sized", width: 390, height: 844 },
|
||||
];
|
||||
|
||||
function colorChannels(color) {
|
||||
const srgb = color.match(/^color\(srgb\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)/);
|
||||
if (srgb) return srgb.slice(1).map((channel) => Number(channel) * 255);
|
||||
const channels = color
|
||||
.match(/[\d.]+/g)
|
||||
?.slice(0, 3)
|
||||
.map(Number);
|
||||
if (!channels || channels.length !== 3) throw new Error(`Unsupported computed color: ${color}`);
|
||||
return channels;
|
||||
}
|
||||
|
||||
function relativeLuminance(color) {
|
||||
const channels = colorChannels(color);
|
||||
const linear = channels.map((channel) => {
|
||||
const normalized = channel / 255;
|
||||
return normalized <= 0.04045 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4;
|
||||
});
|
||||
return linear[0] * 0.2126 + linear[1] * 0.7152 + linear[2] * 0.0722;
|
||||
}
|
||||
|
||||
function contrastRatio(first, second) {
|
||||
const lighter = Math.max(relativeLuminance(first), relativeLuminance(second));
|
||||
const darker = Math.min(relativeLuminance(first), relativeLuminance(second));
|
||||
return (lighter + 0.05) / (darker + 0.05);
|
||||
}
|
||||
|
||||
async function readBarColors(page) {
|
||||
return page.evaluate(() => ({
|
||||
background: getComputedStyle(document.body).backgroundColor,
|
||||
negative: getComputedStyle(document.querySelector("#return-youtube-dislike-bar-container")).backgroundColor,
|
||||
positive: getComputedStyle(document.querySelector("#return-youtube-dislike-bar")).backgroundColor,
|
||||
}));
|
||||
}
|
||||
|
||||
function expectVisibleBarColors(colors, expected) {
|
||||
expect(colors.background).toBe(expected.background);
|
||||
expect(colors.positive).toBe(expected.positive);
|
||||
colorChannels(colors.negative).forEach((channel) => expect(channel).toBeCloseTo(expected.negative, 0));
|
||||
expect(contrastRatio(colors.negative, colors.background)).toBeGreaterThanOrEqual(3);
|
||||
expect(contrastRatio(colors.positive, colors.negative)).toBeGreaterThanOrEqual(3);
|
||||
}
|
||||
|
||||
async function launchRateBarFixture({ context, page }, { rateBarEnabled, theme = "dark" } = {}) {
|
||||
const pageErrors = [];
|
||||
page.on("pageerror", (error) => pageErrors.push(error.message));
|
||||
|
||||
const backend = createFakeBackend({ countsByVideo: { [VIDEO_A]: COUNTS } });
|
||||
await installGmEnvironment(context, { [CREDENTIAL_KEY]: EXISTING_CREDENTIALS });
|
||||
await installHermeticRoutes(context, backend);
|
||||
await openWatchFixture(page, VIDEO_A);
|
||||
if (theme === "light") {
|
||||
await page.evaluate(() => {
|
||||
document.documentElement.style.setProperty("--yt-spec-base-background", "rgb(255, 255, 255)");
|
||||
document.documentElement.style.setProperty("--yt-spec-text-primary", "rgb(15, 15, 15)");
|
||||
document.documentElement.style.setProperty("--yt-spec-text-secondary", "rgb(96, 96, 96)");
|
||||
document.body.style.background = "rgb(255, 255, 255)";
|
||||
});
|
||||
}
|
||||
await injectGeneratedUserscript(page, { rateBarEnabled });
|
||||
|
||||
await expect(page.locator('[data-ryd-role="dislike"] #text')).toHaveText(String(COUNTS.dislikes));
|
||||
return { backend, pageErrors };
|
||||
}
|
||||
|
||||
for (const viewport of VIEWPORTS) {
|
||||
test(`default ratio bar renders cleanly at ${viewport.name} width`, async ({ context, page }) => {
|
||||
await page.setViewportSize({ width: viewport.width, height: viewport.height });
|
||||
const { backend, pageErrors } = await launchRateBarFixture({ context, page });
|
||||
|
||||
const wrapper = page.locator(".ryd-tooltip");
|
||||
const container = page.locator("#return-youtube-dislike-bar-container");
|
||||
const bar = page.locator("#return-youtube-dislike-bar");
|
||||
const tooltip = page.locator("#ryd-dislike-tooltip");
|
||||
const dislikeCount = page.locator('[data-ryd-role="dislike"] #text');
|
||||
|
||||
await expect(dislikeCount).toBeVisible();
|
||||
await expect(dislikeCount).toHaveText(String(COUNTS.dislikes));
|
||||
await expect(wrapper).toBeVisible();
|
||||
await expect(container).toBeVisible();
|
||||
await expect(bar).toBeVisible();
|
||||
await expect(tooltip).toContainText("300 / 100");
|
||||
await expect(tooltip).toHaveAttribute("role", "tooltip");
|
||||
await expect(tooltip).toBeHidden();
|
||||
await expect(page.locator("tp-yt-paper-tooltip#ryd-dislike-tooltip")).toHaveCount(0);
|
||||
|
||||
const idleTooltipStyle = await tooltip.evaluate((element) => {
|
||||
const style = getComputedStyle(element);
|
||||
return { opacity: style.opacity, visibility: style.visibility };
|
||||
});
|
||||
expect(idleTooltipStyle).toEqual({ opacity: "0", visibility: "hidden" });
|
||||
|
||||
await wrapper.hover({ position: { x: 1, y: 1 } });
|
||||
await expect(tooltip).toBeVisible();
|
||||
await expect.poll(() => tooltip.evaluate((element) => getComputedStyle(element).opacity)).toBe("1");
|
||||
const hoveredTooltip = await tooltip.evaluate((element) => {
|
||||
const bounds = element.getBoundingClientRect();
|
||||
const style = getComputedStyle(element);
|
||||
return {
|
||||
backgroundColor: style.backgroundColor,
|
||||
bottom: bounds.bottom,
|
||||
color: style.color,
|
||||
left: bounds.left,
|
||||
opacity: style.opacity,
|
||||
pointerEvents: style.pointerEvents,
|
||||
position: style.position,
|
||||
right: bounds.right,
|
||||
visibility: style.visibility,
|
||||
viewportWidth: innerWidth,
|
||||
};
|
||||
});
|
||||
expect(hoveredTooltip).toMatchObject({
|
||||
backgroundColor: "rgba(28, 28, 28, 0.96)",
|
||||
color: "rgb(255, 255, 255)",
|
||||
opacity: "1",
|
||||
pointerEvents: "none",
|
||||
position: "absolute",
|
||||
visibility: "visible",
|
||||
});
|
||||
expect(hoveredTooltip.left).toBeGreaterThanOrEqual(0);
|
||||
expect(hoveredTooltip.right).toBeLessThanOrEqual(hoveredTooltip.viewportWidth);
|
||||
expect(hoveredTooltip.bottom).toBeGreaterThan(0);
|
||||
|
||||
await page.mouse.move(viewport.width - 1, viewport.height - 1);
|
||||
await expect(tooltip).toBeHidden();
|
||||
await wrapper.focus();
|
||||
await expect(tooltip).toBeVisible();
|
||||
await wrapper.evaluate((element) => element.blur());
|
||||
await expect(tooltip).toBeHidden();
|
||||
|
||||
const geometry = await page.evaluate(() => {
|
||||
const rect = (selector) => {
|
||||
const bounds = document.querySelector(selector).getBoundingClientRect();
|
||||
return {
|
||||
bottom: bounds.bottom,
|
||||
height: bounds.height,
|
||||
left: bounds.left,
|
||||
right: bounds.right,
|
||||
top: bounds.top,
|
||||
width: bounds.width,
|
||||
};
|
||||
};
|
||||
return {
|
||||
bar: rect("#return-youtube-dislike-bar"),
|
||||
container: rect("#return-youtube-dislike-bar-container"),
|
||||
dislike: rect('[data-ryd-role="dislike"] button'),
|
||||
like: rect('[data-ryd-role="like"] button'),
|
||||
viewport: { height: innerHeight, width: innerWidth },
|
||||
wrapper: rect(".ryd-tooltip"),
|
||||
};
|
||||
});
|
||||
|
||||
expect(geometry.container.width).toBeGreaterThan(0);
|
||||
expect(geometry.container.height).toBeGreaterThan(0);
|
||||
expect(geometry.bar.width).toBeGreaterThan(0);
|
||||
expect(geometry.bar.height).toBeGreaterThan(0);
|
||||
expect(geometry.bar.width / geometry.container.width).toBeCloseTo(0.75, 2);
|
||||
expect(geometry.wrapper.width).toBeCloseTo(geometry.like.width + geometry.dislike.width, 0);
|
||||
expect(geometry.container.left).toBeGreaterThanOrEqual(0);
|
||||
expect(geometry.container.top).toBeGreaterThanOrEqual(0);
|
||||
expect(geometry.container.right).toBeLessThanOrEqual(geometry.viewport.width);
|
||||
expect(geometry.container.bottom).toBeLessThanOrEqual(geometry.viewport.height);
|
||||
expect(geometry.container.top).toBeGreaterThanOrEqual(Math.max(geometry.like.bottom, geometry.dislike.bottom));
|
||||
|
||||
expectVisibleBarColors(await readBarColors(page), {
|
||||
background: "rgb(15, 15, 15)",
|
||||
negative: 139,
|
||||
positive: "rgb(241, 241, 241)",
|
||||
});
|
||||
|
||||
expect(backend.blockedRequests).toEqual([]);
|
||||
expect(pageErrors).toEqual([]);
|
||||
});
|
||||
}
|
||||
|
||||
test("default ratio bar keeps both sides distinct in the light theme", async ({ context, page }) => {
|
||||
await page.setViewportSize({ width: 1280, height: 720 });
|
||||
const { backend, pageErrors } = await launchRateBarFixture({ context, page }, { theme: "light" });
|
||||
|
||||
expectVisibleBarColors(await readBarColors(page), {
|
||||
background: "rgb(255, 255, 255)",
|
||||
negative: 123,
|
||||
positive: "rgb(15, 15, 15)",
|
||||
});
|
||||
expect(backend.blockedRequests).toEqual([]);
|
||||
expect(pageErrors).toEqual([]);
|
||||
});
|
||||
|
||||
test("in-memory disabled option omits the ratio bar", async ({ context, page }) => {
|
||||
await page.setViewportSize({ width: 1280, height: 720 });
|
||||
const { backend, pageErrors } = await launchRateBarFixture({ context, page }, { rateBarEnabled: false });
|
||||
|
||||
await expect(page.locator("#return-youtube-dislike-bar-container")).toHaveCount(0);
|
||||
await expect(page.locator("#return-youtube-dislike-bar")).toHaveCount(0);
|
||||
await expect(page.locator("#ryd-dislike-tooltip")).toHaveCount(0);
|
||||
expect(backend.blockedRequests).toEqual([]);
|
||||
expect(pageErrors).toEqual([]);
|
||||
});
|
||||
@@ -0,0 +1,233 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
const {
|
||||
CREDENTIAL_KEY,
|
||||
VIDEO_A,
|
||||
createFakeBackend,
|
||||
injectGeneratedUserscript,
|
||||
installGmEnvironment,
|
||||
installHermeticRoutes,
|
||||
openShortsFixture,
|
||||
} = require("./harness");
|
||||
|
||||
const EXISTING_CREDENTIALS = {
|
||||
userId: "ExistingUserscriptCredential000000000001",
|
||||
registrationConfirmed: true,
|
||||
};
|
||||
const VIEWPORTS = [
|
||||
{ height: 720, name: "wide", width: 1280 },
|
||||
{ height: 720, name: "narrow", width: 768 },
|
||||
{ height: 844, name: "mobile-sized", width: 390 },
|
||||
];
|
||||
|
||||
async function launchShortsLayoutFixture({ context, page }, fixture = {}) {
|
||||
const pageErrors = [];
|
||||
page.on("pageerror", (error) => pageErrors.push(error.message));
|
||||
|
||||
const backend = createFakeBackend({ fixture });
|
||||
await installGmEnvironment(context, { [CREDENTIAL_KEY]: EXISTING_CREDENTIALS });
|
||||
await installHermeticRoutes(context, backend);
|
||||
await openShortsFixture(page, VIDEO_A);
|
||||
await injectGeneratedUserscript(page);
|
||||
|
||||
const syntheticDislike = page.locator("[data-ryd-synthetic-shorts-dislike]:visible");
|
||||
await expect(syntheticDislike.locator("#text")).toHaveText("25");
|
||||
return { backend, pageErrors, syntheticDislike };
|
||||
}
|
||||
|
||||
for (const viewport of VIEWPORTS) {
|
||||
test(`synthetic desktop Shorts action matches the full native stack at ${viewport.name} width`, async ({
|
||||
context,
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize(viewport);
|
||||
const { backend, pageErrors } = await launchShortsLayoutFixture({ context, page });
|
||||
|
||||
const geometry = await page.evaluate(() => {
|
||||
const nativeOuter = document.querySelector('[data-ryd-role="like"]');
|
||||
const syntheticOuter = document.querySelector("[data-ryd-synthetic-shorts-dislike]");
|
||||
const actionBar = syntheticOuter.parentElement;
|
||||
const commentsOuter = document.querySelector('[data-fixture-control="comments"]');
|
||||
const nativeLabel = nativeOuter.querySelector("label");
|
||||
const syntheticLabel = syntheticOuter.querySelector("label");
|
||||
const syntheticButton = syntheticOuter.querySelector("button");
|
||||
const syntheticIconWrapper = syntheticButton.querySelector(".ytSpecButtonShapeNextIcon");
|
||||
const syntheticIcon = syntheticIconWrapper.querySelector("svg");
|
||||
const nativeCount = nativeOuter.querySelector("#text");
|
||||
const syntheticCount = syntheticOuter.querySelector("#text");
|
||||
|
||||
const rect = (element) => {
|
||||
const bounds = element.getBoundingClientRect();
|
||||
return {
|
||||
bottom: bounds.bottom,
|
||||
height: bounds.height,
|
||||
left: bounds.left,
|
||||
right: bounds.right,
|
||||
top: bounds.top,
|
||||
width: bounds.width,
|
||||
};
|
||||
};
|
||||
const boxStyle = (element) => {
|
||||
const style = getComputedStyle(element);
|
||||
return {
|
||||
margin: [style.marginTop, style.marginRight, style.marginBottom, style.marginLeft],
|
||||
padding: [style.paddingTop, style.paddingRight, style.paddingBottom, style.paddingLeft],
|
||||
};
|
||||
};
|
||||
const textStyle = (element) => {
|
||||
const style = getComputedStyle(element);
|
||||
return { fontSize: style.fontSize, lineHeight: style.lineHeight };
|
||||
};
|
||||
|
||||
return {
|
||||
actionStack: [...actionBar.children].map((element) => ({
|
||||
role:
|
||||
element.getAttribute("data-fixture-control") ??
|
||||
(element.matches("[data-ryd-synthetic-shorts-dislike]") ? "synthetic-dislike" : "like"),
|
||||
...rect(element),
|
||||
})),
|
||||
commentsOuter: rect(commentsOuter),
|
||||
nativeClass: nativeOuter.getAttribute("class"),
|
||||
nativeCount: textStyle(nativeCount),
|
||||
nativeLabel: rect(nativeLabel),
|
||||
nativeOuter: rect(nativeOuter),
|
||||
nativeOuterStyle: boxStyle(nativeOuter),
|
||||
syntheticButton: rect(syntheticButton),
|
||||
syntheticClass: syntheticOuter.getAttribute("class"),
|
||||
syntheticCount: textStyle(syntheticCount),
|
||||
syntheticIcon: rect(syntheticIcon),
|
||||
syntheticIconWrapper: rect(syntheticIconWrapper),
|
||||
syntheticLabel: rect(syntheticLabel),
|
||||
syntheticOuter: rect(syntheticOuter),
|
||||
syntheticOuterStyle: boxStyle(syntheticOuter),
|
||||
};
|
||||
});
|
||||
|
||||
expect(geometry.nativeClass).toBe("ytLikeButtonViewModelHost ytwReelActionBarViewModelHostDesktopActionButton");
|
||||
expect(geometry.nativeOuter.width).toBe(48);
|
||||
expect(geometry.nativeOuter.height).toBe(78);
|
||||
expect(geometry.nativeOuterStyle.padding).toEqual(["0px", "0px", "8px", "0px"]);
|
||||
expect(geometry.nativeLabel.width).toBe(48);
|
||||
expect(geometry.nativeLabel.height).toBe(70);
|
||||
expect(geometry.nativeCount).toEqual({ fontSize: "12px", lineHeight: "18px" });
|
||||
|
||||
expect(geometry.syntheticClass.split(/\s+/)).toEqual(
|
||||
expect.arrayContaining([
|
||||
"ytLikeButtonViewModelHost",
|
||||
"ytwReelActionBarViewModelHostDesktopActionButton",
|
||||
"ryd-synthetic-shorts-dislike",
|
||||
]),
|
||||
);
|
||||
expect(geometry.syntheticClass).not.toContain("undefined");
|
||||
expect(geometry.syntheticOuter.width).toBeCloseTo(geometry.nativeOuter.width, 5);
|
||||
expect(geometry.syntheticOuter.height).toBeCloseTo(geometry.nativeOuter.height, 5);
|
||||
expect(geometry.syntheticOuterStyle.padding).toEqual(geometry.nativeOuterStyle.padding);
|
||||
expect(geometry.syntheticOuterStyle.margin).toEqual(geometry.nativeOuterStyle.margin);
|
||||
expect(geometry.syntheticOuter.top).toBeCloseTo(geometry.nativeOuter.bottom, 5);
|
||||
|
||||
expect(geometry.syntheticButton.width).toBe(48);
|
||||
expect(geometry.syntheticButton.height).toBe(48);
|
||||
expect(geometry.syntheticLabel.width).toBe(48);
|
||||
expect(geometry.syntheticLabel.height).toBe(70);
|
||||
expect(geometry.syntheticIconWrapper.width).toBe(24);
|
||||
expect(geometry.syntheticIconWrapper.height).toBe(24);
|
||||
expect(geometry.syntheticIcon.width).toBe(24);
|
||||
expect(geometry.syntheticIcon.height).toBe(24);
|
||||
expect(geometry.syntheticIconWrapper.left + geometry.syntheticIconWrapper.width / 2).toBeCloseTo(
|
||||
geometry.syntheticButton.left + geometry.syntheticButton.width / 2,
|
||||
5,
|
||||
);
|
||||
expect(geometry.syntheticIconWrapper.top + geometry.syntheticIconWrapper.height / 2).toBeCloseTo(
|
||||
geometry.syntheticButton.top + geometry.syntheticButton.height / 2,
|
||||
5,
|
||||
);
|
||||
expect(geometry.syntheticIcon.left + geometry.syntheticIcon.width / 2).toBeCloseTo(
|
||||
geometry.syntheticButton.left + geometry.syntheticButton.width / 2,
|
||||
5,
|
||||
);
|
||||
expect(geometry.syntheticIcon.top + geometry.syntheticIcon.height / 2).toBeCloseTo(
|
||||
geometry.syntheticButton.top + geometry.syntheticButton.height / 2,
|
||||
5,
|
||||
);
|
||||
expect(geometry.syntheticCount).toEqual({ fontSize: "12px", lineHeight: "18px" });
|
||||
expect(geometry.syntheticCount).toEqual(geometry.nativeCount);
|
||||
expect(geometry.commentsOuter.top).toBeCloseTo(geometry.syntheticOuter.bottom, 5);
|
||||
expect(geometry.actionStack.map(({ role }) => role)).toEqual([
|
||||
"like",
|
||||
"synthetic-dislike",
|
||||
"comments",
|
||||
"share",
|
||||
"remix",
|
||||
]);
|
||||
geometry.actionStack.forEach((control, index) => {
|
||||
expect(control.width).toBeCloseTo(48, 5);
|
||||
expect(control.height).toBeCloseTo(78, 5);
|
||||
expect(control.left).toBeGreaterThanOrEqual(0);
|
||||
expect(control.right).toBeLessThanOrEqual(viewport.width);
|
||||
expect(control.top).toBeGreaterThanOrEqual(0);
|
||||
expect(control.bottom).toBeLessThanOrEqual(viewport.height);
|
||||
expect(control.left + control.width / 2).toBeCloseTo(
|
||||
geometry.actionStack[0].left + geometry.actionStack[0].width / 2,
|
||||
5,
|
||||
);
|
||||
if (index > 0) expect(control.top).toBeCloseTo(geometry.actionStack[index - 1].bottom, 5);
|
||||
});
|
||||
|
||||
expect(backend.blockedRequests).toEqual([]);
|
||||
expect(pageErrors).toEqual([]);
|
||||
});
|
||||
}
|
||||
|
||||
for (const viewport of VIEWPORTS) {
|
||||
test(`Shorts selected and unselected states preserve the stack at ${viewport.name} width`, async ({
|
||||
context,
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize(viewport);
|
||||
const { backend, pageErrors, syntheticDislike } = await launchShortsLayoutFixture(
|
||||
{ context, page },
|
||||
{ initialState: "liked" },
|
||||
);
|
||||
|
||||
const nativeLike = page.locator('[data-ryd-role="like"]:visible');
|
||||
const syntheticButton = syntheticDislike.locator("button");
|
||||
const readStackGeometry = () =>
|
||||
page.locator("reel-action-bar-view-model:visible").evaluate((actionBar) =>
|
||||
[...actionBar.children].map((element) => {
|
||||
const bounds = element.getBoundingClientRect();
|
||||
return { height: bounds.height, left: bounds.left, top: bounds.top, width: bounds.width };
|
||||
}),
|
||||
);
|
||||
const initialGeometry = await readStackGeometry();
|
||||
const neutralColor = await syntheticButton.evaluate((button) => getComputedStyle(button).color);
|
||||
|
||||
await expect(nativeLike).toHaveClass(/\bstyle-default-active\b/);
|
||||
await expect(nativeLike.locator("button")).toHaveAttribute("aria-pressed", "true");
|
||||
await expect(syntheticDislike).toHaveClass(/\bytLikeButtonViewModelHost\b/);
|
||||
await expect(syntheticDislike).toHaveClass(/\bytwReelActionBarViewModelHostDesktopActionButton\b/);
|
||||
await expect(syntheticDislike).not.toHaveClass(/\bstyle-default-active\b/);
|
||||
await expect(syntheticDislike).not.toHaveClass(/\bundefined\b/);
|
||||
await expect(syntheticButton).toHaveAttribute("aria-pressed", "false");
|
||||
|
||||
await syntheticButton.click();
|
||||
await expect.poll(() => backend.requestsFor("POST", "/interact/confirmVote").length).toBe(1);
|
||||
await expect(nativeLike.locator("button")).toHaveAttribute("aria-pressed", "false");
|
||||
await expect(syntheticButton).toHaveAttribute("aria-pressed", "true");
|
||||
await expect(syntheticDislike).toHaveClass(/\bstyle-default-active\b/);
|
||||
await expect(syntheticDislike).not.toHaveClass(/\bstyle-text\b/);
|
||||
await expect
|
||||
.poll(() => syntheticButton.evaluate((button) => getComputedStyle(button).color))
|
||||
.toBe("rgb(62, 166, 255)");
|
||||
expect(await readStackGeometry()).toEqual(initialGeometry);
|
||||
|
||||
await syntheticButton.click();
|
||||
await expect.poll(() => backend.requestsFor("POST", "/interact/confirmVote").length).toBe(2);
|
||||
await expect(syntheticButton).toHaveAttribute("aria-pressed", "false");
|
||||
await expect(syntheticDislike).toHaveClass(/\bstyle-text\b/);
|
||||
await expect(syntheticDislike).not.toHaveClass(/\bstyle-default-active\b/);
|
||||
await expect.poll(() => syntheticButton.evaluate((button) => getComputedStyle(button).color)).toBe(neutralColor);
|
||||
expect(await readStackGeometry()).toEqual(initialGeometry);
|
||||
|
||||
expect(backend.blockedRequests).toEqual([]);
|
||||
expect(pageErrors).toEqual([]);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
const {
|
||||
CREDENTIAL_KEY,
|
||||
VIDEO_A,
|
||||
createFakeBackend,
|
||||
injectGeneratedUserscript,
|
||||
installGmEnvironment,
|
||||
installHermeticRoutes,
|
||||
openWatchFixture,
|
||||
} = require("./harness");
|
||||
|
||||
const EXISTING_CREDENTIALS = {
|
||||
userId: "ExistingUserscriptCredential000000000001",
|
||||
registrationConfirmed: true,
|
||||
};
|
||||
const BASE_COUNTS = { likes: 300, dislikes: 100 };
|
||||
const VIEWPORTS = [
|
||||
{ name: "wide", width: 1280, height: 720 },
|
||||
{ name: "narrow", width: 768, height: 720 },
|
||||
{ name: "mobile-sized", width: 390, height: 844 },
|
||||
];
|
||||
const TRANSITIONS = [
|
||||
{
|
||||
action: "like",
|
||||
initialState: "neutral",
|
||||
nextState: "liked",
|
||||
value: 1,
|
||||
likesDelta: 1,
|
||||
dislikesDelta: 0,
|
||||
},
|
||||
{
|
||||
action: "dislike",
|
||||
initialState: "neutral",
|
||||
nextState: "disliked",
|
||||
value: -1,
|
||||
likesDelta: 0,
|
||||
dislikesDelta: 1,
|
||||
},
|
||||
{
|
||||
action: "like",
|
||||
initialState: "liked",
|
||||
nextState: "neutral",
|
||||
value: 0,
|
||||
likesDelta: -1,
|
||||
dislikesDelta: 0,
|
||||
},
|
||||
{
|
||||
action: "dislike",
|
||||
initialState: "liked",
|
||||
nextState: "disliked",
|
||||
value: -1,
|
||||
likesDelta: -1,
|
||||
dislikesDelta: 1,
|
||||
},
|
||||
{
|
||||
action: "like",
|
||||
initialState: "disliked",
|
||||
nextState: "liked",
|
||||
value: 1,
|
||||
likesDelta: 1,
|
||||
dislikesDelta: -1,
|
||||
},
|
||||
{
|
||||
action: "dislike",
|
||||
initialState: "disliked",
|
||||
nextState: "neutral",
|
||||
value: 0,
|
||||
likesDelta: 0,
|
||||
dislikesDelta: -1,
|
||||
},
|
||||
];
|
||||
|
||||
function monitorRuntime(page) {
|
||||
const consoleErrors = [];
|
||||
const pageErrors = [];
|
||||
page.on("console", (message) => {
|
||||
if (message.type() === "error") consoleErrors.push(message.text());
|
||||
});
|
||||
page.on("pageerror", (error) => pageErrors.push(error.message));
|
||||
return { consoleErrors, pageErrors };
|
||||
}
|
||||
|
||||
async function launchVisualFixture({ context, page }, initialState) {
|
||||
const runtime = monitorRuntime(page);
|
||||
const backend = createFakeBackend({
|
||||
countsByVideo: { [VIDEO_A]: BASE_COUNTS },
|
||||
fixture: { initialState },
|
||||
});
|
||||
await installGmEnvironment(context, { [CREDENTIAL_KEY]: EXISTING_CREDENTIALS });
|
||||
await installHermeticRoutes(context, backend);
|
||||
await openWatchFixture(page, VIDEO_A);
|
||||
await injectGeneratedUserscript(page);
|
||||
|
||||
await expect(page.locator('[data-ryd-role="dislike"] #text')).toHaveText(String(BASE_COUNTS.dislikes));
|
||||
await expect(page.locator("#return-youtube-dislike-bar-container")).toBeVisible();
|
||||
return { backend, ...runtime };
|
||||
}
|
||||
|
||||
async function readVisualContract(page) {
|
||||
return page.evaluate(() => {
|
||||
const required = (selector) => {
|
||||
const element = document.querySelector(selector);
|
||||
if (!element) throw new Error(`Missing visual-contract element: ${selector}`);
|
||||
return element;
|
||||
};
|
||||
const rect = (element) => {
|
||||
const bounds = element.getBoundingClientRect();
|
||||
return {
|
||||
bottom: bounds.bottom,
|
||||
height: bounds.height,
|
||||
left: bounds.left,
|
||||
right: bounds.right,
|
||||
top: bounds.top,
|
||||
width: bounds.width,
|
||||
};
|
||||
};
|
||||
const box = (element) => {
|
||||
const style = getComputedStyle(element);
|
||||
return {
|
||||
margin: [style.marginTop, style.marginRight, style.marginBottom, style.marginLeft],
|
||||
padding: [style.paddingTop, style.paddingRight, style.paddingBottom, style.paddingLeft],
|
||||
};
|
||||
};
|
||||
const control = (role) => {
|
||||
const outer = required(`[data-ryd-role="${role}"]`);
|
||||
const button = required(`[data-ryd-role="${role}"] button`);
|
||||
const icon = required(`[data-ryd-role="${role}"] [data-fixture-icon]`);
|
||||
const text = required(`[data-ryd-role="${role}"] #text`);
|
||||
const buttonStyle = getComputedStyle(button);
|
||||
return {
|
||||
ariaPressed: button.getAttribute("aria-pressed"),
|
||||
backgroundColor: buttonStyle.backgroundColor,
|
||||
button: rect(button),
|
||||
buttonBox: box(button),
|
||||
classes: [...outer.classList].sort(),
|
||||
color: buttonStyle.color,
|
||||
icon: rect(icon),
|
||||
iconBox: box(icon),
|
||||
outer: rect(outer),
|
||||
outerBox: box(outer),
|
||||
text: rect(text),
|
||||
textBox: box(text),
|
||||
};
|
||||
};
|
||||
|
||||
const surface = required("#top-level-buttons-computed");
|
||||
const reactionGroup = required('[data-ryd-role="buttons"]');
|
||||
const topRow = required("#top-row");
|
||||
const wrapper = required(".ryd-tooltip");
|
||||
const barContainer = required("#return-youtube-dislike-bar-container");
|
||||
const bar = required("#return-youtube-dislike-bar");
|
||||
const tooltip = required("#ryd-dislike-tooltip");
|
||||
const reactionGroupStyle = getComputedStyle(reactionGroup);
|
||||
const surfaceStyle = getComputedStyle(surface);
|
||||
const topRowStyle = getComputedStyle(topRow);
|
||||
const barStyle = getComputedStyle(bar);
|
||||
const barContainerStyle = getComputedStyle(barContainer);
|
||||
const tooltipStyle = getComputedStyle(tooltip);
|
||||
const wrapperStyle = getComputedStyle(wrapper);
|
||||
|
||||
return {
|
||||
bar: rect(bar),
|
||||
barAppearance: {
|
||||
backgroundColor: barStyle.backgroundColor,
|
||||
borderRadius: barStyle.borderRadius,
|
||||
},
|
||||
barContainer: rect(barContainer),
|
||||
barContainerAppearance: {
|
||||
backgroundColor: barContainerStyle.backgroundColor,
|
||||
borderRadius: barContainerStyle.borderRadius,
|
||||
},
|
||||
buttons: rect(surface),
|
||||
buttonsBox: box(surface),
|
||||
buttonsGap: reactionGroupStyle.gap,
|
||||
buttonsPosition: surfaceStyle.position,
|
||||
dislike: control("dislike"),
|
||||
like: control("like"),
|
||||
ownership: {
|
||||
barOwnedByButtons: surface.contains(barContainer),
|
||||
tooltipDescribedBy: wrapper.getAttribute("aria-describedby"),
|
||||
tooltipOwnedByButtons: surface.contains(tooltip),
|
||||
tooltipRole: tooltip.getAttribute("role"),
|
||||
wrapperOwnedByButtons: wrapper.parentElement === surface,
|
||||
},
|
||||
reactionGroup: rect(reactionGroup),
|
||||
reactionGroupBox: box(reactionGroup),
|
||||
topRow: rect(topRow),
|
||||
topRowBox: box(topRow),
|
||||
topRowStyle: {
|
||||
borderBottomWidth: topRowStyle.borderBottomWidth,
|
||||
paddingBottom: topRowStyle.paddingBottom,
|
||||
},
|
||||
tooltipText: tooltip.textContent.replace(/\s+/g, " ").trim(),
|
||||
tooltipAppearance: {
|
||||
fontSize: tooltipStyle.fontSize,
|
||||
lineHeight: tooltipStyle.lineHeight,
|
||||
opacity: tooltipStyle.opacity,
|
||||
padding: box(tooltip).padding,
|
||||
position: tooltipStyle.position,
|
||||
visibility: tooltipStyle.visibility,
|
||||
},
|
||||
unique: {
|
||||
bars: document.querySelectorAll("#return-youtube-dislike-bar").length,
|
||||
containers: document.querySelectorAll("#return-youtube-dislike-bar-container").length,
|
||||
tooltips: document.querySelectorAll("#ryd-dislike-tooltip").length,
|
||||
wrappers: document.querySelectorAll(".ryd-tooltip").length,
|
||||
},
|
||||
viewport: { height: innerHeight, width: innerWidth },
|
||||
wrapper: rect(wrapper),
|
||||
wrapperAppearance: {
|
||||
box: box(wrapper),
|
||||
position: wrapperStyle.position,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function expectClose(actual, expected, precision = 5) {
|
||||
expect(actual).toBeCloseTo(expected, precision);
|
||||
}
|
||||
|
||||
function expectComputedColor(actual, expectedChannel) {
|
||||
const srgb = actual.match(/^color\(srgb\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)/);
|
||||
const channels = srgb
|
||||
? srgb.slice(1).map((channel) => Number(channel) * 255)
|
||||
: actual
|
||||
.match(/[\d.]+/g)
|
||||
?.slice(0, 3)
|
||||
.map(Number);
|
||||
expect(channels).toHaveLength(3);
|
||||
channels.forEach((channel) => expect(channel).toBeCloseTo(expectedChannel, 0));
|
||||
}
|
||||
|
||||
function expectControlGeometry(control) {
|
||||
expectClose(control.outer.width, 96);
|
||||
expectClose(control.outer.height, 36);
|
||||
expectClose(control.button.width, 96);
|
||||
expectClose(control.button.height, 36);
|
||||
expect(control.outerBox).toEqual({
|
||||
margin: ["0px", "0px", "0px", "0px"],
|
||||
padding: ["0px", "0px", "0px", "0px"],
|
||||
});
|
||||
expect(control.buttonBox).toEqual({
|
||||
margin: ["0px", "0px", "0px", "0px"],
|
||||
padding: ["0px", "12px", "0px", "12px"],
|
||||
});
|
||||
expect(control.iconBox).toEqual({
|
||||
margin: ["0px", "0px", "0px", "0px"],
|
||||
padding: ["0px", "0px", "0px", "0px"],
|
||||
});
|
||||
expect(control.textBox).toEqual({
|
||||
margin: ["0px", "0px", "0px", "0px"],
|
||||
padding: ["0px", "0px", "0px", "0px"],
|
||||
});
|
||||
expectClose(control.icon.width, 20);
|
||||
expectClose(control.icon.height, 20);
|
||||
expectClose(control.icon.top + control.icon.height / 2, control.button.top + control.button.height / 2);
|
||||
expectClose(control.text.top + control.text.height / 2, control.button.top + control.button.height / 2);
|
||||
expectClose(control.text.left - control.icon.right, 6);
|
||||
}
|
||||
|
||||
function expectPressedState(control, pressed) {
|
||||
expect(control.ariaPressed).toBe(String(pressed));
|
||||
expect(control.classes).toEqual([pressed ? "style-default-active" : "style-text"]);
|
||||
expect(control.backgroundColor).toBe(pressed ? "rgb(241, 241, 241)" : "rgb(39, 39, 39)");
|
||||
expect(control.color).toBe(pressed ? "rgb(15, 15, 15)" : "rgb(241, 241, 241)");
|
||||
}
|
||||
|
||||
function expectVisualState(snapshot, state, counts, viewport) {
|
||||
expect(snapshot.viewport).toEqual({ height: viewport.height, width: viewport.width });
|
||||
expect(snapshot.unique).toEqual({ bars: 1, containers: 1, tooltips: 1, wrappers: 1 });
|
||||
expect(snapshot.ownership).toEqual({
|
||||
barOwnedByButtons: true,
|
||||
tooltipDescribedBy: "ryd-dislike-tooltip",
|
||||
tooltipOwnedByButtons: true,
|
||||
tooltipRole: "tooltip",
|
||||
wrapperOwnedByButtons: true,
|
||||
});
|
||||
expect(snapshot.tooltipText).toBe(`${counts.likes} / ${counts.dislikes}`);
|
||||
expect(snapshot.buttonsGap).toBe("0px");
|
||||
expect(snapshot.buttonsPosition).toBe("relative");
|
||||
expect(snapshot.buttonsBox).toEqual({
|
||||
margin: ["0px", "0px", "0px", "0px"],
|
||||
padding: ["0px", "0px", "0px", "0px"],
|
||||
});
|
||||
expect(snapshot.reactionGroupBox).toEqual(snapshot.buttonsBox);
|
||||
expect(snapshot.topRowBox).toEqual({
|
||||
margin: ["0px", "0px", "0px", "0px"],
|
||||
padding: ["0px", "0px", "10px", "0px"],
|
||||
});
|
||||
expect(snapshot.topRowStyle).toEqual({ borderBottomWidth: "1px", paddingBottom: "10px" });
|
||||
expect(snapshot.wrapperAppearance).toEqual({
|
||||
box: {
|
||||
margin: ["0px", "0px", "0px", "0px"],
|
||||
padding: ["0px", "0px", "0px", "0px"],
|
||||
},
|
||||
position: "absolute",
|
||||
});
|
||||
expect(snapshot.barContainerAppearance.borderRadius).toBe("2px");
|
||||
expectComputedColor(snapshot.barContainerAppearance.backgroundColor, 139);
|
||||
expect(snapshot.barAppearance).toEqual({
|
||||
backgroundColor: "rgb(241, 241, 241)",
|
||||
borderRadius: "2px",
|
||||
});
|
||||
expect(snapshot.tooltipAppearance).toEqual({
|
||||
fontSize: "12px",
|
||||
lineHeight: "16px",
|
||||
opacity: "0",
|
||||
padding: ["6px", "8px", "6px", "8px"],
|
||||
position: "absolute",
|
||||
visibility: "hidden",
|
||||
});
|
||||
|
||||
expectControlGeometry(snapshot.like);
|
||||
expectControlGeometry(snapshot.dislike);
|
||||
expectPressedState(snapshot.like, state === "liked");
|
||||
expectPressedState(snapshot.dislike, state === "disliked");
|
||||
|
||||
expectClose(snapshot.like.outer.right, snapshot.dislike.outer.left);
|
||||
expectClose(snapshot.wrapper.left, snapshot.like.outer.left);
|
||||
expectClose(snapshot.wrapper.right, snapshot.dislike.outer.right);
|
||||
expectClose(snapshot.wrapper.width, snapshot.like.outer.width + snapshot.dislike.outer.width);
|
||||
expectClose(snapshot.wrapper.height, 2);
|
||||
expectClose(snapshot.wrapper.top - Math.max(snapshot.like.outer.bottom, snapshot.dislike.outer.bottom), 8);
|
||||
expectClose(snapshot.barContainer.width, snapshot.wrapper.width);
|
||||
expectClose(snapshot.barContainer.height, 2);
|
||||
expectClose(snapshot.bar.height, 2);
|
||||
expectClose(snapshot.bar.width / snapshot.barContainer.width, counts.likes / (counts.likes + counts.dislikes), 3);
|
||||
expect(snapshot.topRow.left).toBeGreaterThanOrEqual(0);
|
||||
expect(snapshot.topRow.right).toBeLessThanOrEqual(snapshot.viewport.width);
|
||||
expect(snapshot.wrapper.left).toBeGreaterThanOrEqual(snapshot.topRow.left);
|
||||
expect(snapshot.wrapper.right).toBeLessThanOrEqual(snapshot.topRow.right);
|
||||
}
|
||||
|
||||
function expectNoStructuralLayoutShift(before, after) {
|
||||
const paths = [
|
||||
["topRow"],
|
||||
["buttons"],
|
||||
["reactionGroup"],
|
||||
["wrapper"],
|
||||
["barContainer"],
|
||||
["like", "outer"],
|
||||
["like", "button"],
|
||||
["dislike", "outer"],
|
||||
["dislike", "button"],
|
||||
];
|
||||
const dimensions = ["top", "right", "bottom", "left", "width", "height"];
|
||||
for (const path of paths) {
|
||||
const beforeRect = path.reduce((value, key) => value[key], before);
|
||||
const afterRect = path.reduce((value, key) => value[key], after);
|
||||
for (const dimension of dimensions) {
|
||||
expect(
|
||||
Math.abs(afterRect[dimension] - beforeRect[dimension]),
|
||||
`${path.join(".")}.${dimension}`,
|
||||
).toBeLessThanOrEqual(0.25);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForBarRatio(page, counts) {
|
||||
const expectedRatio = counts.likes / (counts.likes + counts.dislikes);
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.locator("#return-youtube-dislike-bar").evaluate((bar) => {
|
||||
const container = bar.parentElement;
|
||||
return bar.getBoundingClientRect().width / container.getBoundingClientRect().width;
|
||||
}),
|
||||
)
|
||||
.toBeCloseTo(expectedRatio, 3);
|
||||
}
|
||||
|
||||
async function attachFailureScreenshot(testInfo, page, name) {
|
||||
let body;
|
||||
try {
|
||||
const scope = page.locator("#top-row");
|
||||
body = (await scope.count()) > 0 ? await scope.screenshot({ animations: "disabled" }) : await page.screenshot();
|
||||
} catch {
|
||||
body = await page.screenshot().catch(() => null);
|
||||
}
|
||||
if (body) {
|
||||
await testInfo.attach(`${name}-visual-contract-failure`, { body, contentType: "image/png" });
|
||||
}
|
||||
}
|
||||
|
||||
for (const viewport of VIEWPORTS) {
|
||||
test.describe(`${viewport.name} watch visual contract`, () => {
|
||||
for (const transition of TRANSITIONS) {
|
||||
const name = `${transition.initialState} + ${transition.action} -> ${transition.nextState}`;
|
||||
test(name, async ({ context, page }, testInfo) => {
|
||||
await page.setViewportSize({ width: viewport.width, height: viewport.height });
|
||||
try {
|
||||
const harness = await launchVisualFixture({ context, page }, transition.initialState);
|
||||
const before = await readVisualContract(page);
|
||||
expectVisualState(before, transition.initialState, BASE_COUNTS, viewport);
|
||||
|
||||
await page.locator(`[data-ryd-role="${transition.action}"] button`).click();
|
||||
await expect.poll(() => harness.backend.requestsFor("POST", "/interact/confirmVote").length).toBe(1);
|
||||
|
||||
const nextCounts = {
|
||||
likes: BASE_COUNTS.likes + transition.likesDelta,
|
||||
dislikes: BASE_COUNTS.dislikes + transition.dislikesDelta,
|
||||
};
|
||||
await waitForBarRatio(page, nextCounts);
|
||||
const after = await readVisualContract(page);
|
||||
expectVisualState(after, transition.nextState, nextCounts, viewport);
|
||||
expectNoStructuralLayoutShift(before, after);
|
||||
|
||||
expect(harness.backend.requestsFor("GET", "/votes")).toHaveLength(1);
|
||||
expect(harness.backend.requestsFor("POST", "/interact/vote")).toHaveLength(1);
|
||||
expect(harness.backend.requestsFor("POST", "/interact/vote")[0].body).toMatchObject({
|
||||
userId: EXISTING_CREDENTIALS.userId,
|
||||
value: transition.value,
|
||||
videoId: VIDEO_A,
|
||||
});
|
||||
expect(harness.backend.requestsFor("POST", "/interact/confirmVote")).toHaveLength(1);
|
||||
expect(harness.backend.blockedRequests).toEqual([]);
|
||||
expect(harness.consoleErrors).toEqual([]);
|
||||
expect(harness.pageErrors).toEqual([]);
|
||||
} catch (error) {
|
||||
await attachFailureScreenshot(testInfo, page, `${viewport.name}-${name}`);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
const {
|
||||
CREDENTIAL_KEY,
|
||||
VIDEO_A,
|
||||
createFakeBackend,
|
||||
forbidUnsafeHtmlSinks,
|
||||
injectGeneratedUserscript,
|
||||
installGmEnvironment,
|
||||
installHermeticRoutes,
|
||||
openShortsFixture,
|
||||
openWatchFixture,
|
||||
} = require("./harness");
|
||||
|
||||
const EXISTING_CREDENTIALS = {
|
||||
userId: "ExistingUserscriptCredential000000000001",
|
||||
registrationConfirmed: true,
|
||||
};
|
||||
|
||||
async function prepareGuardedFixture({ context, page }, openFixture) {
|
||||
const pageErrors = [];
|
||||
page.on("pageerror", (error) => pageErrors.push(error.message));
|
||||
|
||||
const backend = createFakeBackend();
|
||||
await installGmEnvironment(context, { [CREDENTIAL_KEY]: EXISTING_CREDENTIALS });
|
||||
await installHermeticRoutes(context, backend);
|
||||
await openFixture(page, VIDEO_A);
|
||||
await forbidUnsafeHtmlSinks(page);
|
||||
await injectGeneratedUserscript(page);
|
||||
|
||||
return { backend, pageErrors };
|
||||
}
|
||||
|
||||
async function expectNoUnsafeHtmlUsage(page, backend, pageErrors) {
|
||||
expect(await page.evaluate(() => globalThis.__rydUnsafeHtmlSinkCalls)).toEqual([]);
|
||||
expect(backend.blockedRequests).toEqual([]);
|
||||
expect(pageErrors).toEqual([]);
|
||||
}
|
||||
|
||||
test("watch ratio bar initializes when unsafe HTML sinks are forbidden", async ({ context, page }) => {
|
||||
const { backend, pageErrors } = await prepareGuardedFixture({ context, page }, openWatchFixture);
|
||||
|
||||
await expect(page.locator('[data-ryd-role="dislike"] #text')).toHaveText("25");
|
||||
expect(await page.evaluate(() => globalThis.__rydUnsafeHtmlSinkCalls)).toEqual([]);
|
||||
await expect(page.locator("#return-youtube-dislike-bar-container")).toBeVisible();
|
||||
await expect(page.locator("#return-youtube-dislike-bar")).toBeVisible();
|
||||
await expect(page.locator("#ryd-dislike-tooltip")).toContainText("100 / 25");
|
||||
await expectNoUnsafeHtmlUsage(page, backend, pageErrors);
|
||||
});
|
||||
|
||||
test("modern Shorts synthetic dislike initializes when unsafe HTML sinks are forbidden", async ({ context, page }) => {
|
||||
const { backend, pageErrors } = await prepareGuardedFixture({ context, page }, openShortsFixture);
|
||||
|
||||
const syntheticDislike = page.locator("[data-ryd-synthetic-shorts-dislike]");
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => ({
|
||||
initialized: Boolean(document.querySelector("[data-ryd-synthetic-shorts-dislike]")),
|
||||
unsafeHtmlSinkCalls: globalThis.__rydUnsafeHtmlSinkCalls,
|
||||
})),
|
||||
)
|
||||
.toEqual({ initialized: true, unsafeHtmlSinkCalls: [] });
|
||||
await expect(syntheticDislike).toBeVisible();
|
||||
await expect(syntheticDislike.locator("button")).toHaveAttribute("aria-pressed", "false");
|
||||
await expect(syntheticDislike.locator("#text")).toHaveText("25");
|
||||
await expect(syntheticDislike.locator("svg path")).toHaveCount(1);
|
||||
await expectNoUnsafeHtmlUsage(page, backend, pageErrors);
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* @jest-environment jsdom
|
||||
*/
|
||||
|
||||
const path = require("node:path");
|
||||
const {
|
||||
LiveRunDiagnostics,
|
||||
UNHANDLED_REJECTION_PREFIX,
|
||||
diagnosticApiUrl,
|
||||
readLivePageState,
|
||||
runLoggedStage,
|
||||
} = require("../e2e/live/live-diagnostics");
|
||||
|
||||
class FakeEmitter {
|
||||
constructor() {
|
||||
this.listeners = new Map();
|
||||
}
|
||||
|
||||
emit(event, value) {
|
||||
for (const listener of this.listeners.get(event) ?? []) listener(value);
|
||||
}
|
||||
|
||||
off(event, listener) {
|
||||
this.listeners.get(event)?.delete(listener);
|
||||
}
|
||||
|
||||
on(event, listener) {
|
||||
if (!this.listeners.has(event)) this.listeners.set(event, new Set());
|
||||
this.listeners.get(event).add(listener);
|
||||
}
|
||||
}
|
||||
|
||||
function visibleRect() {
|
||||
return { bottom: 100, height: 90, left: 10, right: 60, top: 10, width: 50 };
|
||||
}
|
||||
|
||||
describe("live interactive diagnostics", () => {
|
||||
test("captures runtime and Shorts ownership state without dumping page HTML", () => {
|
||||
document.documentElement.setAttribute("data-ryd-userscript-version", "3.2.0");
|
||||
document.body.innerHTML = `
|
||||
<ytd-reel-video-renderer video-id="abcdefghijk" is-active>
|
||||
<a href="/shorts/abcdefghijk">Short</a>
|
||||
<reel-action-bar-view-model>
|
||||
<like-button-view-model></like-button-view-model>
|
||||
<button-view-model data-ryd-synthetic-shorts-dislike data-ryd-video-id="abcdefghijk">
|
||||
<button aria-pressed="false"></button><span>123</span>
|
||||
</button-view-model>
|
||||
</reel-action-bar-view-model>
|
||||
</ytd-reel-video-renderer>
|
||||
`;
|
||||
for (const element of document.querySelectorAll("*")) element.getBoundingClientRect = visibleRect;
|
||||
|
||||
const state = readLivePageState();
|
||||
|
||||
expect(state.runtimeMarkers).toEqual({
|
||||
extension: null,
|
||||
extensionBuild: null,
|
||||
userscript: "3.2.0",
|
||||
userscriptBuild: null,
|
||||
});
|
||||
expect(state.renderers).toEqual([
|
||||
expect.objectContaining({ actionBars: 1, syntheticControls: 1, videoId: "abcdefghijk", visible: true }),
|
||||
]);
|
||||
expect(state.renderers[0].links).toEqual([{ href: "/shorts/abcdefghijk", visible: true }]);
|
||||
expect(state.actionBars).toEqual([
|
||||
expect.objectContaining({ nativeLikes: 1, syntheticControls: 1, videoId: "abcdefghijk", visible: true }),
|
||||
]);
|
||||
expect(state.syntheticControls).toEqual([
|
||||
expect.objectContaining({ ariaPressed: "false", text: "123", videoId: "abcdefghijk", visible: true }),
|
||||
]);
|
||||
expect(JSON.stringify(state)).not.toContain("outerHTML");
|
||||
});
|
||||
|
||||
test("records browser failures and redacted recent API traffic in the persisted snapshot", async () => {
|
||||
const context = new FakeEmitter();
|
||||
const page = new FakeEmitter();
|
||||
page.addInitScript = jest.fn().mockResolvedValue(undefined);
|
||||
page.evaluate = jest.fn(async (callback) =>
|
||||
callback.name === "readLivePageState" ? { runtimeMarkers: { userscript: "3.2.0" } } : undefined,
|
||||
);
|
||||
page.isClosed = jest.fn(() => false);
|
||||
page.url = jest.fn(() => "https://www.youtube.com/shorts/abcdefghijk");
|
||||
const fileSystem = { mkdirSync: jest.fn(), writeFileSync: jest.fn() };
|
||||
const log = jest.fn();
|
||||
const diagnostics = new LiveRunDiagnostics(page, context, {
|
||||
clock: () => new Date("2026-08-18T12:34:56.000Z"),
|
||||
fileSystem,
|
||||
log,
|
||||
outputDirectory: "diagnostics",
|
||||
runtime: "userscript",
|
||||
});
|
||||
await diagnostics.start();
|
||||
diagnostics.stageStarted("read-only.channel-to-shorts-and-next");
|
||||
diagnostics.checkpoint("ryd-votes-response.waiting", { videoId: "abcdefghijk" });
|
||||
|
||||
const request = {
|
||||
method: () => "GET",
|
||||
resourceType: () => "fetch",
|
||||
url: () => "https://returnyoutubedislikeapi.com/votes?videoId=abcdefghijk&userId=private-id",
|
||||
};
|
||||
context.emit("request", request);
|
||||
context.emit("response", { request: () => request, status: () => 200 });
|
||||
page.emit("pageerror", new Error("page exploded"));
|
||||
page.emit("console", {
|
||||
location: () => ({ lineNumber: 7, url: "https://www.youtube.com/shorts/abcdefghijk" }),
|
||||
text: () => `${UNHANDLED_REJECTION_PREFIX}promise exploded`,
|
||||
type: () => "error",
|
||||
});
|
||||
page.emit("console", {
|
||||
location: () => ({}),
|
||||
text: () => "console exploded",
|
||||
type: () => "error",
|
||||
});
|
||||
|
||||
const snapshotPath = await diagnostics.persistFailureSnapshot(new Error("scenario timed out"));
|
||||
diagnostics.stop();
|
||||
|
||||
expect(snapshotPath).toBe(path.join("diagnostics", "failure-2026-08-18T12-34-56-000Z.json"));
|
||||
expect(fileSystem.mkdirSync).toHaveBeenCalledWith("diagnostics", { recursive: true });
|
||||
const snapshot = JSON.parse(fileSystem.writeFileSync.mock.calls[0][1]);
|
||||
expect(snapshot.currentStage).toBe("read-only.channel-to-shorts-and-next");
|
||||
expect(snapshot.currentCheckpoint).toBe("ryd-votes-response.waiting");
|
||||
expect(snapshot.browserSignals.map(({ type }) => type)).toEqual([
|
||||
"pageerror",
|
||||
"unhandledrejection",
|
||||
"console.error",
|
||||
]);
|
||||
expect(snapshot.recentApiRequests).toEqual([
|
||||
expect.objectContaining({
|
||||
method: "GET",
|
||||
pathname: "/votes",
|
||||
query: { userId: "<redacted>", videoId: "abcdefghijk" },
|
||||
status: 200,
|
||||
}),
|
||||
]);
|
||||
expect(snapshot.pageState).toEqual({ runtimeMarkers: { userscript: "3.2.0" } });
|
||||
expect(snapshot.url).toBe("https://www.youtube.com/shorts/abcdefghijk");
|
||||
expect(context.listeners.get("request").size).toBe(0);
|
||||
expect(page.listeners.get("pageerror").size).toBe(0);
|
||||
});
|
||||
|
||||
test("logs stage completion and failure boundaries", async () => {
|
||||
const diagnostics = {
|
||||
stageCompleted: jest.fn(),
|
||||
stageFailed: jest.fn(),
|
||||
stageStarted: jest.fn(),
|
||||
};
|
||||
|
||||
await expect(runLoggedStage(diagnostics, "success", async () => 42)).resolves.toBe(42);
|
||||
const failure = new Error("failed");
|
||||
await expect(
|
||||
runLoggedStage(diagnostics, "failure", async () => {
|
||||
throw failure;
|
||||
}),
|
||||
).rejects.toBe(failure);
|
||||
|
||||
expect(diagnostics.stageStarted.mock.calls.map(([name]) => name)).toEqual(["success", "failure"]);
|
||||
expect(diagnostics.stageCompleted).toHaveBeenCalledWith("success", expect.any(Number));
|
||||
expect(diagnostics.stageFailed).toHaveBeenCalledWith("failure", expect.any(Number), failure);
|
||||
});
|
||||
|
||||
test("redacts identity and proof material while retaining diagnostic video IDs", () => {
|
||||
expect(
|
||||
diagnosticApiUrl(
|
||||
"https://returnyoutubedislikeapi.com/puzzle/registration?userId=private&videoId=abcdefghijk&solution=secret",
|
||||
),
|
||||
).toEqual({
|
||||
pathname: "/puzzle/registration",
|
||||
query: { solution: "<redacted>", userId: "<redacted>", videoId: "abcdefghijk" },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,237 @@
|
||||
const crypto = require("node:crypto");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const extensionPackage = require("../../../package.json");
|
||||
const userscriptVersion = require("../userscript-version.json");
|
||||
|
||||
const LIVE_VOTE_APPROVAL_WINDOW_SECONDS = 120;
|
||||
const LIVE_VOTE_APPROVALS_DIRECTORY = path.resolve(__dirname, "../../../test-results/live-youtube-vote-approvals");
|
||||
const DEFAULT_LIVE_NAV_CHANNEL_URL = "https://www.youtube.com/@SmashTrash";
|
||||
const DEFAULT_LIVE_NAV_SHORT = "iKQhN7omLM4";
|
||||
const DEFAULT_LIVE_SIDEBAR_HOPS = 3;
|
||||
const MAX_LIVE_SIDEBAR_HOPS = 10;
|
||||
const VIDEO_ID_PATTERN = /^[A-Za-z0-9_-]{11}$/;
|
||||
const LIVE_BUILD_ID_PATTERN = /^[a-f0-9]{32}$/;
|
||||
const SUPPORTED_RUNTIMES = new Set(["userscript", "extension"]);
|
||||
const LIVE_BUILD_MARKER_PATHS = {
|
||||
extension: path.resolve(__dirname, "../../combined/dist/chrome/live-build.json"),
|
||||
userscript: path.resolve(__dirname, "../../../test-results/live-build/userscript/live-build.json"),
|
||||
};
|
||||
|
||||
function requireValue(environment, name) {
|
||||
const value = environment[name]?.trim();
|
||||
if (!value) throw new Error(`${name} is required for the live YouTube smoke suite.`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function requireVideoId(environment, name) {
|
||||
const value = requireValue(environment, name);
|
||||
if (!VIDEO_ID_PATTERN.test(value)) {
|
||||
throw new Error(`${name} must be an 11-character YouTube video ID.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalVideoId(environment, name) {
|
||||
const value = environment[name]?.trim();
|
||||
if (!value) return null;
|
||||
if (!VIDEO_ID_PATTERN.test(value)) {
|
||||
throw new Error(`${name} must be an 11-character YouTube video ID when provided.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalBoundedInteger(environment, name, defaultValue, maximum) {
|
||||
const rawValue = environment[name]?.trim();
|
||||
if (!rawValue) return defaultValue;
|
||||
if (!/^\d+$/.test(rawValue)) {
|
||||
throw new Error(`${name} must be a whole number from 1 to ${maximum}.`);
|
||||
}
|
||||
|
||||
const value = Number(rawValue);
|
||||
if (!Number.isSafeInteger(value) || value < 1 || value > maximum) {
|
||||
throw new Error(`${name} must be a whole number from 1 to ${maximum}.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function requireChannelHandle(environment) {
|
||||
const value = requireValue(environment, "RYD_LIVE_EXPECTED_CHANNEL");
|
||||
if (!/^@[A-Za-z0-9._-]{3,100}$/.test(value)) {
|
||||
throw new Error("RYD_LIVE_EXPECTED_CHANNEL must be the public @handle of the signed-in YouTube test channel.");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function liveVoteApproval(runtime, videoId, unixSeconds) {
|
||||
return `${runtime}:${videoId}:${unixSeconds}`;
|
||||
}
|
||||
|
||||
function hasFreshVoteApproval(value, runtime, videoId, nowMilliseconds) {
|
||||
if (!value) return false;
|
||||
const [approvedRuntime, approvedVideoId, approvedAt, ...rest] = value.split(":");
|
||||
if (rest.length || approvedRuntime !== runtime || approvedVideoId !== videoId) return false;
|
||||
|
||||
const approvedAtSeconds = Number(approvedAt);
|
||||
if (!Number.isSafeInteger(approvedAtSeconds)) return false;
|
||||
const ageSeconds = Math.floor(nowMilliseconds / 1000) - approvedAtSeconds;
|
||||
return ageSeconds >= 0 && ageSeconds <= LIVE_VOTE_APPROVAL_WINDOW_SECONDS;
|
||||
}
|
||||
|
||||
function consumeLiveVoteApproval(
|
||||
value,
|
||||
runtime,
|
||||
videoId,
|
||||
{ nowMilliseconds = Date.now(), usedApprovalsDirectory } = {},
|
||||
) {
|
||||
if (!hasFreshVoteApproval(value, runtime, videoId, nowMilliseconds)) return false;
|
||||
|
||||
const directory = usedApprovalsDirectory || LIVE_VOTE_APPROVALS_DIRECTORY;
|
||||
fs.mkdirSync(directory, { recursive: true });
|
||||
const approvalHash = crypto.createHash("sha256").update(value).digest("hex");
|
||||
try {
|
||||
fs.writeFileSync(path.join(directory, approvalHash), `${runtime}:${videoId}\n`, { flag: "wx" });
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error.code === "EEXIST") return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function parsePlaylistUrl(value, watchVideoId) {
|
||||
let url;
|
||||
try {
|
||||
url = new URL(value);
|
||||
} catch {
|
||||
throw new Error("RYD_LIVE_PLAYLIST_URL must be a valid HTTPS YouTube watch URL.");
|
||||
}
|
||||
|
||||
if (
|
||||
url.protocol !== "https:" ||
|
||||
!["www.youtube.com", "youtube.com"].includes(url.hostname) ||
|
||||
url.pathname !== "/watch" ||
|
||||
url.searchParams.get("v") !== watchVideoId ||
|
||||
!url.searchParams.get("list")
|
||||
) {
|
||||
throw new Error(
|
||||
"RYD_LIVE_PLAYLIST_URL must be an HTTPS YouTube watch URL for RYD_LIVE_WATCH_A with a playlist ID.",
|
||||
);
|
||||
}
|
||||
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function parseChannelUrl(value) {
|
||||
let url;
|
||||
try {
|
||||
url = new URL(value);
|
||||
} catch {
|
||||
throw new Error("RYD_LIVE_NAV_CHANNEL_URL must be a valid HTTPS YouTube channel URL.");
|
||||
}
|
||||
|
||||
const isSafeChannelPath = /^\/@[A-Za-z0-9._-]{3,100}(?:\/(?:featured|shorts|videos))?\/?$/.test(url.pathname);
|
||||
if (
|
||||
url.protocol !== "https:" ||
|
||||
!["www.youtube.com", "youtube.com"].includes(url.hostname) ||
|
||||
url.port ||
|
||||
url.username ||
|
||||
url.password ||
|
||||
url.search ||
|
||||
url.hash ||
|
||||
!isSafeChannelPath
|
||||
) {
|
||||
throw new Error(
|
||||
"RYD_LIVE_NAV_CHANNEL_URL must be a plain HTTPS youtube.com /@handle channel, featured, Shorts, or videos URL.",
|
||||
);
|
||||
}
|
||||
|
||||
url.hostname = "www.youtube.com";
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function readExpectedBuildId(runtime, { markerPaths = LIVE_BUILD_MARKER_PATHS, readFileSync = fs.readFileSync } = {}) {
|
||||
const markerPath = markerPaths[runtime];
|
||||
let marker;
|
||||
try {
|
||||
marker = JSON.parse(readFileSync(markerPath, "utf8"));
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Cannot read the ${runtime} live-build marker at ${markerPath}. Build that live artifact before running the smoke.`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
if (!LIVE_BUILD_ID_PATTERN.test(marker?.buildId)) {
|
||||
throw new Error(`The ${runtime} live-build marker at ${markerPath} is malformed.`);
|
||||
}
|
||||
return marker.buildId;
|
||||
}
|
||||
|
||||
function readLiveOptions(
|
||||
environment = process.env,
|
||||
nowMilliseconds = Date.now(),
|
||||
{ readBuildId = readExpectedBuildId } = {},
|
||||
) {
|
||||
if (environment.RYD_LIVE_YOUTUBE !== "1") return null;
|
||||
|
||||
if (environment.RYD_LIVE_PRODUCTION_API !== "1") {
|
||||
throw new Error(
|
||||
"RYD_LIVE_PRODUCTION_API=1 is required because the installed runtime will contact the production RYD API.",
|
||||
);
|
||||
}
|
||||
|
||||
const runtime = requireValue(environment, "RYD_LIVE_RUNTIME");
|
||||
if (!SUPPORTED_RUNTIMES.has(runtime)) {
|
||||
throw new Error('RYD_LIVE_RUNTIME must be either "userscript" or "extension".');
|
||||
}
|
||||
|
||||
const watchA = requireVideoId(environment, "RYD_LIVE_WATCH_A");
|
||||
const watchB = requireVideoId(environment, "RYD_LIVE_WATCH_B");
|
||||
const short = requireVideoId(environment, "RYD_LIVE_SHORT");
|
||||
if (watchA === watchB) throw new Error("RYD_LIVE_WATCH_A and RYD_LIVE_WATCH_B must be different videos.");
|
||||
|
||||
const expectedVersion =
|
||||
environment.RYD_LIVE_EXPECTED_VERSION?.trim() ||
|
||||
(runtime === "userscript" ? userscriptVersion : extensionPackage.version);
|
||||
|
||||
return {
|
||||
cdpEndpoint: environment.RYD_CDP_ENDPOINT?.trim() || "chrome",
|
||||
expectedBuildId: readBuildId(runtime),
|
||||
expectedChannel: requireChannelHandle(environment),
|
||||
expectedVersion,
|
||||
navigation: {
|
||||
channelUrl: parseChannelUrl(environment.RYD_LIVE_NAV_CHANNEL_URL?.trim() || DEFAULT_LIVE_NAV_CHANNEL_URL),
|
||||
short: requireVideoId(
|
||||
{ RYD_LIVE_NAV_SHORT: environment.RYD_LIVE_NAV_SHORT?.trim() || DEFAULT_LIVE_NAV_SHORT },
|
||||
"RYD_LIVE_NAV_SHORT",
|
||||
),
|
||||
watch: optionalVideoId(environment, "RYD_LIVE_NAV_WATCH"),
|
||||
},
|
||||
playlistUrl: parsePlaylistUrl(requireValue(environment, "RYD_LIVE_PLAYLIST_URL"), watchA),
|
||||
productionApiApproved: true,
|
||||
runtime,
|
||||
sidebar: {
|
||||
hopCount: optionalBoundedInteger(
|
||||
environment,
|
||||
"RYD_LIVE_SIDEBAR_HOPS",
|
||||
DEFAULT_LIVE_SIDEBAR_HOPS,
|
||||
MAX_LIVE_SIDEBAR_HOPS,
|
||||
),
|
||||
},
|
||||
short,
|
||||
watchA,
|
||||
watchB,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_LIVE_NAV_CHANNEL_URL,
|
||||
DEFAULT_LIVE_NAV_SHORT,
|
||||
DEFAULT_LIVE_SIDEBAR_HOPS,
|
||||
LIVE_VOTE_APPROVALS_DIRECTORY,
|
||||
LIVE_VOTE_APPROVAL_WINDOW_SECONDS,
|
||||
consumeLiveVoteApproval,
|
||||
hasFreshVoteApproval,
|
||||
liveVoteApproval,
|
||||
readExpectedBuildId,
|
||||
readLiveOptions,
|
||||
};
|
||||
@@ -0,0 +1,223 @@
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const {
|
||||
DEFAULT_LIVE_NAV_CHANNEL_URL,
|
||||
DEFAULT_LIVE_NAV_SHORT,
|
||||
DEFAULT_LIVE_SIDEBAR_HOPS,
|
||||
LIVE_VOTE_APPROVALS_DIRECTORY,
|
||||
LIVE_VOTE_APPROVAL_WINDOW_SECONDS,
|
||||
consumeLiveVoteApproval,
|
||||
hasFreshVoteApproval,
|
||||
liveVoteApproval,
|
||||
readExpectedBuildId,
|
||||
readLiveOptions: readLiveOptionsFromEnvironment,
|
||||
} = require("./live-options");
|
||||
|
||||
const NOW = Date.parse("2026-08-08T12:00:00.000Z");
|
||||
const EXPECTED_BUILD_ID = "0123456789abcdef0123456789abcdef";
|
||||
|
||||
const VALID_ENVIRONMENT = {
|
||||
RYD_LIVE_YOUTUBE: "1",
|
||||
RYD_LIVE_PRODUCTION_API: "1",
|
||||
RYD_LIVE_RUNTIME: "userscript",
|
||||
RYD_LIVE_WATCH_A: "abcdefghijk",
|
||||
RYD_LIVE_WATCH_B: "zyxwvutsrqp",
|
||||
RYD_LIVE_SHORT: "shortsabcde",
|
||||
RYD_LIVE_PLAYLIST_URL: "https://www.youtube.com/watch?v=abcdefghijk&list=PL-test",
|
||||
RYD_LIVE_EXPECTED_CHANNEL: "@ryd-test",
|
||||
};
|
||||
|
||||
function readLiveOptions(environment, nowMilliseconds = NOW) {
|
||||
return readLiveOptionsFromEnvironment(environment, nowMilliseconds, {
|
||||
readBuildId: () => EXPECTED_BUILD_ID,
|
||||
});
|
||||
}
|
||||
|
||||
describe("live YouTube options", () => {
|
||||
test("reads the exact generated build ID from the selected runtime marker", () => {
|
||||
const readFileSync = jest.fn(() => JSON.stringify({ buildId: EXPECTED_BUILD_ID }));
|
||||
|
||||
expect(
|
||||
readExpectedBuildId("userscript", {
|
||||
markerPaths: { userscript: "owned-live-build.json" },
|
||||
readFileSync,
|
||||
}),
|
||||
).toBe(EXPECTED_BUILD_ID);
|
||||
expect(readFileSync).toHaveBeenCalledWith("owned-live-build.json", "utf8");
|
||||
});
|
||||
|
||||
test.each([
|
||||
[
|
||||
"missing",
|
||||
() => {
|
||||
throw Object.assign(new Error("missing"), { code: "ENOENT" });
|
||||
},
|
||||
/Cannot read/,
|
||||
],
|
||||
["invalid JSON", () => "not JSON", /Cannot read/],
|
||||
["malformed ID", () => JSON.stringify({ buildId: "stale" }), /is malformed/],
|
||||
])("rejects a %s generated live-build marker", (_label, readFileSync, expectedMessage) => {
|
||||
expect(() =>
|
||||
readExpectedBuildId("extension", {
|
||||
markerPaths: { extension: "owned-live-build.json" },
|
||||
readFileSync,
|
||||
}),
|
||||
).toThrow(expectedMessage);
|
||||
});
|
||||
|
||||
test("stores consumed vote approvals outside Playwright's cleaned output directory", () => {
|
||||
const playwrightOutputDirectory = path.resolve(__dirname, "../../../test-results/live-youtube");
|
||||
expect(LIVE_VOTE_APPROVALS_DIRECTORY).not.toBe(playwrightOutputDirectory);
|
||||
expect(LIVE_VOTE_APPROVALS_DIRECTORY.startsWith(`${playwrightOutputDirectory}${path.sep}`)).toBe(false);
|
||||
});
|
||||
|
||||
test("stays disabled unless explicitly opted in", () => {
|
||||
expect(readLiveOptions({})).toBeNull();
|
||||
});
|
||||
|
||||
test("requires production API acknowledgement", () => {
|
||||
expect(() => readLiveOptions({ ...VALID_ENVIRONMENT, RYD_LIVE_PRODUCTION_API: undefined })).toThrow(
|
||||
"RYD_LIVE_PRODUCTION_API=1",
|
||||
);
|
||||
});
|
||||
|
||||
test.each(["", "tampermonkey", "chrome"])("rejects unsupported runtime %p", (runtime) => {
|
||||
expect(() => readLiveOptions({ ...VALID_ENVIRONMENT, RYD_LIVE_RUNTIME: runtime })).toThrow(/RYD_LIVE_RUNTIME/);
|
||||
});
|
||||
|
||||
test("requires distinct valid video IDs", () => {
|
||||
expect(() => readLiveOptions({ ...VALID_ENVIRONMENT, RYD_LIVE_SHORT: "too-short" })).toThrow(
|
||||
"11-character YouTube video ID",
|
||||
);
|
||||
expect(() =>
|
||||
readLiveOptions({
|
||||
...VALID_ENVIRONMENT,
|
||||
RYD_LIVE_WATCH_B: VALID_ENVIRONMENT.RYD_LIVE_WATCH_A,
|
||||
}),
|
||||
).toThrow("must be different videos");
|
||||
});
|
||||
|
||||
test("requires a playlist URL anchored at watch A", () => {
|
||||
expect(() =>
|
||||
readLiveOptions({
|
||||
...VALID_ENVIRONMENT,
|
||||
RYD_LIVE_PLAYLIST_URL: "https://www.youtube.com/watch?v=zyxwvutsrqp&list=PL-test",
|
||||
}),
|
||||
).toThrow("for RYD_LIVE_WATCH_A");
|
||||
});
|
||||
|
||||
test("requires the expected signed-in channel handle", () => {
|
||||
expect(() => readLiveOptions({ ...VALID_ENVIRONMENT, RYD_LIVE_EXPECTED_CHANNEL: "not-a-handle" })).toThrow(
|
||||
"public @handle",
|
||||
);
|
||||
});
|
||||
|
||||
test.each(["userscript", "extension"])("accepts the %s runtime", (runtime) => {
|
||||
expect(readLiveOptions({ ...VALID_ENVIRONMENT, RYD_LIVE_RUNTIME: runtime })).toMatchObject({
|
||||
cdpEndpoint: "chrome",
|
||||
expectedBuildId: EXPECTED_BUILD_ID,
|
||||
navigation: {
|
||||
channelUrl: DEFAULT_LIVE_NAV_CHANNEL_URL,
|
||||
short: DEFAULT_LIVE_NAV_SHORT,
|
||||
watch: null,
|
||||
},
|
||||
runtime,
|
||||
sidebar: { hopCount: DEFAULT_LIVE_SIDEBAR_HOPS },
|
||||
short: "shortsabcde",
|
||||
watchA: "abcdefghijk",
|
||||
watchB: "zyxwvutsrqp",
|
||||
});
|
||||
});
|
||||
|
||||
test("takes the exact build ID from the generated marker and ignores environment attempts to bless a stale build", () => {
|
||||
const readBuildId = jest.fn(() => EXPECTED_BUILD_ID);
|
||||
const result = readLiveOptionsFromEnvironment(
|
||||
{ ...VALID_ENVIRONMENT, RYD_LIVE_EXPECTED_BUILD_ID: "f".repeat(32) },
|
||||
NOW,
|
||||
{ readBuildId },
|
||||
);
|
||||
|
||||
expect(readBuildId).toHaveBeenCalledWith("userscript");
|
||||
expect(result.expectedBuildId).toBe(EXPECTED_BUILD_ID);
|
||||
});
|
||||
|
||||
test("accepts a bounded sidebar stress hop count", () => {
|
||||
expect(readLiveOptions({ ...VALID_ENVIRONMENT, RYD_LIVE_SIDEBAR_HOPS: "5" })).toMatchObject({
|
||||
sidebar: { hopCount: 5 },
|
||||
});
|
||||
});
|
||||
|
||||
test.each(["0", "11", "1.5", "three", "-1"])("rejects invalid sidebar stress hop count %p", (hopCount) => {
|
||||
expect(() => readLiveOptions({ ...VALID_ENVIRONMENT, RYD_LIVE_SIDEBAR_HOPS: hopCount })).toThrow(
|
||||
/RYD_LIVE_SIDEBAR_HOPS.*whole number from 1 to 10/,
|
||||
);
|
||||
});
|
||||
|
||||
test("accepts an exact, safely scoped channel-navigation dataset", () => {
|
||||
expect(
|
||||
readLiveOptions({
|
||||
...VALID_ENVIRONMENT,
|
||||
RYD_LIVE_NAV_CHANNEL_URL: "https://youtube.com/@ryd-test/shorts",
|
||||
RYD_LIVE_NAV_SHORT: "navshort001",
|
||||
RYD_LIVE_NAV_WATCH: "navwatch001",
|
||||
}),
|
||||
).toMatchObject({
|
||||
navigation: {
|
||||
channelUrl: "https://www.youtube.com/@ryd-test/shorts",
|
||||
short: "navshort001",
|
||||
watch: "navwatch001",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test.each([
|
||||
"http://www.youtube.com/@SmashTrash",
|
||||
"https://example.com/@SmashTrash",
|
||||
"https://www.youtube.com.evil.example/@SmashTrash",
|
||||
"https://www.youtube.com/channel/UC-not-a-handle",
|
||||
"https://www.youtube.com/@SmashTrash/playlists",
|
||||
"https://www.youtube.com/@SmashTrash?app=desktop",
|
||||
])("rejects unsafe or non-deterministic navigation channel URL %p", (channelUrl) => {
|
||||
expect(() => readLiveOptions({ ...VALID_ENVIRONMENT, RYD_LIVE_NAV_CHANNEL_URL: channelUrl })).toThrow(
|
||||
/RYD_LIVE_NAV_CHANNEL_URL/,
|
||||
);
|
||||
});
|
||||
|
||||
test.each(["RYD_LIVE_NAV_SHORT", "RYD_LIVE_NAV_WATCH"])("validates optional navigation ID %s", (name) => {
|
||||
expect(() => readLiveOptions({ ...VALID_ENVIRONMENT, [name]: "invalid" })).toThrow("11-character YouTube video ID");
|
||||
});
|
||||
|
||||
test("accepts only a fresh runtime-and-video-specific vote approval", () => {
|
||||
const nowSeconds = Math.floor(NOW / 1000);
|
||||
const validApproval = liveVoteApproval("userscript", VALID_ENVIRONMENT.RYD_LIVE_WATCH_B, nowSeconds);
|
||||
expect(hasFreshVoteApproval(validApproval, "userscript", VALID_ENVIRONMENT.RYD_LIVE_WATCH_B, NOW)).toBe(true);
|
||||
|
||||
for (const approval of [
|
||||
liveVoteApproval("extension", VALID_ENVIRONMENT.RYD_LIVE_WATCH_B, nowSeconds),
|
||||
liveVoteApproval("userscript", VALID_ENVIRONMENT.RYD_LIVE_WATCH_A, nowSeconds),
|
||||
liveVoteApproval(
|
||||
"userscript",
|
||||
VALID_ENVIRONMENT.RYD_LIVE_WATCH_B,
|
||||
nowSeconds - LIVE_VOTE_APPROVAL_WINDOW_SECONDS - 1,
|
||||
),
|
||||
]) {
|
||||
expect(hasFreshVoteApproval(approval, "userscript", VALID_ENVIRONMENT.RYD_LIVE_WATCH_B, NOW)).toBe(false);
|
||||
}
|
||||
|
||||
const futureApproval = liveVoteApproval("userscript", VALID_ENVIRONMENT.RYD_LIVE_WATCH_B, nowSeconds + 1);
|
||||
expect(hasFreshVoteApproval(futureApproval, "userscript", VALID_ENVIRONMENT.RYD_LIVE_WATCH_B, NOW)).toBe(false);
|
||||
});
|
||||
|
||||
test("consumes a fresh vote approval only once", () => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "ryd-live-approval-"));
|
||||
const approval = liveVoteApproval("userscript", VALID_ENVIRONMENT.RYD_LIVE_WATCH_B, Math.floor(NOW / 1000));
|
||||
try {
|
||||
const settings = { nowMilliseconds: NOW, usedApprovalsDirectory: directory };
|
||||
expect(consumeLiveVoteApproval(approval, "userscript", VALID_ENVIRONMENT.RYD_LIVE_WATCH_B, settings)).toBe(true);
|
||||
expect(consumeLiveVoteApproval(approval, "userscript", VALID_ENVIRONMENT.RYD_LIVE_WATCH_B, settings)).toBe(false);
|
||||
} finally {
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
const { LiveYoutubeDriver } = require("../e2e/live/live-youtube-driver");
|
||||
|
||||
class FakeBrowserContext {
|
||||
constructor() {
|
||||
this.listeners = new Map();
|
||||
this.routes = [];
|
||||
this.routeCalls = 0;
|
||||
this.unrouteCalls = 0;
|
||||
}
|
||||
|
||||
on(event, handler) {
|
||||
if (!this.listeners.has(event)) this.listeners.set(event, new Set());
|
||||
this.listeners.get(event).add(handler);
|
||||
}
|
||||
|
||||
off(event, handler) {
|
||||
this.listeners.get(event)?.delete(handler);
|
||||
}
|
||||
|
||||
async route(matcher, handler) {
|
||||
this.routeCalls += 1;
|
||||
this.routes.push({ handler, matcher });
|
||||
}
|
||||
|
||||
async unroute(matcher, handler) {
|
||||
this.unrouteCalls += 1;
|
||||
this.routes = this.routes.filter((route) => route.matcher !== matcher || route.handler !== handler);
|
||||
}
|
||||
|
||||
async dispatch(request) {
|
||||
for (const listener of this.listeners.get("request") ?? []) listener(request);
|
||||
|
||||
const route = {
|
||||
abort: jest.fn().mockResolvedValue(undefined),
|
||||
fallback: jest.fn().mockResolvedValue(undefined),
|
||||
request: () => request,
|
||||
};
|
||||
for (const registered of [...this.routes].reverse()) {
|
||||
if (registered.matcher(new URL(request.url()))) {
|
||||
await registered.handler(route);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return route;
|
||||
}
|
||||
}
|
||||
|
||||
function createDriver(context) {
|
||||
const page = {
|
||||
setDefaultNavigationTimeout: jest.fn(),
|
||||
setDefaultTimeout: jest.fn(),
|
||||
};
|
||||
return new LiveYoutubeDriver(page, context);
|
||||
}
|
||||
|
||||
function request(method, url) {
|
||||
return {
|
||||
frame: jest.fn(() => {
|
||||
throw new Error("Service-worker requests do not have a frame");
|
||||
}),
|
||||
method: () => method,
|
||||
url: () => url,
|
||||
};
|
||||
}
|
||||
|
||||
describe("live read-only production-interaction guard", () => {
|
||||
test("installs the route first, aborts a frame-less interaction POST, and reports the attempt", async () => {
|
||||
const context = new FakeBrowserContext();
|
||||
const driver = createDriver(context);
|
||||
const frameLessRequest = request("POST", "https://returnyoutubedislikeapi.com/interact/vote?source=service-worker");
|
||||
let interceptedRoute;
|
||||
|
||||
await expect(
|
||||
driver.withNoProductionInteractions(async () => {
|
||||
expect(context.routeCalls).toBe(1);
|
||||
interceptedRoute = await context.dispatch(frameLessRequest);
|
||||
}),
|
||||
).rejects.toThrow("attempted a production interaction");
|
||||
|
||||
expect(interceptedRoute.abort).toHaveBeenCalledWith("blockedbyclient");
|
||||
expect(interceptedRoute.fallback).not.toHaveBeenCalled();
|
||||
expect(frameLessRequest.frame).not.toHaveBeenCalled();
|
||||
expect(context.unrouteCalls).toBe(1);
|
||||
expect(context.routes).toEqual([]);
|
||||
});
|
||||
|
||||
test("shares one deny route across nested read-only guards", async () => {
|
||||
const context = new FakeBrowserContext();
|
||||
const driver = createDriver(context);
|
||||
const frameLessRequest = request("POST", "https://returnyoutubedislikeapi.com/interact/confirmVote");
|
||||
|
||||
await expect(
|
||||
driver.withNoProductionInteractions(() =>
|
||||
driver.withNoProductionInteractions(async () => {
|
||||
await context.dispatch(frameLessRequest);
|
||||
}),
|
||||
),
|
||||
).rejects.toThrow("attempted a production interaction");
|
||||
|
||||
expect(context.routeCalls).toBe(1);
|
||||
expect(context.unrouteCalls).toBe(1);
|
||||
expect(frameLessRequest.frame).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("allows non-interaction traffic and removes the route after success", async () => {
|
||||
const context = new FakeBrowserContext();
|
||||
const driver = createDriver(context);
|
||||
const result = await driver.withNoProductionInteractions(async () => {
|
||||
const unrelatedRoute = await context.dispatch(request("POST", "https://www.youtube.com/youtubei/v1/player"));
|
||||
expect(unrelatedRoute.abort).not.toHaveBeenCalled();
|
||||
return "complete";
|
||||
});
|
||||
|
||||
expect(result).toBe("complete");
|
||||
expect(context.routeCalls).toBe(1);
|
||||
expect(context.unrouteCalls).toBe(1);
|
||||
expect(context.routes).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,649 @@
|
||||
const path = require("node:path");
|
||||
const {
|
||||
RESPONSIVE_VIEWPORTS,
|
||||
runChannelShortsNavigationScenario,
|
||||
runProductionReactionMatrixScenario,
|
||||
runReactionCycle,
|
||||
runResponsiveVisualScenario,
|
||||
runSidebarStressScenario,
|
||||
} = require("../e2e/live/live-scenarios");
|
||||
|
||||
const EXPECTED_ACTIONS = {
|
||||
neutral: ["like", "like", "dislike", "like", "dislike", "dislike"],
|
||||
liked: ["like", "dislike", "like", "dislike", "dislike", "like"],
|
||||
disliked: ["like", "dislike", "dislike", "like", "like", "dislike"],
|
||||
};
|
||||
|
||||
function nextState(state, action) {
|
||||
if (action === "like") return state === "liked" ? "neutral" : "liked";
|
||||
return state === "disliked" ? "neutral" : "disliked";
|
||||
}
|
||||
|
||||
function valueForState(state) {
|
||||
if (state === "liked") return 1;
|
||||
if (state === "disliked") return -1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function createReactionHarness(
|
||||
initialState,
|
||||
{
|
||||
clickThrowTransition = null,
|
||||
failHandshake = null,
|
||||
failHandshakes = failHandshake === null ? [] : [failHandshake],
|
||||
failedVoteUserId = "shared-user-id",
|
||||
rollbackOnClickThrow = false,
|
||||
rollbackOnHandshakeFailure = false,
|
||||
rollbackOnStateWaitFailure = false,
|
||||
stateWaitFailTransition = null,
|
||||
} = {},
|
||||
) {
|
||||
const events = [];
|
||||
let currentState = initialState;
|
||||
let currentVideo = null;
|
||||
let handshakeNumber = 0;
|
||||
let stateWaitNumber = 0;
|
||||
const failedHandshakeNumbers = new Set(failHandshakes);
|
||||
|
||||
const driver = {
|
||||
assertCurrentVideo: jest.fn((videoId) => expect(currentVideo).toBe(videoId)),
|
||||
assertRuntime: jest.fn(),
|
||||
assertSignedIn: jest.fn(),
|
||||
clickAction: jest.fn(async (videoId, action) => {
|
||||
expect(currentVideo).toBe(videoId);
|
||||
currentState = nextState(currentState, action);
|
||||
events.push({ action, state: currentState });
|
||||
if (events.length === clickThrowTransition) {
|
||||
if (rollbackOnClickThrow) currentState = initialState;
|
||||
throw new Error("simulated post-dispatch click failure");
|
||||
}
|
||||
}),
|
||||
openShort: jest.fn(async (videoId) => {
|
||||
currentVideo = videoId;
|
||||
}),
|
||||
openWatch: jest.fn(async (videoId) => {
|
||||
currentVideo = videoId;
|
||||
}),
|
||||
readReactionState: jest.fn(async () => currentState),
|
||||
waitForDislikeText: jest.fn(async () => "123"),
|
||||
waitForReactionState: jest.fn(async (expected) => {
|
||||
stateWaitNumber += 1;
|
||||
if (stateWaitNumber === stateWaitFailTransition) {
|
||||
if (rollbackOnStateWaitFailure) currentState = initialState;
|
||||
throw new Error("simulated post-click state-wait failure");
|
||||
}
|
||||
expect(currentState).toBe(expected);
|
||||
}),
|
||||
};
|
||||
|
||||
const recorder = {
|
||||
mark: jest.fn(() => events.length),
|
||||
waitForHandshake: jest.fn(async (value) => {
|
||||
handshakeNumber += 1;
|
||||
expect(value).toBe(valueForState(currentState));
|
||||
if (failedHandshakeNumbers.has(handshakeNumber)) {
|
||||
if (rollbackOnHandshakeFailure) currentState = initialState;
|
||||
throw new Error("simulated handshake failure");
|
||||
}
|
||||
return "shared-user-id";
|
||||
}),
|
||||
voteUserId: jest.fn(() => failedVoteUserId),
|
||||
};
|
||||
|
||||
return {
|
||||
driver,
|
||||
events,
|
||||
getState: () => currentState,
|
||||
recorder,
|
||||
};
|
||||
}
|
||||
|
||||
const OPTIONS = {
|
||||
expectedBuildId: "0123456789abcdef0123456789abcdef",
|
||||
expectedChannel: "@ryd-test",
|
||||
expectedVersion: "3.2.0",
|
||||
runtime: "userscript",
|
||||
};
|
||||
|
||||
describe("live Shorts navigation stabilization", () => {
|
||||
test("pauses the next Short only after its current control has rendered", async () => {
|
||||
const events = [];
|
||||
const driver = {
|
||||
assertCurrentShortsControl: jest.fn(async (videoId) => {
|
||||
events.push(`control:${videoId}`);
|
||||
return { count: videoId === "abcdefghijk" ? "100" : "200", synthetic: true, videoId };
|
||||
}),
|
||||
assertRuntime: jest.fn(),
|
||||
assertSignedIn: jest.fn(),
|
||||
navigateFromColdChannelToShort: jest.fn(async () => events.push("channel-navigation")),
|
||||
navigateToNextShort: jest.fn(async () => {
|
||||
events.push("next-navigation");
|
||||
return "lmnopqrstuv";
|
||||
}),
|
||||
pausePlayback: jest.fn(async () => events.push("pause")),
|
||||
withNoProductionInteractions: jest.fn(async (action) => action()),
|
||||
};
|
||||
const options = {
|
||||
...OPTIONS,
|
||||
navigation: {
|
||||
channelUrl: "https://www.youtube.com/@SmashTrash",
|
||||
short: "abcdefghijk",
|
||||
},
|
||||
};
|
||||
|
||||
await expect(runChannelShortsNavigationScenario(driver, options)).resolves.toEqual({
|
||||
initial: { count: "100", synthetic: true, videoId: "abcdefghijk" },
|
||||
next: { count: "200", synthetic: true, videoId: "lmnopqrstuv" },
|
||||
});
|
||||
|
||||
expect(events).toEqual([
|
||||
"channel-navigation",
|
||||
"control:abcdefghijk",
|
||||
"next-navigation",
|
||||
"control:lmnopqrstuv",
|
||||
"pause",
|
||||
]);
|
||||
expect(driver.pausePlayback).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("live watch sidebar stress scenario", () => {
|
||||
test.each(["userscript", "extension"])(
|
||||
"takes consecutive unvisited related links and soaks one current %s bar after each exact response",
|
||||
async (runtime) => {
|
||||
const hopVideoIds = ["hopvideo001", "hopvideo002", "hopvideo003"];
|
||||
const counts = ["101", "202", "303"];
|
||||
let currentVideoId = null;
|
||||
let hopIndex = 0;
|
||||
const driver = {
|
||||
assertCurrentVideo: jest.fn((videoId) => expect(currentVideoId).toBe(videoId)),
|
||||
assertRuntime: jest.fn(),
|
||||
assertSignedIn: jest.fn(),
|
||||
captureWatchRatioVisual: jest.fn(async (_runtime, screenshotPath) => ({
|
||||
count: counts[hopIndex - 1],
|
||||
screenshotPath,
|
||||
})),
|
||||
navigateToRelatedWatch: jest.fn(async (excludedVideoIds) => {
|
||||
expect(excludedVideoIds).toEqual(["abcdefghijk", ...hopVideoIds.slice(0, hopIndex)]);
|
||||
const videoId = hopVideoIds[hopIndex];
|
||||
const dislikes = (hopIndex + 1) * 1_000;
|
||||
hopIndex += 1;
|
||||
currentVideoId = videoId;
|
||||
return { body: { dislikes }, videoId };
|
||||
}),
|
||||
openWatch: jest.fn(async (videoId) => {
|
||||
currentVideoId = videoId;
|
||||
}),
|
||||
soakWatchRatioVisual: jest.fn(async (_runtime, settings) => ({
|
||||
count: settings.expectedCount,
|
||||
durationMs: settings.durationMs,
|
||||
sampleCount: 9,
|
||||
videoId: settings.videoId,
|
||||
})),
|
||||
withNoProductionInteractions: jest.fn(async (action) => action()),
|
||||
};
|
||||
const makeDirectory = jest.fn();
|
||||
const options = { ...OPTIONS, runtime, sidebar: { hopCount: 3 }, watchA: "abcdefghijk" };
|
||||
|
||||
const result = await runSidebarStressScenario(driver, options, {
|
||||
makeDirectory,
|
||||
outputDirectory: "sidebar-evidence",
|
||||
soakDurationMs: 25,
|
||||
});
|
||||
|
||||
expect(makeDirectory).toHaveBeenCalledWith("sidebar-evidence");
|
||||
expect(driver.openWatch).toHaveBeenCalledWith(options.watchA);
|
||||
expect(driver.navigateToRelatedWatch).toHaveBeenCalledTimes(3);
|
||||
expect(driver.captureWatchRatioVisual.mock.calls).toEqual(
|
||||
hopVideoIds.map((_videoId, index) => [
|
||||
runtime,
|
||||
path.join("sidebar-evidence", `${runtime}-sidebar-hop-0${index + 1}.png`),
|
||||
{ presenceTimeoutMs: 1_000 },
|
||||
]),
|
||||
);
|
||||
expect(driver.soakWatchRatioVisual.mock.calls).toEqual(
|
||||
hopVideoIds.map((videoId, index) => [runtime, { durationMs: 25, expectedCount: counts[index], videoId }]),
|
||||
);
|
||||
expect(driver.assertRuntime).toHaveBeenCalledTimes(4);
|
||||
expect(driver.assertSignedIn).toHaveBeenCalledTimes(4);
|
||||
expect(driver.withNoProductionInteractions).toHaveBeenCalledTimes(1);
|
||||
expect(result).toEqual({
|
||||
hopCount: 3,
|
||||
hops: hopVideoIds.map((videoId, index) => ({
|
||||
apiDislikes: (index + 1) * 1_000,
|
||||
count: counts[index],
|
||||
readyLatencyMs: expect.any(Number),
|
||||
readyTimeoutMs: 1_000,
|
||||
screenshotPath: path.join("sidebar-evidence", `${runtime}-sidebar-hop-0${index + 1}.png`),
|
||||
soak: {
|
||||
count: counts[index],
|
||||
durationMs: 25,
|
||||
sampleCount: 9,
|
||||
videoId,
|
||||
},
|
||||
videoId,
|
||||
})),
|
||||
outputDirectory: "sidebar-evidence",
|
||||
startVideoId: options.watchA,
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("live reaction cycle", () => {
|
||||
test.each(["neutral", "liked", "disliked"])(
|
||||
"covers all six transitions and restores an initially %s watch video",
|
||||
async (initialState) => {
|
||||
const harness = createReactionHarness(initialState);
|
||||
const beforeFirstAction = jest.fn();
|
||||
|
||||
const result = await runReactionCycle(harness.driver, harness.recorder, OPTIONS, {
|
||||
beforeFirstAction,
|
||||
videoId: "abcdefghijk",
|
||||
});
|
||||
|
||||
expect(harness.driver.openWatch).toHaveBeenCalledWith("abcdefghijk");
|
||||
expect(harness.driver.openShort).not.toHaveBeenCalled();
|
||||
expect(harness.events.map(({ action }) => action)).toEqual(EXPECTED_ACTIONS[initialState]);
|
||||
expect(
|
||||
new Set(
|
||||
harness.events.map(
|
||||
({ action, state }, index) =>
|
||||
`${index ? harness.events[index - 1].state : initialState}:${action}:${state}`,
|
||||
),
|
||||
).size,
|
||||
).toBe(6);
|
||||
expect(harness.getState()).toBe(initialState);
|
||||
expect(harness.recorder.waitForHandshake).toHaveBeenCalledTimes(6);
|
||||
expect(beforeFirstAction).toHaveBeenCalledTimes(1);
|
||||
expect(result).toEqual({ evidencePaths: [], initialState, userId: "shared-user-id" });
|
||||
},
|
||||
);
|
||||
|
||||
test("targets Shorts through the same transition implementation", async () => {
|
||||
const harness = createReactionHarness("neutral");
|
||||
|
||||
await runReactionCycle(harness.driver, harness.recorder, OPTIONS, {
|
||||
isShort: true,
|
||||
videoId: "shortsabcde",
|
||||
});
|
||||
|
||||
expect(harness.driver.openShort).toHaveBeenCalledWith("shortsabcde");
|
||||
expect(harness.driver.openWatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("restores the initial state when a production handshake fails mid-cycle", async () => {
|
||||
const harness = createReactionHarness("neutral", { failHandshake: 3 });
|
||||
|
||||
await expect(
|
||||
runReactionCycle(harness.driver, harness.recorder, OPTIONS, {
|
||||
beforeFirstAction: jest.fn(),
|
||||
videoId: "abcdefghijk",
|
||||
}),
|
||||
).rejects.toThrow("simulated handshake failure");
|
||||
|
||||
expect(harness.getState()).toBe("neutral");
|
||||
expect(harness.events.at(-1)).toEqual({ action: "dislike", state: "neutral" });
|
||||
expect(harness.recorder.waitForHandshake).toHaveBeenLastCalledWith(0, 3);
|
||||
});
|
||||
|
||||
test.each([
|
||||
[
|
||||
"click dispatch throws",
|
||||
{ clickThrowTransition: 1, rollbackOnClickThrow: true },
|
||||
"simulated post-dispatch click failure",
|
||||
],
|
||||
[
|
||||
"post-click state wait fails",
|
||||
{ rollbackOnStateWaitFailure: true, stateWaitFailTransition: 1 },
|
||||
"simulated post-click state-wait failure",
|
||||
],
|
||||
])(
|
||||
"forces a verified away-and-back cleanup when %s after the UI returns to its initial state",
|
||||
async (_failure, harnessOptions, expectedError) => {
|
||||
const harness = createReactionHarness("neutral", harnessOptions);
|
||||
|
||||
await expect(
|
||||
runReactionCycle(harness.driver, harness.recorder, OPTIONS, {
|
||||
beforeFirstAction: jest.fn(),
|
||||
videoId: "abcdefghijk",
|
||||
}),
|
||||
).rejects.toThrow(expectedError);
|
||||
|
||||
expect(harness.recorder.voteUserId).toHaveBeenCalledWith(1, 0);
|
||||
expect(harness.events.slice(-2)).toEqual([
|
||||
{ action: "dislike", state: "disliked" },
|
||||
{ action: "dislike", state: "neutral" },
|
||||
]);
|
||||
expect(harness.recorder.waitForHandshake.mock.calls).toEqual([
|
||||
[-1, 1],
|
||||
[0, 2],
|
||||
]);
|
||||
expect(harness.getState()).toBe("neutral");
|
||||
},
|
||||
);
|
||||
|
||||
test("reports the manual-restore URL when cleanup after a post-dispatch click failure cannot be confirmed", async () => {
|
||||
const harness = createReactionHarness("neutral", {
|
||||
clickThrowTransition: 1,
|
||||
failHandshakes: [1],
|
||||
rollbackOnClickThrow: true,
|
||||
});
|
||||
|
||||
await expect(
|
||||
runReactionCycle(harness.driver, harness.recorder, OPTIONS, {
|
||||
beforeFirstAction: jest.fn(),
|
||||
videoId: "abcdefghijk",
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
/Automatic cleanup could not be verified\. Manually restore https:\/\/www\.youtube\.com\/watch\?v=abcdefghijk/,
|
||||
);
|
||||
});
|
||||
|
||||
test.each([
|
||||
["first", "neutral", 1, true],
|
||||
["middle", "liked", 3, false],
|
||||
["final", "disliked", 6, false],
|
||||
])(
|
||||
"forces a verified away-and-back cleanup after a %s transition handshake fails with the UI at its initial state",
|
||||
async (_position, initialState, failHandshake, rollbackOnHandshakeFailure) => {
|
||||
const harness = createReactionHarness(initialState, { failHandshake, rollbackOnHandshakeFailure });
|
||||
|
||||
await expect(
|
||||
runReactionCycle(harness.driver, harness.recorder, OPTIONS, {
|
||||
beforeFirstAction: jest.fn(),
|
||||
videoId: "abcdefghijk",
|
||||
}),
|
||||
).rejects.toThrow("simulated handshake failure");
|
||||
|
||||
let failedState = initialState;
|
||||
for (const action of EXPECTED_ACTIONS[initialState].slice(0, failHandshake)) {
|
||||
failedState = nextState(failedState, action);
|
||||
}
|
||||
const cleanupAction = initialState === "liked" ? "like" : "dislike";
|
||||
const awayState = initialState === "neutral" ? "disliked" : "neutral";
|
||||
expect(harness.recorder.voteUserId).toHaveBeenCalledWith(valueForState(failedState), failHandshake - 1);
|
||||
expect(harness.events.slice(-2)).toEqual([
|
||||
{ action: cleanupAction, state: awayState },
|
||||
{ action: cleanupAction, state: initialState },
|
||||
]);
|
||||
expect(harness.recorder.waitForHandshake.mock.calls.slice(-2)).toEqual([
|
||||
[valueForState(awayState), failHandshake],
|
||||
[valueForState(initialState), failHandshake + 1],
|
||||
]);
|
||||
expect(harness.getState()).toBe(initialState);
|
||||
},
|
||||
);
|
||||
|
||||
test("reports the manual-restore URL when the verified cleanup round trip fails", async () => {
|
||||
const harness = createReactionHarness("neutral", {
|
||||
failHandshakes: [1, 2],
|
||||
rollbackOnHandshakeFailure: true,
|
||||
});
|
||||
|
||||
await expect(
|
||||
runReactionCycle(harness.driver, harness.recorder, OPTIONS, {
|
||||
beforeFirstAction: jest.fn(),
|
||||
videoId: "abcdefghijk",
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
/Automatic cleanup could not be verified\. Manually restore https:\/\/www\.youtube\.com\/watch\?v=abcdefghijk/,
|
||||
);
|
||||
});
|
||||
|
||||
test("reports the manual-restore URL when cleanup cannot match the failed attempt identity", async () => {
|
||||
const harness = createReactionHarness("neutral", {
|
||||
failHandshake: 1,
|
||||
failedVoteUserId: "failed-attempt-user-id",
|
||||
});
|
||||
|
||||
await expect(
|
||||
runReactionCycle(harness.driver, harness.recorder, OPTIONS, {
|
||||
beforeFirstAction: jest.fn(),
|
||||
videoId: "abcdefghijk",
|
||||
}),
|
||||
).rejects.toThrow(/cleanup reaction did not use the failed attempt's RYD identity/);
|
||||
});
|
||||
|
||||
test("restores the initial state when a post-action visual assertion fails", async () => {
|
||||
const harness = createReactionHarness("neutral");
|
||||
const captureReactionVisual = jest.fn(async ({ index, state }) => {
|
||||
if (index === 3) throw new Error("simulated visual failure");
|
||||
return `watch-${index}-${state}.png`;
|
||||
});
|
||||
|
||||
await expect(
|
||||
runReactionCycle(harness.driver, harness.recorder, OPTIONS, {
|
||||
beforeFirstAction: jest.fn(),
|
||||
captureReactionVisual,
|
||||
videoId: "abcdefghijk",
|
||||
}),
|
||||
).rejects.toThrow("simulated visual failure");
|
||||
|
||||
expect(captureReactionVisual.mock.calls.map(([capture]) => capture)).toEqual([
|
||||
{ index: 0, state: "neutral" },
|
||||
{ index: 1, state: "liked" },
|
||||
{ index: 2, state: "neutral" },
|
||||
{ index: 3, state: "disliked" },
|
||||
]);
|
||||
expect(harness.getState()).toBe("neutral");
|
||||
expect(harness.events.at(-1)).toEqual({ action: "dislike", state: "neutral" });
|
||||
expect(harness.recorder.waitForHandshake).toHaveBeenLastCalledWith(0, 3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("live production reaction matrix visual evidence", () => {
|
||||
test("captures and returns the initial state plus all six transitions for watch and Shorts", async () => {
|
||||
const states = new Map([
|
||||
["abcdefghijk", "neutral"],
|
||||
["shortsabcde", "neutral"],
|
||||
]);
|
||||
let currentVideo;
|
||||
const driver = {
|
||||
assertCurrentVideo: jest.fn((videoId) => expect(currentVideo).toBe(videoId)),
|
||||
assertRuntime: jest.fn(),
|
||||
assertSignedIn: jest.fn(),
|
||||
captureReactionStateVisual: jest.fn(async (capture) => {
|
||||
expect(states.get(capture.videoId)).toBe(capture.expectedState);
|
||||
return { screenshotPath: capture.screenshotPath };
|
||||
}),
|
||||
clickAction: jest.fn(async (videoId, action) => {
|
||||
states.set(videoId, nextState(states.get(videoId), action));
|
||||
}),
|
||||
openShort: jest.fn(async (videoId) => {
|
||||
currentVideo = videoId;
|
||||
}),
|
||||
openWatch: jest.fn(async (videoId) => {
|
||||
currentVideo = videoId;
|
||||
}),
|
||||
readReactionState: jest.fn(async () => states.get(currentVideo)),
|
||||
waitForDislikeText: jest.fn(async () => "123"),
|
||||
waitForReactionState: jest.fn(async (expected) => expect(states.get(currentVideo)).toBe(expected)),
|
||||
};
|
||||
const recorders = [];
|
||||
const createRecorder = jest.fn((videoId) => {
|
||||
const recorder = {
|
||||
mark: jest.fn(() => 0),
|
||||
stop: jest.fn(),
|
||||
waitForHandshake: jest.fn(async (value) => {
|
||||
expect(value).toBe(valueForState(states.get(videoId)));
|
||||
return "shared-user-id";
|
||||
}),
|
||||
};
|
||||
recorders.push(recorder);
|
||||
return recorder;
|
||||
});
|
||||
const consumeVoteApproval = jest.fn();
|
||||
const makeDirectory = jest.fn();
|
||||
const options = {
|
||||
...OPTIONS,
|
||||
short: "shortsabcde",
|
||||
watchB: "abcdefghijk",
|
||||
};
|
||||
const outputDirectory = "reaction-evidence";
|
||||
|
||||
const result = await runProductionReactionMatrixScenario(driver, options, createRecorder, consumeVoteApproval, {
|
||||
makeDirectory,
|
||||
outputDirectory,
|
||||
});
|
||||
|
||||
const stateSequence = ["neutral", "liked", "neutral", "disliked", "liked", "disliked", "neutral"];
|
||||
const expectedWatch = stateSequence.map((state, index) =>
|
||||
path.join(outputDirectory, `watch-${index}-${state}.png`),
|
||||
);
|
||||
const expectedShort = stateSequence.map((state, index) =>
|
||||
path.join(outputDirectory, `short-${index}-${state}.png`),
|
||||
);
|
||||
expect(makeDirectory).toHaveBeenCalledWith(outputDirectory);
|
||||
expect(consumeVoteApproval).toHaveBeenCalledTimes(1);
|
||||
expect(driver.captureReactionStateVisual).toHaveBeenCalledTimes(14);
|
||||
expect(driver.captureReactionStateVisual.mock.calls.slice(0, 7).map(([capture]) => capture)).toEqual(
|
||||
expectedWatch.map((screenshotPath, index) => ({
|
||||
expectedState: stateSequence[index],
|
||||
isShort: false,
|
||||
runtime: "userscript",
|
||||
screenshotPath,
|
||||
videoId: options.watchB,
|
||||
})),
|
||||
);
|
||||
expect(driver.captureReactionStateVisual.mock.calls.slice(7).map(([capture]) => capture)).toEqual(
|
||||
expectedShort.map((screenshotPath, index) => ({
|
||||
expectedState: stateSequence[index],
|
||||
isShort: true,
|
||||
runtime: "userscript",
|
||||
screenshotPath,
|
||||
videoId: options.short,
|
||||
})),
|
||||
);
|
||||
expect(result).toEqual({
|
||||
evidencePaths: [...expectedWatch, ...expectedShort],
|
||||
outputDirectory,
|
||||
short: { evidencePaths: expectedShort, initialState: "neutral", userId: "shared-user-id" },
|
||||
watch: { evidencePaths: expectedWatch, initialState: "neutral", userId: "shared-user-id" },
|
||||
});
|
||||
expect(recorders).toHaveLength(2);
|
||||
expect(recorders.every((recorder) => recorder.stop.mock.calls.length === 1)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("live responsive visual scenario", () => {
|
||||
test("captures watch and userscript Shorts evidence at all responsive widths without actions", async () => {
|
||||
const originalViewport = { height: 900, width: 1440 };
|
||||
let currentViewport = originalViewport;
|
||||
const driver = {
|
||||
assertCurrentShortsControl: jest.fn(),
|
||||
assertRuntime: jest.fn(),
|
||||
assertSignedIn: jest.fn(),
|
||||
captureSyntheticShortsVisual: jest.fn(async (_videoId, screenshotPath) => ({
|
||||
screenshotPath,
|
||||
viewport: currentViewport,
|
||||
})),
|
||||
captureWatchRatioVisual: jest.fn(async (_runtime, screenshotPath) => ({
|
||||
screenshotPath,
|
||||
viewport: currentViewport,
|
||||
})),
|
||||
openShort: jest.fn(),
|
||||
openWatch: jest.fn(),
|
||||
readViewportSize: jest.fn(async () => currentViewport),
|
||||
setViewportSize: jest.fn(async (viewport) => {
|
||||
currentViewport = viewport;
|
||||
}),
|
||||
waitForDislikeText: jest.fn(async () => "123"),
|
||||
withNoProductionInteractions: jest.fn(async (action) => action()),
|
||||
};
|
||||
const makeDirectory = jest.fn();
|
||||
const options = { ...OPTIONS, short: "shortsabcde", watchA: "abcdefghijk" };
|
||||
|
||||
const result = await runResponsiveVisualScenario(driver, options, {
|
||||
makeDirectory,
|
||||
outputDirectory: "responsive-evidence",
|
||||
});
|
||||
|
||||
expect(makeDirectory).toHaveBeenCalledWith("responsive-evidence");
|
||||
expect(driver.openWatch).toHaveBeenCalledWith(options.watchA);
|
||||
expect(driver.openShort).toHaveBeenCalledWith(options.short);
|
||||
expect(driver.assertCurrentShortsControl).toHaveBeenCalledTimes(RESPONSIVE_VIEWPORTS.length);
|
||||
expect(driver.captureWatchRatioVisual).toHaveBeenCalledTimes(RESPONSIVE_VIEWPORTS.length);
|
||||
expect(driver.captureSyntheticShortsVisual).toHaveBeenCalledTimes(RESPONSIVE_VIEWPORTS.length);
|
||||
expect(driver.captureWatchRatioVisual.mock.calls.map(([, screenshotPath]) => screenshotPath)).toEqual(
|
||||
RESPONSIVE_VIEWPORTS.map(({ width }) => expect.stringContaining(`userscript-watch-ratio-${width}.png`)),
|
||||
);
|
||||
expect(driver.captureSyntheticShortsVisual.mock.calls.map(([, screenshotPath]) => screenshotPath)).toEqual(
|
||||
RESPONSIVE_VIEWPORTS.map(({ width }) => expect.stringContaining(`userscript-shorts-control-${width}.png`)),
|
||||
);
|
||||
expect(driver.setViewportSize).toHaveBeenLastCalledWith(originalViewport);
|
||||
expect(driver.withNoProductionInteractions).toHaveBeenCalledTimes(1);
|
||||
expect(driver.waitForDislikeText).toHaveBeenCalledTimes(RESPONSIVE_VIEWPORTS.length * 2);
|
||||
expect(result.watch).toHaveLength(RESPONSIVE_VIEWPORTS.length);
|
||||
expect(result.shorts).toHaveLength(RESPONSIVE_VIEWPORTS.length);
|
||||
expect(result.shortsSkipped).toBeNull();
|
||||
});
|
||||
|
||||
test("captures watch and native extension Shorts evidence at all responsive widths without actions", async () => {
|
||||
const originalViewport = { height: 900, width: 1440 };
|
||||
let currentViewport = originalViewport;
|
||||
const driver = {
|
||||
assertCurrentShortsControl: jest.fn(),
|
||||
assertRuntime: jest.fn(),
|
||||
assertSignedIn: jest.fn(),
|
||||
captureNativeShortsVisual: jest.fn(async (_videoId, screenshotPath) => ({
|
||||
screenshotPath,
|
||||
viewport: currentViewport,
|
||||
})),
|
||||
captureWatchRatioVisual: jest.fn(async (_runtime, screenshotPath) => ({
|
||||
screenshotPath,
|
||||
viewport: currentViewport,
|
||||
})),
|
||||
openShort: jest.fn(),
|
||||
openWatch: jest.fn(),
|
||||
readViewportSize: jest.fn(async () => currentViewport),
|
||||
setViewportSize: jest.fn(async (viewport) => {
|
||||
currentViewport = viewport;
|
||||
}),
|
||||
waitForDislikeText: jest.fn(async () => "123"),
|
||||
withNoProductionInteractions: jest.fn(async (action) => action()),
|
||||
};
|
||||
|
||||
const result = await runResponsiveVisualScenario(
|
||||
driver,
|
||||
{ ...OPTIONS, runtime: "extension", short: "shortsabcde", watchA: "abcdefghijk" },
|
||||
{ makeDirectory: jest.fn(), outputDirectory: "responsive-evidence" },
|
||||
);
|
||||
|
||||
expect(driver.openShort).toHaveBeenCalledWith("shortsabcde");
|
||||
expect(driver.assertCurrentShortsControl).toHaveBeenCalledTimes(RESPONSIVE_VIEWPORTS.length);
|
||||
expect(driver.captureNativeShortsVisual).toHaveBeenCalledTimes(RESPONSIVE_VIEWPORTS.length);
|
||||
expect(driver.captureNativeShortsVisual.mock.calls.map(([, screenshotPath]) => screenshotPath)).toEqual(
|
||||
RESPONSIVE_VIEWPORTS.map(({ width }) => expect.stringContaining(`extension-shorts-control-${width}.png`)),
|
||||
);
|
||||
expect(result.shorts).toHaveLength(RESPONSIVE_VIEWPORTS.length);
|
||||
expect(result.shortsSkipped).toBeNull();
|
||||
expect(driver.setViewportSize).toHaveBeenLastCalledWith(originalViewport);
|
||||
});
|
||||
|
||||
test("restores the original viewport when a visual assertion fails", async () => {
|
||||
const originalViewport = { height: 800, width: 1200 };
|
||||
const driver = {
|
||||
assertRuntime: jest.fn(),
|
||||
assertSignedIn: jest.fn(),
|
||||
captureWatchRatioVisual: jest.fn(async () => Promise.reject(new Error("visual failed"))),
|
||||
openWatch: jest.fn(),
|
||||
readViewportSize: jest.fn(async () => originalViewport),
|
||||
setViewportSize: jest.fn(),
|
||||
waitForDislikeText: jest.fn(async () => "123"),
|
||||
withNoProductionInteractions: jest.fn(async (action) => action()),
|
||||
};
|
||||
|
||||
await expect(
|
||||
runResponsiveVisualScenario(
|
||||
driver,
|
||||
{ ...OPTIONS, short: "shortsabcde", watchA: "abcdefghijk" },
|
||||
{
|
||||
makeDirectory: jest.fn(),
|
||||
outputDirectory: "responsive-evidence",
|
||||
},
|
||||
),
|
||||
).rejects.toThrow("visual failed");
|
||||
expect(driver.setViewportSize).toHaveBeenLastCalledWith(originalViewport);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
const { assertLogicalVoteHandshake } = require("../e2e/live/live-youtube-driver");
|
||||
|
||||
const VIDEO_ID = "abcdefghijk";
|
||||
const USER_ID = "shared-user-id";
|
||||
|
||||
function vote({ status = 200, userId = USER_ID, value = 1, videoId = VIDEO_ID } = {}) {
|
||||
return {
|
||||
body: { userId, value, videoId },
|
||||
pathname: "/interact/vote",
|
||||
responseError: null,
|
||||
status,
|
||||
};
|
||||
}
|
||||
|
||||
function confirmation({ confirmed = true, status = 200, userId = USER_ID, videoId = VIDEO_ID } = {}) {
|
||||
return {
|
||||
body: { solution: "proof", userId, videoId },
|
||||
pathname: "/interact/confirmVote",
|
||||
responseBody: confirmed,
|
||||
responseError: null,
|
||||
status,
|
||||
};
|
||||
}
|
||||
|
||||
describe("live logical vote handshake validation", () => {
|
||||
test("accepts one vote puzzle followed by one true confirmation", () => {
|
||||
expect(assertLogicalVoteHandshake([vote(), confirmation()], VIDEO_ID, 1)).toBe(USER_ID);
|
||||
});
|
||||
|
||||
test("accepts two matching vote puzzle requests followed by one true confirmation", () => {
|
||||
expect(assertLogicalVoteHandshake([vote(), vote(), confirmation()], VIDEO_ID, 1)).toBe(USER_ID);
|
||||
});
|
||||
|
||||
test("accepts three matching vote puzzle requests followed by one true confirmation", () => {
|
||||
expect(assertLogicalVoteHandshake([vote(), vote(), vote(), confirmation()], VIDEO_ID, 1)).toBe(USER_ID);
|
||||
});
|
||||
|
||||
test("rejects a fourth vote puzzle request", () => {
|
||||
expect(() => assertLogicalVoteHandshake([vote(), vote(), vote(), vote(), confirmation()], VIDEO_ID, 1)).toThrow(
|
||||
/one to three vote puzzle requests/,
|
||||
);
|
||||
});
|
||||
|
||||
test.each([
|
||||
["user", [vote(), vote({ userId: "another-user" }), confirmation()], /different user IDs/],
|
||||
["video", [vote(), vote({ videoId: "lmnopqrstuv" }), confirmation()], /different video/],
|
||||
["value", [vote(), vote({ value: 0 }), confirmation()], /changed the requested vote value/],
|
||||
])("rejects a retry with a mismatched %s", (_field, records, message) => {
|
||||
expect(() => assertLogicalVoteHandshake(records, VIDEO_ID, 1)).toThrow(message);
|
||||
});
|
||||
|
||||
test.each([
|
||||
["extra confirmation", [vote(), confirmation(), confirmation()]],
|
||||
["other interaction", [vote(), { ...vote(), pathname: "/interact/other" }, confirmation()]],
|
||||
["traffic after confirmation", [vote(), confirmation(), vote()]],
|
||||
])("rejects %s traffic", (_case, records) => {
|
||||
expect(() => assertLogicalVoteHandshake(records, VIDEO_ID, 1)).toThrow(
|
||||
/exactly one confirmation|logical vote may/i,
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects a false confirmation", () => {
|
||||
expect(() => assertLogicalVoteHandshake([vote(), confirmation({ confirmed: false })], VIDEO_ID, 1)).toThrow(
|
||||
/did not confirm the vote/,
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects a failed vote or confirmation response", () => {
|
||||
expect(() => assertLogicalVoteHandshake([vote({ status: 500 }), confirmation()], VIDEO_ID, 1)).toThrow(
|
||||
/Vote request failed with HTTP 500/,
|
||||
);
|
||||
expect(() => assertLogicalVoteHandshake([vote(), confirmation({ status: 500 })], VIDEO_ID, 1)).toThrow(
|
||||
/Vote confirmation failed with HTTP 500/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,920 @@
|
||||
/**
|
||||
* @jest-environment jsdom
|
||||
*/
|
||||
|
||||
const {
|
||||
LiveYoutubeDriver,
|
||||
assertNativeShortsPairGeometry,
|
||||
assertReactionPressedStates,
|
||||
assertShortsActionStackGeometry,
|
||||
assertSyntheticShortsGeometry,
|
||||
assertWatchRatioViewportAlignment,
|
||||
clickWithSingleNavigationRetry,
|
||||
croppedScreenshotClip,
|
||||
firstVisibleRelatedWatchLink,
|
||||
isShortCandidateEligible,
|
||||
isShortsIconVisualReady,
|
||||
readDislikeControlText,
|
||||
relatedWatchVideoId,
|
||||
} = require("../e2e/live/live-youtube-driver");
|
||||
|
||||
const VISIBLE_RECT = {
|
||||
bottom: 200,
|
||||
height: 100,
|
||||
left: 10,
|
||||
right: 110,
|
||||
top: 100,
|
||||
width: 100,
|
||||
};
|
||||
|
||||
const ACTION_HOST_STYLE = {
|
||||
marginBottom: 0,
|
||||
marginLeft: 0,
|
||||
marginRight: 0,
|
||||
marginTop: 0,
|
||||
paddingBottom: 8,
|
||||
paddingLeft: 0,
|
||||
paddingRight: 0,
|
||||
paddingTop: 0,
|
||||
};
|
||||
const COUNT_STYLE = {
|
||||
fontFamily: '"Roboto", "Arial", sans-serif',
|
||||
fontSize: 12,
|
||||
fontStyle: "normal",
|
||||
fontWeight: "400",
|
||||
lineHeight: 18,
|
||||
};
|
||||
|
||||
function box(x, y, width, height) {
|
||||
return { height, width, x, y };
|
||||
}
|
||||
|
||||
function validSyntheticShortsGeometry() {
|
||||
return {
|
||||
like: {
|
||||
button: box(100, 10, 48, 48),
|
||||
count: box(110, 58, 28, 14),
|
||||
countStyle: { ...COUNT_STYLE },
|
||||
host: box(100, 10, 48, 78),
|
||||
hostStyle: { ...ACTION_HOST_STYLE },
|
||||
icon: box(112, 22, 24, 24),
|
||||
label: box(100, 10, 48, 70),
|
||||
svg: box(112, 22, 24, 24),
|
||||
},
|
||||
next: {
|
||||
host: box(100, 166, 48, 78),
|
||||
},
|
||||
synthetic: {
|
||||
button: box(100, 88, 48, 48),
|
||||
count: box(109, 136, 30, 14),
|
||||
countStyle: { ...COUNT_STYLE },
|
||||
host: box(100, 88, 48, 78),
|
||||
hostStyle: { ...ACTION_HOST_STYLE },
|
||||
icon: box(112, 100, 24, 24),
|
||||
label: box(100, 88, 48, 70),
|
||||
svg: box(112, 100, 24, 24),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function validNativeShortsPairGeometry() {
|
||||
const syntheticGeometry = validSyntheticShortsGeometry();
|
||||
const enrich = (geometry, actionIndex) => ({
|
||||
...geometry,
|
||||
actionIndex,
|
||||
reelIndex: 2,
|
||||
videoMatches: true,
|
||||
});
|
||||
return {
|
||||
dislike: enrich(syntheticGeometry.synthetic, 4),
|
||||
like: enrich(syntheticGeometry.like, 3),
|
||||
};
|
||||
}
|
||||
|
||||
function renderShort({ href, rendererVideoId } = {}) {
|
||||
document.body.innerHTML = `
|
||||
<ytd-reel-video-renderer${rendererVideoId ? ` video-id="${rendererVideoId}"` : ""}>
|
||||
${href ? `<a href="${href}"></a>` : ""}
|
||||
<button type="button">Dislike</button>
|
||||
</ytd-reel-video-renderer>
|
||||
`;
|
||||
const reel = document.querySelector("ytd-reel-video-renderer");
|
||||
const button = reel.querySelector("button");
|
||||
reel.getBoundingClientRect = jest.fn(() => VISIBLE_RECT);
|
||||
button.getBoundingClientRect = jest.fn(() => VISIBLE_RECT);
|
||||
return { button, reel };
|
||||
}
|
||||
|
||||
const activeShortSettings = (videoId) => ({
|
||||
activeShortRequired: true,
|
||||
expectedShortVideoId: videoId,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(window, "innerHeight", { configurable: true, value: 768 });
|
||||
Object.defineProperty(window, "innerWidth", { configurable: true, value: 1024 });
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
describe("exact installed live-build identity", () => {
|
||||
const buildId = "0123456789abcdef0123456789abcdef";
|
||||
|
||||
function createDriver(markers) {
|
||||
const page = {
|
||||
locator: jest.fn((selector) => {
|
||||
expect(selector).toBe("html");
|
||||
return { evaluate: jest.fn(async () => markers) };
|
||||
}),
|
||||
setDefaultNavigationTimeout: jest.fn(),
|
||||
setDefaultTimeout: jest.fn(),
|
||||
};
|
||||
return new LiveYoutubeDriver(page, {});
|
||||
}
|
||||
|
||||
test.each(["userscript", "extension"])("accepts only the exact installed %s live build", async (runtime) => {
|
||||
const otherRuntime = runtime === "userscript" ? "extension" : "userscript";
|
||||
const markers = {
|
||||
extension: null,
|
||||
extensionBuild: null,
|
||||
userscript: null,
|
||||
userscriptBuild: null,
|
||||
[runtime]: runtime === "userscript" ? "3.2.0" : "4.0.4",
|
||||
[`${runtime}Build`]: buildId,
|
||||
};
|
||||
const driver = createDriver(markers);
|
||||
|
||||
await expect(driver.assertRuntime(runtime, markers[runtime], buildId)).resolves.toBeUndefined();
|
||||
expect(markers[otherRuntime]).toBeNull();
|
||||
});
|
||||
|
||||
test("rejects a stale userscript build even when its version matches", async () => {
|
||||
const driver = createDriver({
|
||||
extension: null,
|
||||
extensionBuild: null,
|
||||
userscript: "3.2.0",
|
||||
userscriptBuild: "f".repeat(32),
|
||||
});
|
||||
|
||||
await expect(driver.assertRuntime("userscript", "3.2.0", buildId)).rejects.toThrow(
|
||||
"match the freshly generated artifact",
|
||||
);
|
||||
});
|
||||
|
||||
test.each([undefined, "", "stale", "A".repeat(32)])(
|
||||
"refuses to test without a valid exact build ID: %p",
|
||||
async (expectedBuildId) => {
|
||||
const driver = createDriver({
|
||||
extension: null,
|
||||
extensionBuild: null,
|
||||
userscript: "3.2.0",
|
||||
userscriptBuild: buildId,
|
||||
});
|
||||
|
||||
await expect(driver.assertRuntime("userscript", "3.2.0", expectedBuildId)).rejects.toThrow(
|
||||
"exact 32-character live build ID",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test("rejects an enabled opposite runtime even when the selected marker is exact", async () => {
|
||||
const driver = createDriver({
|
||||
extension: "4.0.4",
|
||||
extensionBuild: "f".repeat(32),
|
||||
userscript: "3.2.0",
|
||||
userscriptBuild: buildId,
|
||||
});
|
||||
|
||||
await expect(driver.assertRuntime("userscript", "3.2.0", buildId)).rejects.toThrow(
|
||||
"Disable the extension before running the userscript smoke",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isShortCandidateEligible", () => {
|
||||
test("accepts an exact renderer video-id without requiring a Shorts link", () => {
|
||||
const { button } = renderShort({ rendererVideoId: "current-video" });
|
||||
|
||||
expect(isShortCandidateEligible(button, activeShortSettings("current-video"))).toBe(true);
|
||||
});
|
||||
|
||||
test("accepts an exact Shorts link when the renderer has no video-id", () => {
|
||||
const { button } = renderShort({ href: "/shorts/current-video?feature=share" });
|
||||
|
||||
expect(isShortCandidateEligible(button, activeShortSettings("current-video"))).toBe(true);
|
||||
});
|
||||
|
||||
test("rejects a matching descendant link when the renderer video-id identifies another Short", () => {
|
||||
const { button } = renderShort({ href: "/shorts/current-video", rendererVideoId: "outgoing-video" });
|
||||
|
||||
expect(isShortCandidateEligible(button, activeShortSettings("current-video"))).toBe(false);
|
||||
});
|
||||
|
||||
test.each(["/shorts/current-video-extra", "/shorts/current-video/related", "/shorts/other-video"])(
|
||||
"rejects a non-exact Shorts link: %s",
|
||||
(href) => {
|
||||
const { button } = renderShort({ href });
|
||||
|
||||
expect(isShortCandidateEligible(button, activeShortSettings("current-video"))).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
test("rejects an exact video outside the viewport", () => {
|
||||
const { button, reel } = renderShort({ rendererVideoId: "current-video" });
|
||||
reel.getBoundingClientRect = jest.fn(() => ({ ...VISIBLE_RECT, bottom: -1, top: -101 }));
|
||||
|
||||
expect(isShortCandidateEligible(button, activeShortSettings("current-video"))).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects an action button outside the viewport even when its renderer intersects", () => {
|
||||
const { button } = renderShort({ rendererVideoId: "current-video" });
|
||||
button.getBoundingClientRect = jest.fn(() => ({ ...VISIBLE_RECT, left: 1100, right: 1200 }));
|
||||
|
||||
expect(isShortCandidateEligible(button, activeShortSettings("current-video"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("readDislikeControlText", () => {
|
||||
test("reads a synthetic control count from the sibling of its inner button", () => {
|
||||
document.body.innerHTML = `
|
||||
<div data-ryd-synthetic-shorts-dislike>
|
||||
<div>
|
||||
<button type="button" aria-pressed="false"><svg></svg></button>
|
||||
<div><span id="text" role="text">1.2K</span></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
expect(readDislikeControlText(document.querySelector("button"))).toBe("1.2K");
|
||||
});
|
||||
|
||||
test("continues to read native dislike text from the button", () => {
|
||||
document.body.innerHTML = `<button type="button"><span role="text">456</span></button>`;
|
||||
|
||||
expect(readDislikeControlText(document.querySelector("button"))).toBe("456");
|
||||
});
|
||||
});
|
||||
|
||||
describe("relatedWatchVideoId", () => {
|
||||
const settings = {
|
||||
currentVideoId: "abcdefghijk",
|
||||
excludedVideoIds: ["excluded001"],
|
||||
origin: "https://www.youtube.com",
|
||||
};
|
||||
|
||||
function read(href, overrides = {}) {
|
||||
const link = document.createElement("a");
|
||||
link.setAttribute("href", href);
|
||||
return relatedWatchVideoId(link, { ...settings, ...overrides });
|
||||
}
|
||||
|
||||
test("accepts an exact same-origin watch target while preserving harmless query parameters", () => {
|
||||
expect(read("/watch?v=targetvid01&pp=sidebar")).toBe("targetvid01");
|
||||
});
|
||||
|
||||
test.each([
|
||||
["/watch?v=abcdefghijk", "current video"],
|
||||
["/watch?v=excluded001", "previously visited video"],
|
||||
["/watch?v=too-short", "invalid video ID"],
|
||||
["/shorts/targetvid01", "Shorts URL"],
|
||||
["https://example.com/watch?v=targetvid01", "cross-origin URL"],
|
||||
["https://www.youtube.com.evil.example/watch?v=targetvid01", "lookalike host"],
|
||||
])("rejects a %s (%s)", (href) => {
|
||||
expect(read(href)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("firstVisibleRelatedWatchLink", () => {
|
||||
function relatedLink(href, { visible = true, label = href } = {}) {
|
||||
const element = document.createElement("a");
|
||||
element.setAttribute("href", href);
|
||||
element.textContent = label;
|
||||
return {
|
||||
element,
|
||||
evaluate: async (callback, settings) => callback(element, settings),
|
||||
isVisible: async () => visible,
|
||||
};
|
||||
}
|
||||
|
||||
function relatedPage(candidates) {
|
||||
const locator = {
|
||||
count: async () => candidates.length,
|
||||
nth: (index) => candidates[index],
|
||||
};
|
||||
return {
|
||||
locator,
|
||||
page: {
|
||||
locator: jest.fn(() => locator),
|
||||
url: jest.fn(() => "https://www.youtube.com/watch?v=abcdefghijk"),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("selects the first visible exact generic related anchor without renderer or thumbnail markup", async () => {
|
||||
const offscreenThumbnail = relatedLink("/watch?v=targetvid01&pp=thumbnail", {
|
||||
label: "thumbnail duplicate",
|
||||
visible: false,
|
||||
});
|
||||
const currentVideo = relatedLink("/watch?v=abcdefghijk", { label: "current video" });
|
||||
const excludedVideo = relatedLink("/watch?v=excluded001", { label: "visited video" });
|
||||
const genericTitle = relatedLink("/watch?v=targetvid01&pp=title", { label: "generic title link" });
|
||||
const visibleThumbnailDuplicate = relatedLink("/watch?v=targetvid01&pp=thumbnail", {
|
||||
label: "visible thumbnail duplicate",
|
||||
});
|
||||
const laterTarget = relatedLink("/watch?v=latervideo1", { label: "later target" });
|
||||
const { page } = relatedPage([
|
||||
offscreenThumbnail,
|
||||
currentVideo,
|
||||
excludedVideo,
|
||||
genericTitle,
|
||||
visibleThumbnailDuplicate,
|
||||
laterTarget,
|
||||
]);
|
||||
|
||||
await expect(firstVisibleRelatedWatchLink(page, "abcdefghijk", ["excluded001"], 100)).resolves.toEqual({
|
||||
link: genericTitle,
|
||||
videoId: "targetvid01",
|
||||
});
|
||||
expect(page.locator).toHaveBeenCalledTimes(1);
|
||||
expect(page.locator).toHaveBeenCalledWith('#related a[href*="/watch"]');
|
||||
});
|
||||
});
|
||||
|
||||
describe("live playback diagnostics", () => {
|
||||
test("reports that playback was intentionally paused", async () => {
|
||||
const videos = [{ pause: jest.fn() }, { pause: jest.fn() }];
|
||||
const page = {
|
||||
locator: jest.fn(() => ({ evaluateAll: async (callback) => callback(videos) })),
|
||||
setDefaultNavigationTimeout: jest.fn(),
|
||||
setDefaultTimeout: jest.fn(),
|
||||
url: jest.fn(() => "https://www.youtube.com/shorts/abcdefghijk"),
|
||||
};
|
||||
const reportProgress = jest.fn();
|
||||
const driver = new LiveYoutubeDriver(page, {}, { reportProgress });
|
||||
|
||||
await expect(driver.pausePlayback()).resolves.toEqual({ pauseFailures: [], pausedVideos: 2 });
|
||||
|
||||
expect(videos.every(({ pause }) => pause.mock.calls.length === 1)).toBe(true);
|
||||
expect(reportProgress).toHaveBeenCalledWith("playback.paused", {
|
||||
explanation: "The live smoke pauses media intentionally while it validates the current page",
|
||||
pauseFailures: [],
|
||||
pausedVideos: 2,
|
||||
url: "https://www.youtube.com/shorts/abcdefghijk",
|
||||
});
|
||||
});
|
||||
|
||||
test("catches individual synchronous pause failures without waiting on media playback", async () => {
|
||||
const videos = [
|
||||
{ pause: jest.fn(() => undefined) },
|
||||
{
|
||||
pause: jest.fn(() => {
|
||||
throw new Error("media state unavailable");
|
||||
}),
|
||||
},
|
||||
];
|
||||
const page = {
|
||||
locator: jest.fn(() => ({ evaluateAll: async (callback) => callback(videos) })),
|
||||
setDefaultNavigationTimeout: jest.fn(),
|
||||
setDefaultTimeout: jest.fn(),
|
||||
url: jest.fn(() => "https://www.youtube.com/shorts/abcdefghijk"),
|
||||
};
|
||||
const driver = new LiveYoutubeDriver(page, {});
|
||||
|
||||
await expect(driver.pausePlayback()).resolves.toEqual({
|
||||
pauseFailures: ["media state unavailable"],
|
||||
pausedVideos: 1,
|
||||
});
|
||||
});
|
||||
|
||||
test("waits for a new video URL without pausing until the caller requests stable playback", async () => {
|
||||
const videos = [{ pause: jest.fn() }];
|
||||
const page = {
|
||||
locator: jest.fn(() => ({ evaluateAll: async (callback) => callback(videos) })),
|
||||
setDefaultNavigationTimeout: jest.fn(),
|
||||
setDefaultTimeout: jest.fn(),
|
||||
url: jest.fn(() => "https://www.youtube.com/shorts/abcdefghijk"),
|
||||
};
|
||||
const driver = new LiveYoutubeDriver(page, {});
|
||||
|
||||
await driver.waitForVideoUrl("abcdefghijk");
|
||||
expect(page.locator).not.toHaveBeenCalled();
|
||||
expect(videos[0].pause).not.toHaveBeenCalled();
|
||||
|
||||
await driver.waitForVideo("abcdefghijk");
|
||||
expect(page.locator).toHaveBeenCalledWith("video");
|
||||
expect(videos[0].pause).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("watch ratio-bar soak", () => {
|
||||
test("rechecks the current video, bar geometry, and stable count for the requested interval", async () => {
|
||||
const page = {
|
||||
setDefaultNavigationTimeout: jest.fn(),
|
||||
setDefaultTimeout: jest.fn(),
|
||||
};
|
||||
const reportProgress = jest.fn();
|
||||
const driver = new LiveYoutubeDriver(page, {}, { reportProgress });
|
||||
driver.assertCurrentVideo = jest.fn();
|
||||
driver.assertWatchRatioVisual = jest.fn(async () => ({ count: "123" }));
|
||||
|
||||
const result = await driver.soakWatchRatioVisual("userscript", {
|
||||
durationMs: 1,
|
||||
expectedCount: "123",
|
||||
intervalMs: 1,
|
||||
videoId: "targetvid01",
|
||||
});
|
||||
|
||||
expect(driver.assertCurrentVideo).toHaveBeenCalledWith("targetvid01");
|
||||
expect(driver.assertWatchRatioVisual).toHaveBeenCalledWith("userscript", { expectedCount: "123" });
|
||||
expect(result).toEqual({
|
||||
count: "123",
|
||||
durationMs: 1,
|
||||
sampleCount: driver.assertWatchRatioVisual.mock.calls.length,
|
||||
videoId: "targetvid01",
|
||||
});
|
||||
expect(reportProgress).toHaveBeenNthCalledWith(1, "watch-ratio-soak.start", {
|
||||
durationMs: 1,
|
||||
expectedCount: "123",
|
||||
runtime: "userscript",
|
||||
videoId: "targetvid01",
|
||||
});
|
||||
expect(reportProgress).toHaveBeenLastCalledWith("watch-ratio-soak.complete", {
|
||||
durationMs: 1,
|
||||
expectedCount: "123",
|
||||
runtime: "userscript",
|
||||
sampleCount: result.sampleCount,
|
||||
videoId: "targetvid01",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("reaction visual capture hygiene", () => {
|
||||
function createCapturePage(tooltip, videoBoxes = []) {
|
||||
const tooltipList = {
|
||||
count: jest.fn(async () => 1),
|
||||
nth: jest.fn(() => tooltip),
|
||||
};
|
||||
const videos = videoBoxes.map((measurement) => ({
|
||||
boundingBox: jest.fn(async () => measurement),
|
||||
isVisible: jest.fn(async () => true),
|
||||
}));
|
||||
const videoList = {
|
||||
count: jest.fn(async () => videos.length),
|
||||
nth: jest.fn((index) => videos[index]),
|
||||
};
|
||||
return {
|
||||
evaluate: jest.fn(async () => undefined),
|
||||
locator: jest.fn((selector) => (selector === "video" ? videoList : tooltipList)),
|
||||
mouse: { move: jest.fn(async () => undefined) },
|
||||
screenshot: jest.fn(async () => undefined),
|
||||
setDefaultNavigationTimeout: jest.fn(),
|
||||
setDefaultTimeout: jest.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
test("parks the pointer inside the largest visible video and outside controls before capturing", async () => {
|
||||
const tooltip = {
|
||||
innerText: jest.fn(async () => "I like this"),
|
||||
isVisible: jest.fn().mockResolvedValueOnce(true).mockResolvedValue(false),
|
||||
};
|
||||
const preloadedVideo = box(0, 0, 320, 180);
|
||||
const currentVideo = box(100, 40, 800, 640);
|
||||
const page = createCapturePage(tooltip, [preloadedVideo, currentVideo]);
|
||||
const driver = new LiveYoutubeDriver(page, {}, { visualTooltipTimeout: 1_000 });
|
||||
const control = box(900, 500, 48, 78);
|
||||
driver.readViewportSize = jest.fn(async () => ({ height: 720, width: 1280 }));
|
||||
|
||||
await expect(driver.captureCroppedScreenshot("short-liked.png", [control])).resolves.toEqual({
|
||||
height: 102,
|
||||
width: 72,
|
||||
x: 888,
|
||||
y: 488,
|
||||
});
|
||||
|
||||
const [pointerX, pointerY] = page.mouse.move.mock.calls[0];
|
||||
expect({ x: pointerX, y: pointerY }).toEqual({ x: 500, y: 360 });
|
||||
expect(pointerX).toBeGreaterThan(currentVideo.x);
|
||||
expect(pointerX).toBeLessThan(currentVideo.x + currentVideo.width);
|
||||
expect(pointerY).toBeGreaterThan(currentVideo.y);
|
||||
expect(pointerY).toBeLessThan(currentVideo.y + currentVideo.height);
|
||||
expect(
|
||||
pointerX < control.x ||
|
||||
pointerX > control.x + control.width ||
|
||||
pointerY < control.y ||
|
||||
pointerY > control.y + control.height,
|
||||
).toBe(true);
|
||||
expect(tooltip.isVisible).toHaveBeenCalledTimes(2);
|
||||
expect(page.mouse.move.mock.invocationCallOrder[0]).toBeLessThan(page.screenshot.mock.invocationCallOrder[0]);
|
||||
expect(page.screenshot).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ path: "short-liked.png", clip: { height: 102, width: 72, x: 888, y: 488 } }),
|
||||
);
|
||||
});
|
||||
|
||||
test("fails within the configured bound instead of capturing a persistent native tooltip", async () => {
|
||||
const tooltip = {
|
||||
innerText: jest.fn(async () => "I like this"),
|
||||
isVisible: jest.fn(async () => true),
|
||||
};
|
||||
const page = createCapturePage(tooltip);
|
||||
const driver = new LiveYoutubeDriver(page, {}, { visualTooltipTimeout: 1 });
|
||||
driver.readViewportSize = jest.fn(async () => ({ height: 720, width: 1280 }));
|
||||
|
||||
await expect(driver.captureCroppedScreenshot("obscured.png", [box(900, 500, 48, 78)])).rejects.toThrow(
|
||||
/Timed out waiting for native YouTube tooltips.*I like this/,
|
||||
);
|
||||
|
||||
expect(page.mouse.move).toHaveBeenCalledTimes(1);
|
||||
expect(page.screenshot).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("watch reaction screenshot bounds", () => {
|
||||
function visibleBoxLocator(measurement) {
|
||||
const locator = {
|
||||
boundingBox: jest.fn(async () => measurement),
|
||||
count: jest.fn(async () => 1),
|
||||
isVisible: jest.fn(async () => true),
|
||||
nth: jest.fn(() => locator),
|
||||
scrollIntoViewIfNeeded: jest.fn(async () => undefined),
|
||||
};
|
||||
return locator;
|
||||
}
|
||||
|
||||
test("passes the full ratio wrapper to the crop so its bottom label is retained", async () => {
|
||||
const viewport = { height: 300, width: 500 };
|
||||
const likeBox = box(100, 100, 80, 40);
|
||||
const dislikeBox = box(180, 100, 80, 40);
|
||||
const containerBox = box(100, 144, 160, 2);
|
||||
const barBox = box(100, 144, 120, 2);
|
||||
const wrapperBox = box(100, 140, 160, 62);
|
||||
const like = visibleBoxLocator(likeBox);
|
||||
const dislike = visibleBoxLocator(dislikeBox);
|
||||
const bar = visibleBoxLocator(barBox);
|
||||
const wrapper = visibleBoxLocator(wrapperBox);
|
||||
const container = visibleBoxLocator(containerBox);
|
||||
container.locator = jest.fn(() => wrapper);
|
||||
const page = {
|
||||
locator: jest.fn((selector) => (selector === "#return-youtube-dislike-bar-container" ? container : bar)),
|
||||
setDefaultNavigationTimeout: jest.fn(),
|
||||
setDefaultTimeout: jest.fn(),
|
||||
};
|
||||
const driver = new LiveYoutubeDriver(page, {});
|
||||
driver.captureCroppedScreenshot = jest.fn(async (_path, boxes) => croppedScreenshotClip(boxes, viewport));
|
||||
driver.readViewportSize = jest.fn(async () => viewport);
|
||||
driver.visibleDislikeButton = jest.fn(async () => dislike);
|
||||
driver.visibleLikeButton = jest.fn(async () => like);
|
||||
driver.waitForDislikeText = jest.fn(async () => "123");
|
||||
|
||||
const result = await driver.captureWatchRatioVisual("userscript", "watch-liked.png");
|
||||
|
||||
expect(driver.captureCroppedScreenshot).toHaveBeenCalledWith("watch-liked.png", [
|
||||
likeBox,
|
||||
dislikeBox,
|
||||
containerBox,
|
||||
wrapperBox,
|
||||
]);
|
||||
expect(result.screenshotClip.y + result.screenshotClip.height).toBe(wrapperBox.y + wrapperBox.height + 12);
|
||||
expect(result.screenshotClip.y + result.screenshotClip.height).toBeGreaterThan(
|
||||
containerBox.y + containerBox.height + 12,
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects duplicate visible runtime bars before taking evidence", async () => {
|
||||
const candidates = [{ isVisible: jest.fn(async () => true) }, { isVisible: jest.fn(async () => true) }];
|
||||
const duplicateContainers = {
|
||||
count: jest.fn(async () => candidates.length),
|
||||
nth: jest.fn((index) => candidates[index]),
|
||||
};
|
||||
const bar = visibleBoxLocator(box(100, 144, 120, 2));
|
||||
const page = {
|
||||
locator: jest.fn((selector) =>
|
||||
selector === "#return-youtube-dislike-bar-container" ? duplicateContainers : bar,
|
||||
),
|
||||
setDefaultNavigationTimeout: jest.fn(),
|
||||
setDefaultTimeout: jest.fn(),
|
||||
};
|
||||
const driver = new LiveYoutubeDriver(page, {});
|
||||
|
||||
await expect(driver.captureWatchRatioVisual("userscript", "duplicate.png")).rejects.toThrow(
|
||||
"Expected exactly one visible userscript watch ratio bar; found 2.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("live Shorts Next navigation retry", () => {
|
||||
test("retries exactly once when YouTube ignores the first trusted click", async () => {
|
||||
let navigated = false;
|
||||
const click = jest.fn().mockResolvedValue(undefined);
|
||||
const reportProgress = jest.fn();
|
||||
const waitForNavigation = jest
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error("first navigation timed out"))
|
||||
.mockImplementationOnce(async () => {
|
||||
navigated = true;
|
||||
});
|
||||
|
||||
await expect(
|
||||
clickWithSingleNavigationRetry({
|
||||
click,
|
||||
hasNavigated: () => navigated,
|
||||
reportProgress,
|
||||
retryDetails: { previousVideoId: "abcdefghijk" },
|
||||
waitForNavigation,
|
||||
}),
|
||||
).resolves.toEqual({ retried: true });
|
||||
|
||||
expect(click.mock.calls).toEqual([
|
||||
[1, 5_000],
|
||||
[2, 25_000],
|
||||
]);
|
||||
expect(waitForNavigation.mock.calls.map(([timeout]) => timeout)).toEqual([5_000, 25_000]);
|
||||
expect(reportProgress).toHaveBeenCalledTimes(1);
|
||||
expect(reportProgress).toHaveBeenCalledWith("shorts-next-control.retrying", {
|
||||
firstFailure: "first navigation timed out",
|
||||
firstTimeoutMs: 5_000,
|
||||
previousVideoId: "abcdefghijk",
|
||||
retryTimeoutMs: 25_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("fails after the single retry instead of clicking a third time", async () => {
|
||||
const click = jest.fn().mockResolvedValue(undefined);
|
||||
const reportProgress = jest.fn();
|
||||
const waitForNavigation = jest
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error("first navigation timed out"))
|
||||
.mockRejectedValueOnce(new Error("retry navigation timed out"));
|
||||
|
||||
await expect(
|
||||
clickWithSingleNavigationRetry({
|
||||
click,
|
||||
hasNavigated: () => false,
|
||||
reportProgress,
|
||||
retryDetails: { previousVideoId: "abcdefghijk" },
|
||||
waitForNavigation,
|
||||
}),
|
||||
).rejects.toThrow(/first Shorts Next click or its single retry/);
|
||||
|
||||
expect(click).toHaveBeenCalledTimes(2);
|
||||
expect(waitForNavigation).toHaveBeenCalledTimes(2);
|
||||
expect(reportProgress).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("does not retry when navigation completed at the first timeout boundary", async () => {
|
||||
let navigated = false;
|
||||
const click = jest.fn().mockResolvedValue(undefined);
|
||||
const reportProgress = jest.fn();
|
||||
const waitForNavigation = jest.fn(async () => {
|
||||
navigated = true;
|
||||
throw new Error("navigation event timed out after the URL changed");
|
||||
});
|
||||
|
||||
await expect(
|
||||
clickWithSingleNavigationRetry({
|
||||
click,
|
||||
hasNavigated: () => navigated,
|
||||
reportProgress,
|
||||
retryDetails: { previousVideoId: "abcdefghijk" },
|
||||
waitForNavigation,
|
||||
}),
|
||||
).resolves.toEqual({ retried: false });
|
||||
|
||||
expect(click).toHaveBeenCalledTimes(1);
|
||||
expect(reportProgress).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("watch ratio viewport alignment", () => {
|
||||
const viewport = { height: 844, width: 375 };
|
||||
const like = box(-25.828, 700, 80.77, 40);
|
||||
const dislike = box(54.942, 700, 84.658, 40);
|
||||
|
||||
test("allows the ratio bar to share YouTube's native horizontal clipping exactly", () => {
|
||||
const container = box(-25.828, 744, 165.428, 2);
|
||||
|
||||
expect(assertWatchRatioViewportAlignment(container, like, dislike, viewport)).toEqual({
|
||||
nativeControlsAreHorizontallyClipped: true,
|
||||
nativeLeft: -25.828,
|
||||
nativeRight: 139.6,
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects ratio-bar clipping that extends beyond the native reaction controls", () => {
|
||||
const container = box(-35.828, 744, 175.428, 2);
|
||||
|
||||
expect(() => assertWatchRatioViewportAlignment(container, like, dislike, viewport)).toThrow(
|
||||
/left edge alignment with native reaction controls/,
|
||||
);
|
||||
});
|
||||
|
||||
test("retains the strict viewport assertion when native controls are in bounds", () => {
|
||||
const inBoundsLike = box(10, 700, 80, 40);
|
||||
const inBoundsDislike = box(90, 700, 85, 40);
|
||||
const clippedContainer = box(-2, 744, 177, 2);
|
||||
|
||||
expect(() => assertWatchRatioViewportAlignment(clippedContainer, inBoundsLike, inBoundsDislike, viewport)).toThrow(
|
||||
/Watch ratio bar is clipped past the viewport's left edge/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("assertReactionPressedStates", () => {
|
||||
test.each([
|
||||
["neutral", { dislikeState: "false", likeState: "false" }],
|
||||
["liked", { dislikeState: "false", likeState: "true" }],
|
||||
["disliked", { dislikeState: "true", likeState: "false" }],
|
||||
])("accepts the exact mutually-exclusive %s state", (expectedState, pressedStates) => {
|
||||
expect(() => assertReactionPressedStates(pressedStates, expectedState)).not.toThrow();
|
||||
});
|
||||
|
||||
test("rejects a valid but unexpected pressed state", () => {
|
||||
expect(() => assertReactionPressedStates({ dislikeState: "false", likeState: "true" }, "neutral")).toThrow(
|
||||
/Expected YouTube reaction state neutral/,
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects Like and Dislike being selected together", () => {
|
||||
expect(() => assertReactionPressedStates({ dislikeState: "true", likeState: "true" }, "liked")).toThrow(
|
||||
/Like and Dislike as selected/,
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects an invalid pressed-state value", () => {
|
||||
expect(() => assertReactionPressedStates({ dislikeState: null, likeState: "false" }, "neutral")).toThrow(
|
||||
/Unexpected YouTube dislike state/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("assertSyntheticShortsGeometry", () => {
|
||||
test("accepts the measured native Shorts action geometry", () => {
|
||||
expect(() => assertSyntheticShortsGeometry(validSyntheticShortsGeometry())).not.toThrow();
|
||||
});
|
||||
|
||||
test.each([
|
||||
["icon container", "icon"],
|
||||
["SVG", "svg"],
|
||||
])("rejects an 8px-wide synthetic %s", (_label, property) => {
|
||||
const measurement = validSyntheticShortsGeometry();
|
||||
measurement.synthetic[property].width = 8;
|
||||
|
||||
expect(() => assertSyntheticShortsGeometry(measurement)).toThrow(
|
||||
new RegExp(`Synthetic Shorts ${property === "svg" ? "SVG" : "icon container"} width`),
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects a synthetic action host with a 16px margin", () => {
|
||||
const measurement = validSyntheticShortsGeometry();
|
||||
measurement.synthetic.hostStyle.marginTop = 16;
|
||||
|
||||
expect(() => assertSyntheticShortsGeometry(measurement)).toThrow(/Synthetic Shorts action host marginTop/);
|
||||
});
|
||||
|
||||
test("rejects a gap between Like and the synthetic action", () => {
|
||||
const measurement = validSyntheticShortsGeometry();
|
||||
measurement.synthetic.host.y += 16;
|
||||
measurement.next.host.y += 16;
|
||||
|
||||
expect(() => assertSyntheticShortsGeometry(measurement)).toThrow(
|
||||
/Gap between native Like and synthetic Shorts action hosts/,
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects a gap between the synthetic and following actions", () => {
|
||||
const measurement = validSyntheticShortsGeometry();
|
||||
measurement.next.host.y += 2;
|
||||
|
||||
expect(() => assertSyntheticShortsGeometry(measurement)).toThrow(
|
||||
/Gap between synthetic Shorts and following action hosts/,
|
||||
);
|
||||
});
|
||||
|
||||
test.each([
|
||||
["button", "width", 44, /Synthetic Shorts button width/],
|
||||
["label", "height", 68, /Synthetic Shorts label height/],
|
||||
["host", "height", 80, /Synthetic Shorts action host height/],
|
||||
])("rejects a mismatched synthetic %s %s", (part, dimension, value, message) => {
|
||||
const measurement = validSyntheticShortsGeometry();
|
||||
measurement.synthetic[part][dimension] = value;
|
||||
|
||||
expect(() => assertSyntheticShortsGeometry(measurement)).toThrow(message);
|
||||
});
|
||||
|
||||
test("rejects incorrect action-host padding", () => {
|
||||
const measurement = validSyntheticShortsGeometry();
|
||||
measurement.synthetic.hostStyle.paddingBottom = 0;
|
||||
|
||||
expect(() => assertSyntheticShortsGeometry(measurement)).toThrow(/Synthetic Shorts action host paddingBottom/);
|
||||
});
|
||||
|
||||
test.each([
|
||||
["fontSize", 14, /Synthetic Shorts count font-size/],
|
||||
["lineHeight", 16, /Synthetic Shorts count line-height/],
|
||||
["fontFamily", "Arial", /Synthetic Shorts count fontFamily/],
|
||||
])("rejects mismatched count %s", (property, value, message) => {
|
||||
const measurement = validSyntheticShortsGeometry();
|
||||
measurement.synthetic.countStyle[property] = value;
|
||||
|
||||
expect(() => assertSyntheticShortsGeometry(measurement)).toThrow(message);
|
||||
});
|
||||
|
||||
test("rejects a control that is off the native action-column center", () => {
|
||||
const measurement = validSyntheticShortsGeometry();
|
||||
measurement.synthetic.button.x += 2;
|
||||
|
||||
expect(() => assertSyntheticShortsGeometry(measurement)).toThrow(/Synthetic Shorts button horizontal center/);
|
||||
});
|
||||
|
||||
test("allows a one-pixel fractional rendering difference", () => {
|
||||
const measurement = validSyntheticShortsGeometry();
|
||||
measurement.synthetic.button.width = 47;
|
||||
measurement.synthetic.svg.height = 24.75;
|
||||
measurement.next.host.x = 101;
|
||||
|
||||
expect(() => assertSyntheticShortsGeometry(measurement)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("assertNativeShortsPairGeometry", () => {
|
||||
test("accepts an exact active native Like/Dislike action pair", () => {
|
||||
expect(() => assertNativeShortsPairGeometry(validNativeShortsPairGeometry())).not.toThrow();
|
||||
});
|
||||
|
||||
test("rejects controls selected from different retained Shorts reels", () => {
|
||||
const measurement = validNativeShortsPairGeometry();
|
||||
measurement.dislike.reelIndex = 1;
|
||||
|
||||
expect(() => assertNativeShortsPairGeometry(measurement)).toThrow(/belong to different reels/);
|
||||
});
|
||||
|
||||
test("rejects a native Dislike that is not immediately after Like", () => {
|
||||
const measurement = validNativeShortsPairGeometry();
|
||||
measurement.dislike.actionIndex += 1;
|
||||
|
||||
expect(() => assertNativeShortsPairGeometry(measurement)).toThrow(/not immediately after Like/);
|
||||
});
|
||||
|
||||
test("rejects a shrunken native Dislike icon", () => {
|
||||
const measurement = validNativeShortsPairGeometry();
|
||||
measurement.dislike.icon.width = 8;
|
||||
|
||||
expect(() => assertNativeShortsPairGeometry(measurement)).toThrow(/Native Shorts Dislike icon container width/);
|
||||
});
|
||||
|
||||
test("rejects broken native action-stack spacing", () => {
|
||||
const measurement = validNativeShortsPairGeometry();
|
||||
measurement.dislike.host.y += 12;
|
||||
|
||||
expect(() => assertNativeShortsPairGeometry(measurement)).toThrow(
|
||||
/Gap between native Shorts Like and Dislike action hosts/,
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects native Dislike count typography that diverges from Like", () => {
|
||||
const measurement = validNativeShortsPairGeometry();
|
||||
measurement.dislike.countStyle.fontFamily = "Comic Sans MS";
|
||||
|
||||
expect(() => assertNativeShortsPairGeometry(measurement)).toThrow(/count fontFamily does not match/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("live Shorts full-stack visual readiness", () => {
|
||||
const viewport = { height: 844, width: 390 };
|
||||
const validStack = () => [0, 1, 2, 3, 4].map((index) => box(330, 100 + index * 78, 48, 78));
|
||||
|
||||
test("accepts five aligned visible controls and painted icons", () => {
|
||||
expect(() => assertShortsActionStackGeometry(validStack(), viewport)).not.toThrow();
|
||||
expect(
|
||||
isShortsIconVisualReady({
|
||||
effectiveOpacity: 1,
|
||||
paintedGraphicCount: 1,
|
||||
rendered: true,
|
||||
svgPresent: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("rejects an incomplete Shorts stack", () => {
|
||||
expect(() => assertShortsActionStackGeometry(validStack().slice(0, 4), viewport)).toThrow(
|
||||
/full Shorts action stack/,
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects a misaligned or clipped control", () => {
|
||||
const misaligned = validStack();
|
||||
misaligned[3].x -= 8;
|
||||
expect(() => assertShortsActionStackGeometry(misaligned, viewport)).toThrow(/horizontal center/);
|
||||
|
||||
const clipped = validStack();
|
||||
clipped[4].y = 800;
|
||||
expect(() => assertShortsActionStackGeometry(clipped, viewport)).toThrow(/bottom edge/);
|
||||
});
|
||||
|
||||
test.each([
|
||||
["unhydrated SVG", { effectiveOpacity: 1, paintedGraphicCount: 0, rendered: true, svgPresent: true }],
|
||||
["missing SVG", { effectiveOpacity: 1, paintedGraphicCount: 0, rendered: true, svgPresent: false }],
|
||||
["hidden ancestor", { effectiveOpacity: 0, paintedGraphicCount: 1, rendered: true, svgPresent: true }],
|
||||
["non-rendered ancestor", { effectiveOpacity: 1, paintedGraphicCount: 1, rendered: false, svgPresent: true }],
|
||||
])("does not screenshot a %s as a ready icon", (_label, state) => {
|
||||
expect(isShortsIconVisualReady(state)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
const CREDENTIALS_KEY = "rydVoteCredentials";
|
||||
|
||||
function hasModernMethod(name) {
|
||||
return typeof GM !== "undefined" && typeof GM?.[name] === "function";
|
||||
}
|
||||
|
||||
async function getStoredValue() {
|
||||
if (hasModernMethod("getValue")) {
|
||||
return GM.getValue(CREDENTIALS_KEY, null);
|
||||
}
|
||||
if (typeof GM_getValue === "function") {
|
||||
return GM_getValue(CREDENTIALS_KEY, null);
|
||||
}
|
||||
throw new Error("Userscript storage API is unavailable");
|
||||
}
|
||||
|
||||
async function setStoredValue(value) {
|
||||
if (hasModernMethod("setValue")) {
|
||||
await GM.setValue(CREDENTIALS_KEY, value);
|
||||
return;
|
||||
}
|
||||
if (typeof GM_setValue === "function") {
|
||||
await GM_setValue(CREDENTIALS_KEY, value);
|
||||
return;
|
||||
}
|
||||
throw new Error("Userscript storage API is unavailable");
|
||||
}
|
||||
|
||||
async function deleteStoredValue() {
|
||||
if (hasModernMethod("deleteValue")) {
|
||||
await GM.deleteValue(CREDENTIALS_KEY);
|
||||
return;
|
||||
}
|
||||
if (typeof GM_deleteValue === "function") {
|
||||
await GM_deleteValue(CREDENTIALS_KEY);
|
||||
return;
|
||||
}
|
||||
|
||||
// Old managers may expose get/set without delete. Null is treated as an
|
||||
// empty credential by load(), while still allowing the client to recover.
|
||||
await setStoredValue(null);
|
||||
}
|
||||
|
||||
function createGmCredentialStore() {
|
||||
return {
|
||||
async load() {
|
||||
const value = await getStoredValue();
|
||||
if (!value || typeof value !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
userId: value.userId,
|
||||
registrationConfirmed: value.registrationConfirmed === true,
|
||||
};
|
||||
},
|
||||
|
||||
async save(credentials) {
|
||||
await setStoredValue({
|
||||
userId: credentials.userId,
|
||||
registrationConfirmed: credentials.registrationConfirmed === true,
|
||||
});
|
||||
},
|
||||
|
||||
async clear() {
|
||||
await deleteStoredValue();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export { CREDENTIALS_KEY, createGmCredentialStore };
|
||||
@@ -0,0 +1,86 @@
|
||||
import { CREDENTIALS_KEY, createGmCredentialStore } from "./gm-credential-store";
|
||||
|
||||
const LEGACY_METHODS = ["GM_getValue", "GM_setValue", "GM_deleteValue"];
|
||||
|
||||
afterEach(() => {
|
||||
delete global.GM;
|
||||
LEGACY_METHODS.forEach((name) => delete global[name]);
|
||||
});
|
||||
|
||||
describe("createGmCredentialStore", () => {
|
||||
it("uses the modern GM storage contract", async () => {
|
||||
let value = null;
|
||||
global.GM = {
|
||||
getValue: jest.fn(async (_key, fallbackValue) => value ?? fallbackValue),
|
||||
setValue: jest.fn(async (_key, nextValue) => {
|
||||
value = nextValue;
|
||||
}),
|
||||
deleteValue: jest.fn(async () => {
|
||||
value = null;
|
||||
}),
|
||||
};
|
||||
const store = createGmCredentialStore();
|
||||
|
||||
expect(await store.load()).toBeNull();
|
||||
await store.save({ userId: "modern-user", registrationConfirmed: true });
|
||||
expect(await store.load()).toEqual({ userId: "modern-user", registrationConfirmed: true });
|
||||
expect(global.GM.setValue).toHaveBeenCalledWith(CREDENTIALS_KEY, {
|
||||
userId: "modern-user",
|
||||
registrationConfirmed: true,
|
||||
});
|
||||
|
||||
await store.clear();
|
||||
expect(global.GM.deleteValue).toHaveBeenCalledWith(CREDENTIALS_KEY);
|
||||
expect(await store.load()).toBeNull();
|
||||
});
|
||||
|
||||
it("uses legacy synchronous storage methods when modern GM methods are absent", async () => {
|
||||
let value = null;
|
||||
global.GM_getValue = jest.fn((_key, fallbackValue) => value ?? fallbackValue);
|
||||
global.GM_setValue = jest.fn((_key, nextValue) => {
|
||||
value = nextValue;
|
||||
});
|
||||
global.GM_deleteValue = jest.fn(() => {
|
||||
value = null;
|
||||
});
|
||||
const store = createGmCredentialStore();
|
||||
|
||||
await store.save({ userId: "legacy-user", registrationConfirmed: true });
|
||||
expect(await store.load()).toEqual({ userId: "legacy-user", registrationConfirmed: true });
|
||||
await store.clear();
|
||||
expect(global.GM_deleteValue).toHaveBeenCalledWith(CREDENTIALS_KEY);
|
||||
expect(await store.load()).toBeNull();
|
||||
});
|
||||
|
||||
it("falls back to a null value when a legacy manager has no delete method", async () => {
|
||||
global.GM_getValue = jest.fn(async (_key, fallbackValue) => fallbackValue);
|
||||
global.GM_setValue = jest.fn(async () => undefined);
|
||||
|
||||
await createGmCredentialStore().clear();
|
||||
expect(global.GM_setValue).toHaveBeenCalledWith(CREDENTIALS_KEY, null);
|
||||
});
|
||||
|
||||
it("normalizes invalid and unconfirmed stored values", async () => {
|
||||
global.GM = {
|
||||
getValue: jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce("invalid")
|
||||
.mockResolvedValueOnce({ userId: "pending", registrationConfirmed: false }),
|
||||
};
|
||||
const store = createGmCredentialStore();
|
||||
|
||||
expect(await store.load()).toBeNull();
|
||||
expect(await store.load()).toEqual({ userId: "pending", registrationConfirmed: false });
|
||||
});
|
||||
|
||||
it("propagates storage failures and reports a missing storage API", async () => {
|
||||
global.GM = { getValue: jest.fn(async () => Promise.reject(new Error("GM storage failed"))) };
|
||||
await expect(createGmCredentialStore().load()).rejects.toThrow("GM storage failed");
|
||||
|
||||
delete global.GM;
|
||||
await expect(createGmCredentialStore().load()).rejects.toThrow("Userscript storage API is unavailable");
|
||||
await expect(createGmCredentialStore().save({ userId: "no-storage", registrationConfirmed: true })).rejects.toThrow(
|
||||
"Userscript storage API is unavailable",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
const SYNTHETIC_DISLIKE_KEY_PREFIX = "rydSyntheticDislikedShort:";
|
||||
|
||||
// Each currently disliked Short owns one independent key. Deliberately do not
|
||||
// evict selected videos: forgetting one would make the next click submit -1
|
||||
// again instead of the required neutral (0) transition.
|
||||
|
||||
function hasModernMethod(name) {
|
||||
return typeof GM !== "undefined" && typeof GM?.[name] === "function";
|
||||
}
|
||||
|
||||
async function getStoredValue(key, fallbackValue) {
|
||||
if (hasModernMethod("getValue")) {
|
||||
return GM.getValue(key, fallbackValue);
|
||||
}
|
||||
if (typeof GM_getValue === "function") {
|
||||
return GM_getValue(key, fallbackValue);
|
||||
}
|
||||
throw new Error("Userscript storage API is unavailable");
|
||||
}
|
||||
|
||||
async function setStoredValue(key, value) {
|
||||
if (hasModernMethod("setValue")) {
|
||||
await GM.setValue(key, value);
|
||||
return;
|
||||
}
|
||||
if (typeof GM_setValue === "function") {
|
||||
await GM_setValue(key, value);
|
||||
return;
|
||||
}
|
||||
throw new Error("Userscript storage API is unavailable");
|
||||
}
|
||||
|
||||
async function deleteStoredValue(key) {
|
||||
if (hasModernMethod("deleteValue")) {
|
||||
await GM.deleteValue(key);
|
||||
return;
|
||||
}
|
||||
if (typeof GM_deleteValue === "function") {
|
||||
await GM_deleteValue(key);
|
||||
return;
|
||||
}
|
||||
|
||||
// Some legacy managers do not expose deleteValue. An explicit false value
|
||||
// has the same read semantics and prevents a stale state from returning.
|
||||
await setStoredValue(key, false);
|
||||
}
|
||||
|
||||
function validateVideoId(videoId) {
|
||||
if (typeof videoId !== "string" || videoId.length === 0) {
|
||||
throw new TypeError("videoId must be a non-empty string");
|
||||
}
|
||||
}
|
||||
|
||||
function syntheticDislikeKey(videoId) {
|
||||
return `${SYNTHETIC_DISLIKE_KEY_PREFIX}${videoId}`;
|
||||
}
|
||||
|
||||
function createGmSyntheticDislikeStore() {
|
||||
let mutationQueue = Promise.resolve();
|
||||
|
||||
function enqueueMutation(mutation) {
|
||||
const result = mutationQueue.catch(() => undefined).then(mutation);
|
||||
mutationQueue = result;
|
||||
return result;
|
||||
}
|
||||
|
||||
return {
|
||||
async isDisliked(videoId) {
|
||||
validateVideoId(videoId);
|
||||
await mutationQueue.catch(() => undefined);
|
||||
return (await getStoredValue(syntheticDislikeKey(videoId), false)) === true;
|
||||
},
|
||||
|
||||
async setDisliked(videoId, disliked) {
|
||||
validateVideoId(videoId);
|
||||
if (typeof disliked !== "boolean") {
|
||||
throw new TypeError("disliked must be a boolean");
|
||||
}
|
||||
|
||||
return enqueueMutation(() =>
|
||||
disliked ? setStoredValue(syntheticDislikeKey(videoId), true) : deleteStoredValue(syntheticDislikeKey(videoId)),
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export { SYNTHETIC_DISLIKE_KEY_PREFIX, createGmSyntheticDislikeStore };
|
||||
@@ -0,0 +1,156 @@
|
||||
import { SYNTHETIC_DISLIKE_KEY_PREFIX, createGmSyntheticDislikeStore } from "./gm-synthetic-dislike-store";
|
||||
|
||||
const LEGACY_METHODS = ["GM_getValue", "GM_setValue", "GM_deleteValue"];
|
||||
const stateKey = (videoId) => `${SYNTHETIC_DISLIKE_KEY_PREFIX}${videoId}`;
|
||||
|
||||
function installModernStorage(initialValues = {}) {
|
||||
const values = new Map(Object.entries(initialValues));
|
||||
global.GM = {
|
||||
getValue: jest.fn(async (key, fallbackValue) => (values.has(key) ? values.get(key) : fallbackValue)),
|
||||
setValue: jest.fn(async (key, value) => {
|
||||
values.set(key, value);
|
||||
}),
|
||||
deleteValue: jest.fn(async (key) => {
|
||||
values.delete(key);
|
||||
}),
|
||||
};
|
||||
return values;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
delete global.GM;
|
||||
LEGACY_METHODS.forEach((name) => delete global[name]);
|
||||
});
|
||||
|
||||
describe("createGmSyntheticDislikeStore", () => {
|
||||
it("uses independent per-video values as the authoritative modern GM state", async () => {
|
||||
const values = installModernStorage({ [stateKey("existing-video")]: true });
|
||||
const store = createGmSyntheticDislikeStore();
|
||||
|
||||
await expect(store.isDisliked("existing-video")).resolves.toBe(true);
|
||||
await expect(store.isDisliked("missing-video")).resolves.toBe(false);
|
||||
|
||||
await store.setDisliked("new-video", true);
|
||||
expect(values.get(stateKey("new-video"))).toBe(true);
|
||||
|
||||
await store.setDisliked("existing-video", false);
|
||||
expect(values.has(stateKey("existing-video"))).toBe(false);
|
||||
await expect(store.isDisliked("existing-video")).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("supports legacy synchronous GM methods", async () => {
|
||||
const values = new Map();
|
||||
global.GM_getValue = jest.fn((key, fallbackValue) => (values.has(key) ? values.get(key) : fallbackValue));
|
||||
global.GM_setValue = jest.fn((key, value) => values.set(key, value));
|
||||
global.GM_deleteValue = jest.fn((key) => values.delete(key));
|
||||
const store = createGmSyntheticDislikeStore();
|
||||
|
||||
await store.setDisliked("legacy-video", true);
|
||||
expect(global.GM_setValue).toHaveBeenCalledWith(stateKey("legacy-video"), true);
|
||||
await expect(store.isDisliked("legacy-video")).resolves.toBe(true);
|
||||
|
||||
await store.setDisliked("legacy-video", false);
|
||||
expect(global.GM_deleteValue).toHaveBeenCalledWith(stateKey("legacy-video"));
|
||||
await expect(store.isDisliked("legacy-video")).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("stores false when a legacy manager has no delete method", async () => {
|
||||
const values = new Map();
|
||||
global.GM_getValue = jest.fn((key, fallbackValue) => (values.has(key) ? values.get(key) : fallbackValue));
|
||||
global.GM_setValue = jest.fn((key, value) => values.set(key, value));
|
||||
const store = createGmSyntheticDislikeStore();
|
||||
|
||||
await store.setDisliked("legacy-video", true);
|
||||
await store.setDisliked("legacy-video", false);
|
||||
|
||||
expect(values.get(stateKey("legacy-video"))).toBe(false);
|
||||
await expect(store.isDisliked("legacy-video")).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("does not lose state when two store instances update different videos concurrently", async () => {
|
||||
const values = installModernStorage();
|
||||
const firstStore = createGmSyntheticDislikeStore();
|
||||
const secondStore = createGmSyntheticDislikeStore();
|
||||
|
||||
await Promise.all([firstStore.setDisliked("first-video", true), secondStore.setDisliked("second-video", true)]);
|
||||
|
||||
expect(values.get(stateKey("first-video"))).toBe(true);
|
||||
expect(values.get(stateKey("second-video"))).toBe(true);
|
||||
await expect(firstStore.isDisliked("first-video")).resolves.toBe(true);
|
||||
await expect(secondStore.isDisliked("second-video")).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("uses the last completed per-video write for same-video races", async () => {
|
||||
const values = new Map();
|
||||
let releaseTrueWrite;
|
||||
let trueWriteStarted;
|
||||
const trueWriteGate = new Promise((resolve) => {
|
||||
releaseTrueWrite = resolve;
|
||||
});
|
||||
const trueWriteEntered = new Promise((resolve) => {
|
||||
trueWriteStarted = resolve;
|
||||
});
|
||||
global.GM = {
|
||||
getValue: jest.fn(async (key, fallbackValue) => (values.has(key) ? values.get(key) : fallbackValue)),
|
||||
setValue: jest.fn(async (key, value) => {
|
||||
if (key === stateKey("shared-video") && value === true) {
|
||||
trueWriteStarted();
|
||||
await trueWriteGate;
|
||||
}
|
||||
values.set(key, value);
|
||||
}),
|
||||
deleteValue: jest.fn(async (key) => {
|
||||
values.delete(key);
|
||||
}),
|
||||
};
|
||||
const firstStore = createGmSyntheticDislikeStore();
|
||||
const secondStore = createGmSyntheticDislikeStore();
|
||||
|
||||
const delayedTrue = firstStore.setDisliked("shared-video", true);
|
||||
await trueWriteEntered;
|
||||
await secondStore.setDisliked("shared-video", false);
|
||||
releaseTrueWrite();
|
||||
await delayedTrue;
|
||||
|
||||
await expect(firstStore.isDisliked("shared-video")).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("treats non-true stored values as neutral", async () => {
|
||||
installModernStorage({
|
||||
[stateKey("false-video")]: false,
|
||||
[stateKey("corrupt-video")]: { disliked: true },
|
||||
});
|
||||
const store = createGmSyntheticDislikeStore();
|
||||
|
||||
await expect(store.isDisliked("false-video")).resolves.toBe(false);
|
||||
await expect(store.isDisliked("corrupt-video")).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("propagates storage errors and recovers its local mutation queue", async () => {
|
||||
global.GM = { getValue: jest.fn(async () => Promise.reject(new Error("read failed"))) };
|
||||
await expect(createGmSyntheticDislikeStore().isDisliked("video")).rejects.toThrow("read failed");
|
||||
|
||||
const values = installModernStorage();
|
||||
global.GM.setValue.mockRejectedValueOnce(new Error("write failed"));
|
||||
const store = createGmSyntheticDislikeStore();
|
||||
await expect(store.setDisliked("video", true)).rejects.toThrow("write failed");
|
||||
await expect(store.setDisliked("video", true)).resolves.toBeUndefined();
|
||||
expect(values.get(stateKey("video"))).toBe(true);
|
||||
|
||||
delete global.GM;
|
||||
await expect(createGmSyntheticDislikeStore().isDisliked("video")).rejects.toThrow(
|
||||
"Userscript storage API is unavailable",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects invalid arguments without accessing storage", async () => {
|
||||
installModernStorage();
|
||||
const store = createGmSyntheticDislikeStore();
|
||||
|
||||
await expect(store.isDisliked(123)).rejects.toThrow(TypeError);
|
||||
await expect(store.setDisliked("", true)).rejects.toThrow(TypeError);
|
||||
await expect(store.setDisliked("video", 1)).rejects.toThrow(TypeError);
|
||||
expect(global.GM.getValue).not.toHaveBeenCalled();
|
||||
expect(global.GM.setValue).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,87 @@
|
||||
const fs = require("fs");
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
const vm = require("vm");
|
||||
const webpack = require("webpack");
|
||||
const createUserscriptConfig = require("../../webpack.userscript.config");
|
||||
const userscriptMeta = require("./userscript.meta");
|
||||
|
||||
const ARTIFACT_PATH = path.join(__dirname, "Return Youtube Dislike.user.js");
|
||||
|
||||
function compile(config) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const compiler = webpack(config);
|
||||
compiler.run((error, stats) => {
|
||||
const finish = (closeError) => {
|
||||
if (error || closeError) {
|
||||
reject(error || closeError);
|
||||
return;
|
||||
}
|
||||
if (stats.hasErrors()) {
|
||||
reject(new Error(stats.toString({ all: false, errors: true })));
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
};
|
||||
compiler.close(finish);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
describe("generated userscript artifact", () => {
|
||||
let artifact;
|
||||
|
||||
beforeAll(() => {
|
||||
artifact = fs.readFileSync(ARTIFACT_PATH, "utf8");
|
||||
});
|
||||
|
||||
it("contains the candidate metadata and required modern and legacy grants", () => {
|
||||
expect(artifact.startsWith("// ==UserScript==\n")).toBe(true);
|
||||
expect(artifact).toContain(`// @version ${userscriptMeta.version}`);
|
||||
expect(userscriptMeta.version).toBe("3.2.0");
|
||||
expect(artifact).toContain(`// @downloadURL ${userscriptMeta.downloadURL}`);
|
||||
expect(artifact).toContain(`// @updateURL ${userscriptMeta.updateURL}`);
|
||||
for (const grant of userscriptMeta.grants) {
|
||||
expect(artifact).toContain(`// @grant ${grant}`);
|
||||
}
|
||||
expect(artifact).not.toContain("@grant GM.xmlHttpRequest");
|
||||
expect(artifact).not.toContain("@connect");
|
||||
});
|
||||
|
||||
it("is a standalone syntactically valid script with editable user options", () => {
|
||||
expect(() => new vm.Script(artifact, { filename: ARTIFACT_PATH })).not.toThrow();
|
||||
expect(artifact).not.toMatch(/^\s*(?:import|export)\s/m);
|
||||
expect(artifact).toContain("BEGIN USER OPTIONS");
|
||||
expect(artifact).toContain("disableVoteSubmission: false");
|
||||
expect(artifact).not.toContain("data-ryd-userscript-version");
|
||||
});
|
||||
|
||||
it("builds an unpublished live-test artifact with a runtime marker and no update URLs", async () => {
|
||||
const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "ryd-userscript-live-build-"));
|
||||
try {
|
||||
const config = createUserscriptConfig({ liveTest: "true" }, { mode: "production" });
|
||||
config.output = { ...config.output, path: temporaryDirectory };
|
||||
await compile(config);
|
||||
const liveArtifact = fs.readFileSync(path.join(temporaryDirectory, config.output.filename), "utf8");
|
||||
expect(liveArtifact).toContain("data-ryd-userscript-version");
|
||||
expect(liveArtifact).toContain("// @name Return YouTube Dislike [Live Test]");
|
||||
expect(liveArtifact).not.toContain("// @downloadURL");
|
||||
expect(liveArtifact).not.toContain("// @updateURL");
|
||||
} finally {
|
||||
fs.rmSync(temporaryDirectory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("rebuilds deterministically", async () => {
|
||||
const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "ryd-userscript-build-"));
|
||||
try {
|
||||
const config = createUserscriptConfig({}, { mode: "production" });
|
||||
config.output = { ...config.output, path: temporaryDirectory };
|
||||
await compile(config);
|
||||
const rebuilt = fs.readFileSync(path.join(temporaryDirectory, config.output.filename), "utf8");
|
||||
expect(rebuilt).toBe(artifact);
|
||||
} finally {
|
||||
fs.rmSync(temporaryDirectory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
"3.2.0"
|
||||
@@ -0,0 +1,29 @@
|
||||
const version = require("./userscript-version.json");
|
||||
|
||||
module.exports = {
|
||||
name: "Return YouTube Dislike",
|
||||
namespace: "https://www.returnyoutubedislike.com/",
|
||||
homepage: "https://www.returnyoutubedislike.com/",
|
||||
version,
|
||||
encoding: "utf-8",
|
||||
description: "Return of the YouTube Dislike, Based off https://www.returnyoutubedislike.com/",
|
||||
icon: "https://github.com/Anarios/return-youtube-dislike/raw/main/Icons/Return%20Youtube%20Dislike%20-%20Transparent.png",
|
||||
author: "Anarios & JRWR",
|
||||
match: ["*://*.youtube.com/*"],
|
||||
exclude: ["*://music.youtube.com/*", "*://*.music.youtube.com/*"],
|
||||
compatible: ["chrome", "firefox", "opera", "safari", "edge"],
|
||||
downloadURL:
|
||||
"https://github.com/Anarios/return-youtube-dislike/raw/main/Extensions/UserScript/Return%20Youtube%20Dislike.user.js",
|
||||
updateURL:
|
||||
"https://github.com/Anarios/return-youtube-dislike/raw/main/Extensions/UserScript/Return%20Youtube%20Dislike.user.js",
|
||||
grants: [
|
||||
"GM.getValue",
|
||||
"GM.setValue",
|
||||
"GM.deleteValue",
|
||||
"GM_getValue",
|
||||
"GM_setValue",
|
||||
"GM_deleteValue",
|
||||
"GM_addStyle",
|
||||
],
|
||||
runAt: "document-end",
|
||||
};
|
||||
@@ -19,7 +19,12 @@
|
||||
} */
|
||||
|
||||
#ryd-bar-container {
|
||||
background: var(--yt-spec-icon-disabled);
|
||||
background: #737373;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--yt-spec-text-primary, #f1f1f1) 55%,
|
||||
var(--yt-spec-base-background, #0f0f0f) 45%
|
||||
);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { config, getApiUrl, getApiEndpoint, getChangelogUrl } from "./src/config";
|
||||
import { createVoteClient } from "../common/vote-client";
|
||||
import { createBrowserCredentialStore } from "./src/vote-client-adapter";
|
||||
|
||||
const apiUrl = getApiUrl();
|
||||
const voteDisabledIconName = config.voteDisabledIconName;
|
||||
@@ -13,7 +15,15 @@ let extConfig = { ...config.defaultExtConfig };
|
||||
if (isChrome()) api = chrome;
|
||||
else if (isFirefox()) api = browser;
|
||||
|
||||
const voteClient = createVoteClient({
|
||||
apiBaseUrl: apiUrl,
|
||||
fetchImpl: (...args) => fetch(...args),
|
||||
credentialStore: createBrowserCredentialStore(api.storage.sync, () => api.runtime?.lastError),
|
||||
cryptoImpl: globalThis.crypto,
|
||||
});
|
||||
|
||||
initExtConfig();
|
||||
voteClient.ensureRegistered().catch((error) => console.error("Vote registration failed", error));
|
||||
|
||||
function broadcastPatreonStatus(authenticated, user, sessionToken) {
|
||||
chrome.tabs.query({}, (tabs) => {
|
||||
@@ -175,10 +185,16 @@ api.runtime.onMessage.addListener((request, sender, sendResponse) => {
|
||||
toSend = [];
|
||||
}
|
||||
} else if (request.message == "register") {
|
||||
register();
|
||||
voteClient
|
||||
.ensureRegistered()
|
||||
.then(({ userId }) => sendResponse?.({ success: true, userId }))
|
||||
.catch((error) => sendResponse?.({ success: false, error: error.message }));
|
||||
return true;
|
||||
} else if (request.message == "send_vote") {
|
||||
sendVote(request.videoId, request.vote);
|
||||
voteClient
|
||||
.submitVote(request.videoId, request.vote)
|
||||
.then(() => sendResponse?.({ success: true }))
|
||||
.catch((error) => sendResponse?.({ success: false, error: error.message }));
|
||||
return true;
|
||||
} else if (request.message === "patreon_oauth_login") {
|
||||
(async () => {
|
||||
@@ -486,156 +502,9 @@ if (api?.runtime?.onStartup && typeof api.runtime.onStartup.addListener === "fun
|
||||
// }
|
||||
// });
|
||||
|
||||
async function sendVote(videoId, vote, depth = 1) {
|
||||
api.storage.sync.get(null, async (storageResult) => {
|
||||
if (!storageResult.userId || !storageResult.registrationConfirmed) {
|
||||
await register();
|
||||
}
|
||||
let voteResponse = await fetch(getApiEndpoint("/interact/vote"), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
userId: storageResult.userId,
|
||||
videoId,
|
||||
value: vote,
|
||||
}),
|
||||
});
|
||||
|
||||
if (voteResponse.status == 401 && depth > 0) {
|
||||
await register();
|
||||
await sendVote(videoId, vote, depth - 1);
|
||||
return;
|
||||
} else if (voteResponse.status == 401) {
|
||||
// We have already tried registering
|
||||
return;
|
||||
}
|
||||
|
||||
const voteResponseJson = await voteResponse.json();
|
||||
const solvedPuzzle = await solvePuzzle(voteResponseJson);
|
||||
if (!solvedPuzzle.solution) {
|
||||
await sendVote(videoId, vote);
|
||||
return;
|
||||
}
|
||||
|
||||
await fetch(getApiEndpoint("/interact/confirmVote"), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
...solvedPuzzle,
|
||||
userId: storageResult.userId,
|
||||
videoId,
|
||||
}),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function register() {
|
||||
const userId = generateUserID();
|
||||
api.storage.sync.set({ userId });
|
||||
const registrationResponse = await fetch(getApiEndpoint(`/puzzle/registration?userId=${userId}`), {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
},
|
||||
}).then((response) => response.json());
|
||||
const solvedPuzzle = await solvePuzzle(registrationResponse);
|
||||
if (!solvedPuzzle.solution) {
|
||||
await register();
|
||||
return;
|
||||
}
|
||||
const result = await fetch(getApiEndpoint(`/puzzle/registration?userId=${userId}`), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(solvedPuzzle),
|
||||
}).then((response) => response.json());
|
||||
if (result === true) {
|
||||
return api.storage.sync.set({ registrationConfirmed: true });
|
||||
}
|
||||
}
|
||||
|
||||
api.storage.sync.get(null, async (res) => {
|
||||
if (!res || !res.userId || !res.registrationConfirmed) {
|
||||
await register();
|
||||
}
|
||||
});
|
||||
|
||||
const sentIds = new Set();
|
||||
let toSend = [];
|
||||
|
||||
function countLeadingZeroes(uInt8View, limit) {
|
||||
let zeroes = 0;
|
||||
let value = 0;
|
||||
for (let i = 0; i < uInt8View.length; i++) {
|
||||
value = uInt8View[i];
|
||||
if (value === 0) {
|
||||
zeroes += 8;
|
||||
} else {
|
||||
let count = 1;
|
||||
if (value >>> 4 === 0) {
|
||||
count += 4;
|
||||
value <<= 4;
|
||||
}
|
||||
if (value >>> 6 === 0) {
|
||||
count += 2;
|
||||
value <<= 2;
|
||||
}
|
||||
zeroes += count - (value >>> 7);
|
||||
break;
|
||||
}
|
||||
if (zeroes >= limit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return zeroes;
|
||||
}
|
||||
|
||||
async function solvePuzzle(puzzle) {
|
||||
let challenge = Uint8Array.from(atob(puzzle.challenge), (c) => c.charCodeAt(0));
|
||||
let buffer = new ArrayBuffer(20);
|
||||
let uInt8View = new Uint8Array(buffer);
|
||||
let uInt32View = new Uint32Array(buffer);
|
||||
let maxCount = Math.pow(2, puzzle.difficulty) * 3;
|
||||
for (let i = 4; i < 20; i++) {
|
||||
uInt8View[i] = challenge[i - 4];
|
||||
}
|
||||
|
||||
for (let i = 0; i < maxCount; i++) {
|
||||
uInt32View[0] = i;
|
||||
let hash = await crypto.subtle.digest("SHA-512", buffer);
|
||||
let hashUint8 = new Uint8Array(hash);
|
||||
if (countLeadingZeroes(hashUint8) >= puzzle.difficulty) {
|
||||
return {
|
||||
solution: btoa(String.fromCharCode.apply(null, uInt8View.slice(0, 4))),
|
||||
};
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function generateUserID(length = 36) {
|
||||
const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||
let result = "";
|
||||
if (crypto && crypto.getRandomValues) {
|
||||
const values = new Uint32Array(length);
|
||||
crypto.getRandomValues(values);
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += charset[values[i] % charset.length];
|
||||
}
|
||||
return result;
|
||||
} else {
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += charset[Math.floor(Math.random() * charset.length)];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
function storageChangeHandler(changes, area) {
|
||||
if (changes.disableVoteSubmission !== undefined) {
|
||||
handleDisableVoteSubmissionChangeEvent(changes.disableVoteSubmission.newValue);
|
||||
|
||||
@@ -1,9 +1,22 @@
|
||||
import { getButtons } from "./src/buttons";
|
||||
import { isShorts, setInitialState, initExtConfig } from "./src/state";
|
||||
import { getBrowser, isVideoLoaded } from "./src/utils";
|
||||
import { getButtons, getDislikeButton, getLikeButton, hasRenderedBox, markButtonsForVideo } from "./src/buttons";
|
||||
import {
|
||||
hasLoadedStateForVideo,
|
||||
initExtConfig,
|
||||
isLikesDisabled,
|
||||
isShorts,
|
||||
restoreCurrentState,
|
||||
setInitialState,
|
||||
} from "./src/state";
|
||||
import { getBrowser, getVideoId, isVideoLoaded } from "./src/utils";
|
||||
import { addLikeDislikeEventListener, createSmartimationObserver, storageChangeHandler } from "./src/events";
|
||||
import { createInitializationCycleRunner } from "./src/initialization-cycle";
|
||||
import { initPatreonFeatures } from "./src/patreon";
|
||||
|
||||
if (__RYD_LIVE_TEST_BUILD__) {
|
||||
document.documentElement.setAttribute("data-ryd-extension-version", getBrowser().runtime.getManifest().version);
|
||||
document.documentElement.setAttribute("data-ryd-extension-build", __RYD_LIVE_BUILD_ID__);
|
||||
}
|
||||
|
||||
await initExtConfig();
|
||||
initPatreonFeatures();
|
||||
|
||||
@@ -12,6 +25,11 @@ let isSetInitialStateDone = false;
|
||||
let isStorageListenerRegistered = false;
|
||||
let shortsNavigationObserver = null;
|
||||
let shortsNavigationObserverTarget = null;
|
||||
let initializedVideoId = null;
|
||||
let initializedButtons = null;
|
||||
let initializedLikeButton = null;
|
||||
let initializedDislikeButton = null;
|
||||
let initializationCheckRunning = false;
|
||||
|
||||
function ensureShortsNavigationObserver() {
|
||||
if (!isShorts()) {
|
||||
@@ -51,19 +69,44 @@ function ensureShortsNavigationObserver() {
|
||||
}
|
||||
|
||||
async function checkForInitialization() {
|
||||
if (initializationCheckRunning) return;
|
||||
initializationCheckRunning = true;
|
||||
try {
|
||||
if (isShorts()) {
|
||||
ensureShortsNavigationObserver();
|
||||
}
|
||||
|
||||
if ((isShorts() && isVideoLoaded()) || (getButtons()?.offsetParent && isVideoLoaded())) {
|
||||
const buttons = getButtons();
|
||||
const videoId = getVideoId(window.location.href);
|
||||
if ((isShorts() && isVideoLoaded()) || (hasRenderedBox(buttons) && isVideoLoaded())) {
|
||||
if (!buttons) return;
|
||||
const likeButton = getLikeButton();
|
||||
const dislikeButton = getDislikeButton();
|
||||
if (!likeButton || !dislikeButton) return;
|
||||
if (jsInitChecktimer !== null) {
|
||||
clearInterval(jsInitChecktimer);
|
||||
jsInitChecktimer = null;
|
||||
}
|
||||
markButtonsForVideo(buttons, videoId);
|
||||
createSmartimationObserver();
|
||||
addLikeDislikeEventListener();
|
||||
await setInitialState();
|
||||
if (hasLoadedStateForVideo(videoId)) {
|
||||
restoreCurrentState();
|
||||
} else {
|
||||
await setInitialState();
|
||||
}
|
||||
if (
|
||||
videoId !== getVideoId(window.location.href) ||
|
||||
buttons !== getButtons() ||
|
||||
likeButton !== getLikeButton() ||
|
||||
dislikeButton !== getDislikeButton()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
initializedVideoId = videoId;
|
||||
initializedButtons = buttons;
|
||||
initializedLikeButton = likeButton;
|
||||
initializedDislikeButton = dislikeButton;
|
||||
isSetInitialStateDone = true;
|
||||
if (!isStorageListenerRegistered) {
|
||||
getBrowser().storage.onChanged.addListener(storageChangeHandler);
|
||||
@@ -71,14 +114,13 @@ async function checkForInitialization() {
|
||||
}
|
||||
}
|
||||
} catch (exception) {
|
||||
if (!isSetInitialStateDone) {
|
||||
console.log("error");
|
||||
await setInitialState();
|
||||
}
|
||||
console.warn("Initialization failed; retrying when the current controls are ready.", exception);
|
||||
} finally {
|
||||
initializationCheckRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function triggerInitializationCycle() {
|
||||
const initializationCycle = createInitializationCycleRunner(async () => {
|
||||
isSetInitialStateDone = false;
|
||||
|
||||
if (jsInitChecktimer !== null) {
|
||||
@@ -99,6 +141,10 @@ async function triggerInitializationCycle() {
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
});
|
||||
|
||||
function triggerInitializationCycle() {
|
||||
return initializationCycle.request();
|
||||
}
|
||||
|
||||
async function setEventListeners() {
|
||||
@@ -111,6 +157,34 @@ document.addEventListener("yt-navigate-finish", async function (event) {
|
||||
await setEventListeners();
|
||||
});
|
||||
|
||||
function watchControlsNeedInitialization() {
|
||||
const videoId = getVideoId(window.location.href);
|
||||
if (!videoId || initializationCycle.isRunning() || jsInitChecktimer !== null) return false;
|
||||
const buttons = getButtons();
|
||||
if (!buttons) return true;
|
||||
const likeButton = getLikeButton();
|
||||
const dislikeButton = getDislikeButton();
|
||||
return (
|
||||
initializedVideoId !== videoId ||
|
||||
initializedButtons !== buttons ||
|
||||
initializedLikeButton !== likeButton ||
|
||||
initializedDislikeButton !== dislikeButton ||
|
||||
!buttons?.isConnected ||
|
||||
!likeButton?.isConnected ||
|
||||
!dislikeButton?.isConnected ||
|
||||
!buttons?.contains(likeButton) ||
|
||||
!buttons?.contains(dislikeButton) ||
|
||||
(!isShorts() &&
|
||||
hasLoadedStateForVideo(videoId) &&
|
||||
!isLikesDisabled() &&
|
||||
!buttons.querySelector("#ryd-bar-container"))
|
||||
);
|
||||
}
|
||||
|
||||
setInterval(() => {
|
||||
if (watchControlsNeedInitialization()) void triggerInitializationCycle();
|
||||
}, 500);
|
||||
|
||||
const s = document.createElement("script");
|
||||
s.src = chrome.runtime.getURL("menu-fixer.js");
|
||||
s.onload = function () {
|
||||
|
||||
@@ -2,12 +2,32 @@ import { getButtons, getDislikeButton, getLikeButton } from "./buttons";
|
||||
import { extConfig, isMobile, isLikesDisabled, isNewDesign, isRoundedDesign, isShorts } from "./state";
|
||||
import { getColorFromTheme, isInViewport, querySelector } from "./utils";
|
||||
|
||||
function closestConfigured(element, selectors) {
|
||||
for (const selector of Array.isArray(selectors) ? selectors : [selectors]) {
|
||||
const match = selector ? element?.closest(selector) : null;
|
||||
if (match) return match;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findInCurrentWatchTree(buttons, selectors, fallbackScope = null) {
|
||||
const closest = closestConfigured(buttons, selectors);
|
||||
if (closest) return closest;
|
||||
const watchRoot = buttons?.closest("ytd-watch-flexy, ytd-watch-grid");
|
||||
const scope = fallbackScope ?? watchRoot;
|
||||
return scope ? querySelector(selectors, scope) : undefined;
|
||||
}
|
||||
|
||||
function createRateBar(likes, dislikes) {
|
||||
let rateBar = document.getElementById("ryd-bar-container");
|
||||
const buttons = getButtons();
|
||||
for (const wrapper of document.querySelectorAll(".ryd-tooltip")) {
|
||||
if (!buttons?.contains(wrapper)) wrapper.remove();
|
||||
}
|
||||
let rateBar = buttons?.querySelector("#ryd-bar-container");
|
||||
if (!isLikesDisabled()) {
|
||||
// sometimes rate bar is hidden
|
||||
if (rateBar && !isInViewport(rateBar)) {
|
||||
rateBar.remove();
|
||||
(rateBar.closest(".ryd-tooltip") ?? rateBar).remove();
|
||||
rateBar = null;
|
||||
}
|
||||
|
||||
@@ -52,10 +72,7 @@ function createRateBar(likes, dislikes) {
|
||||
colorLikeStyle = "; background-color: " + getColorFromTheme(true);
|
||||
colorDislikeStyle = "; background-color: " + getColorFromTheme(false);
|
||||
}
|
||||
let actions =
|
||||
isNewDesign() && getButtons() === querySelector(extConfig.selectors.rateBar.newDesignActions)
|
||||
? getButtons()
|
||||
: querySelector(extConfig.selectors.rateBar.oldDesignActions);
|
||||
const actions = buttons;
|
||||
(actions || querySelector(extConfig.selectors.rateBar.mobileActionBar)).insertAdjacentHTML(
|
||||
"beforeend",
|
||||
`
|
||||
@@ -80,30 +97,43 @@ function createRateBar(likes, dislikes) {
|
||||
|
||||
if (isNewDesign()) {
|
||||
// Add border between info and comments
|
||||
let descriptionAndActionsElement = querySelector(extConfig.selectors.rateBar.topRow);
|
||||
descriptionAndActionsElement.style.borderBottom = "1px solid var(--yt-spec-10-percent-layer)";
|
||||
descriptionAndActionsElement.style.paddingBottom = "10px";
|
||||
const descriptionAndActionsElement = findInCurrentWatchTree(buttons, extConfig.selectors.rateBar.topRow);
|
||||
if (descriptionAndActionsElement) {
|
||||
descriptionAndActionsElement.style.borderBottom = "1px solid var(--yt-spec-10-percent-layer)";
|
||||
descriptionAndActionsElement.style.paddingBottom = "10px";
|
||||
}
|
||||
|
||||
// Fix like/dislike ratio bar offset in new UI
|
||||
querySelector(extConfig.selectors.rateBar.actionsInner).style.width = "revert";
|
||||
const actionsInner = findInCurrentWatchTree(
|
||||
buttons,
|
||||
extConfig.selectors.rateBar.actionsInner,
|
||||
descriptionAndActionsElement,
|
||||
);
|
||||
if (actionsInner) actionsInner.style.width = "revert";
|
||||
if (isRoundedDesign()) {
|
||||
querySelector(extConfig.selectors.rateBar.actions).style.flexDirection = "row-reverse";
|
||||
const actions = findInCurrentWatchTree(
|
||||
buttons,
|
||||
extConfig.selectors.rateBar.actions,
|
||||
descriptionAndActionsElement,
|
||||
);
|
||||
if (actions) actions.style.flexDirection = "row-reverse";
|
||||
}
|
||||
}
|
||||
} else {
|
||||
document.querySelector(`.ryd-tooltip`).style.width = widthPx + "px";
|
||||
document.getElementById("ryd-bar").style.width = widthPercent + "%";
|
||||
document.querySelector("#ryd-dislike-tooltip > #tooltip").innerHTML = tooltipInnerHTML;
|
||||
buttons.querySelector(`.ryd-tooltip`).style.width = widthPx + "px";
|
||||
buttons.querySelector("#ryd-bar").style.width = widthPercent + "%";
|
||||
const tooltip = buttons.querySelector("#ryd-dislike-tooltip > #tooltip");
|
||||
if (tooltip) tooltip.innerHTML = tooltipInnerHTML;
|
||||
if (extConfig.coloredBar) {
|
||||
document.getElementById("ryd-bar-container").style.backgroundColor = getColorFromTheme(false);
|
||||
document.getElementById("ryd-bar").style.backgroundColor = getColorFromTheme(true);
|
||||
buttons.querySelector("#ryd-bar-container").style.backgroundColor = getColorFromTheme(false);
|
||||
buttons.querySelector("#ryd-bar").style.backgroundColor = getColorFromTheme(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log("removing bar");
|
||||
if (rateBar) {
|
||||
rateBar.parentNode.removeChild(rateBar);
|
||||
(rateBar.closest(".ryd-tooltip") ?? rateBar).remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
/** @jest-environment jsdom */
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
jest.mock("./buttons", () => ({
|
||||
getButtons: jest.fn(),
|
||||
getDislikeButton: jest.fn(),
|
||||
getLikeButton: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("./state", () => ({
|
||||
extConfig: {
|
||||
coloredBar: false,
|
||||
rateBar: null,
|
||||
selectors: {
|
||||
rateBar: {
|
||||
actions: ["#actions"],
|
||||
actionsInner: ["#actions-inner"],
|
||||
mobileActionBar: ["ytm-slim-video-action-bar-renderer"],
|
||||
topRow: ["#top-row"],
|
||||
},
|
||||
},
|
||||
showTooltipPercentage: false,
|
||||
tooltipPercentageMode: "dash_like",
|
||||
},
|
||||
isLikesDisabled: jest.fn(() => false),
|
||||
isMobile: jest.fn(() => false),
|
||||
isNewDesign: jest.fn(() => true),
|
||||
isRoundedDesign: jest.fn(() => true),
|
||||
isShorts: jest.fn(() => false),
|
||||
}));
|
||||
|
||||
jest.mock("./utils", () => ({
|
||||
getColorFromTheme: jest.fn(() => "red"),
|
||||
isInViewport: jest.fn(() => true),
|
||||
querySelector: jest.fn((selectors, element) => {
|
||||
const scope = element ?? globalThis.document;
|
||||
for (const selector of Array.isArray(selectors) ? selectors : [selectors]) {
|
||||
const match = scope?.querySelector(selector);
|
||||
if (match) return match;
|
||||
}
|
||||
return undefined;
|
||||
}),
|
||||
}));
|
||||
|
||||
const { getButtons, getDislikeButton, getLikeButton } = require("./buttons");
|
||||
const { createRateBar } = require("./bar");
|
||||
const { isLikesDisabled, isMobile, isNewDesign, isRoundedDesign, isShorts } = require("./state");
|
||||
|
||||
describe("rate-bar stylesheet contrast", () => {
|
||||
test("uses the same high-contrast negative track recipe as the userscript", () => {
|
||||
const stylesheet = fs.readFileSync(path.join(__dirname, "../content-style.css"), "utf8");
|
||||
const rule = stylesheet.match(/#ryd-bar-container\s*{([^}]*)}/)?.[1] ?? "";
|
||||
|
||||
expect(rule).toContain("background: #737373");
|
||||
expect(rule).toContain("var(--yt-spec-text-primary, #f1f1f1) 55%");
|
||||
expect(rule).toContain("var(--yt-spec-base-background, #0f0f0f) 45%");
|
||||
expect(rule).not.toContain("--yt-spec-icon-disabled");
|
||||
});
|
||||
});
|
||||
|
||||
function watchTree(videoId) {
|
||||
return `
|
||||
<ytd-watch-flexy video-id="${videoId}">
|
||||
<div id="top-row" data-watch-row="${videoId}">
|
||||
<div id="actions-inner" data-watch-actions-inner="${videoId}" style="width: 999px">
|
||||
<div id="actions" data-watch-actions="${videoId}">
|
||||
<div id="top-level-buttons-computed" data-watch-buttons="${videoId}">
|
||||
<like-button-view-model><button style="width: 96px"></button></like-button-view-model>
|
||||
<dislike-button-view-model><button style="width: 96px"></button></dislike-button-view-model>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ytd-watch-flexy>`;
|
||||
}
|
||||
|
||||
describe("rate-bar visual ownership", () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = `${watchTree("AAAAAAAAAAA")}${watchTree("BBBBBBBBBBB")}`;
|
||||
isLikesDisabled.mockReturnValue(false);
|
||||
isMobile.mockReturnValue(false);
|
||||
isNewDesign.mockReturnValue(true);
|
||||
isRoundedDesign.mockReturnValue(true);
|
||||
isShorts.mockReturnValue(false);
|
||||
const buttons = document.querySelector('[data-watch-buttons="BBBBBBBBBBB"]');
|
||||
getButtons.mockReturnValue(buttons);
|
||||
getLikeButton.mockReturnValue(buttons.querySelector("like-button-view-model"));
|
||||
getDislikeButton.mockReturnValue(buttons.querySelector("dislike-button-view-model"));
|
||||
});
|
||||
|
||||
test("mutates only the selected current watch tree when stale duplicate IDs come first", () => {
|
||||
const currentButtons = document.querySelector('[data-watch-buttons="BBBBBBBBBBB"]');
|
||||
expect(currentButtons.closest("#top-row")).toBe(document.querySelector('[data-watch-row="BBBBBBBBBBB"]'));
|
||||
expect(isNewDesign()).toBe(true);
|
||||
createRateBar(100, 25);
|
||||
|
||||
const staleRow = document.querySelector('[data-watch-row="AAAAAAAAAAA"]');
|
||||
const staleActionsInner = document.querySelector('[data-watch-actions-inner="AAAAAAAAAAA"]');
|
||||
const staleActions = document.querySelector('[data-watch-actions="AAAAAAAAAAA"]');
|
||||
const currentRow = document.querySelector('[data-watch-row="BBBBBBBBBBB"]');
|
||||
const currentActionsInner = document.querySelector('[data-watch-actions-inner="BBBBBBBBBBB"]');
|
||||
const currentActions = document.querySelector('[data-watch-actions="BBBBBBBBBBB"]');
|
||||
|
||||
expect(staleRow.style.borderBottom).toBe("");
|
||||
expect(staleRow.style.paddingBottom).toBe("");
|
||||
expect(staleActionsInner.style.width).toBe("999px");
|
||||
expect(staleActions.style.flexDirection).toBe("");
|
||||
expect(currentRow.style.paddingBottom).toBe("10px");
|
||||
expect(currentActionsInner.style.width).toBe("revert");
|
||||
expect(currentActions.style.flexDirection).toBe("row-reverse");
|
||||
expect(document.querySelector('[data-watch-buttons="BBBBBBBBBBB"] > .ryd-tooltip')).not.toBeNull();
|
||||
expect(document.querySelector('[data-watch-buttons="AAAAAAAAAAA"] > .ryd-tooltip')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,109 @@
|
||||
import { isMobile, isShorts, extConfig } from "./state";
|
||||
import { isInViewport, querySelector, querySelectorAll } from "./utils";
|
||||
import { getVideoId, isInViewport, querySelector, querySelectorAll } from "./utils";
|
||||
|
||||
const buttonsVideoOwnership = new WeakMap();
|
||||
|
||||
function hasRenderedBox(element) {
|
||||
if (!element?.isConnected || element.closest("[hidden], [aria-hidden='true'], [inert]")) {
|
||||
return false;
|
||||
}
|
||||
for (let current = element; current; current = current.parentElement) {
|
||||
const style = window.getComputedStyle(current);
|
||||
if (
|
||||
style.display === "none" ||
|
||||
style.visibility === "hidden" ||
|
||||
style.visibility === "collapse" ||
|
||||
Number.parseFloat(style.opacity) === 0
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
const rect = element.getBoundingClientRect();
|
||||
return rect.width > 0 && rect.height > 0;
|
||||
}
|
||||
|
||||
function intersectsViewport(element) {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const height = innerHeight || document.documentElement.clientHeight;
|
||||
const width = innerWidth || document.documentElement.clientWidth;
|
||||
return (
|
||||
rect.width > 0 && rect.height > 0 && rect.bottom > 0 && rect.right > 0 && rect.top < height && rect.left < width
|
||||
);
|
||||
}
|
||||
|
||||
function configuredMatches(selectors) {
|
||||
const matches = [];
|
||||
for (const selector of Array.isArray(selectors) ? selectors : [selectors]) {
|
||||
if (selector) matches.push(...document.querySelectorAll(selector));
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
function getDesktopWatchButtonCandidates() {
|
||||
return Array.from(
|
||||
new Set([
|
||||
...configuredMatches(extConfig.selectors.buttons.regular.desktopMenu),
|
||||
...configuredMatches(extConfig.selectors.buttons.regular.desktopNoMenu),
|
||||
]),
|
||||
).filter((candidate) => {
|
||||
const segmented = querySelector(extConfig.selectors.buttons.segmentedContainer, candidate);
|
||||
const like = querySelector(extConfig.selectors.buttons.likeButton.notSegmented, candidate);
|
||||
return segmented !== undefined || like !== undefined;
|
||||
});
|
||||
}
|
||||
|
||||
function markButtonsForVideo(buttons, videoId) {
|
||||
if (buttons && videoId) {
|
||||
buttonsVideoOwnership.set(buttons, videoId);
|
||||
}
|
||||
}
|
||||
|
||||
function getButtonsVideoOwnership(candidate) {
|
||||
return (
|
||||
buttonsVideoOwnership.get(candidate) ??
|
||||
candidate.getAttribute("video-id") ??
|
||||
candidate.getAttribute("data-video-id")
|
||||
);
|
||||
}
|
||||
|
||||
function selectCurrentWatchButtons(candidates) {
|
||||
const videoId = getVideoId(window.location.href);
|
||||
const ranked = candidates
|
||||
.map((candidate, index) => {
|
||||
const watchRoot = candidate.closest("ytd-watch-flexy, ytd-watch-grid");
|
||||
const rootVideoId = watchRoot?.getAttribute("video-id");
|
||||
const rootMatches = Boolean(videoId && rootVideoId === videoId);
|
||||
const rootConflicts = Boolean(videoId && rootVideoId && rootVideoId !== videoId);
|
||||
const controlsVideoId = getButtonsVideoOwnership(candidate);
|
||||
const controlsMatch = Boolean(videoId && controlsVideoId === videoId);
|
||||
const controlsConflict = Boolean(videoId && controlsVideoId && controlsVideoId !== videoId);
|
||||
const rendered = hasRenderedBox(candidate);
|
||||
const inViewport = rendered && intersectsViewport(candidate);
|
||||
const tier =
|
||||
controlsMatch && inViewport
|
||||
? 8
|
||||
: rootMatches && inViewport
|
||||
? 7
|
||||
: inViewport
|
||||
? 6
|
||||
: controlsMatch && rendered
|
||||
? 5
|
||||
: rootMatches && rendered
|
||||
? 4
|
||||
: rendered
|
||||
? 3
|
||||
: controlsMatch || rootMatches
|
||||
? 2
|
||||
: 1;
|
||||
return { candidate, controlsConflict, index, rootConflicts, tier };
|
||||
})
|
||||
// Do not bind outgoing controls to the destination video while YouTube is
|
||||
// switching the current watch root during an SPA navigation.
|
||||
.filter(({ rootConflicts }) => !rootConflicts);
|
||||
const ownershipCompatible = ranked.filter(({ controlsConflict }) => !controlsConflict);
|
||||
const selectionPool = ownershipCompatible.length > 0 ? ownershipCompatible : ranked;
|
||||
return selectionPool.sort((left, right) => right.tier - left.tier || left.index - right.index)[0]?.candidate;
|
||||
}
|
||||
|
||||
function getNativeButton(buttonContainer) {
|
||||
return querySelector(extConfig.selectors.buttons.nativeButton, buttonContainer);
|
||||
@@ -32,20 +136,15 @@ function getButtons() {
|
||||
if (isMobile()) {
|
||||
return document.querySelector(extConfig.selectors.buttons.regular.mobile);
|
||||
}
|
||||
//--- If Menu Element Is Displayed: ---//
|
||||
if (querySelector(extConfig.selectors.menuContainer)?.offsetParent === null) {
|
||||
return querySelector(extConfig.selectors.buttons.regular.desktopMenu);
|
||||
//--- If Menu Element Isn't Displayed: ---//
|
||||
} else {
|
||||
return querySelector(extConfig.selectors.buttons.regular.desktopNoMenu);
|
||||
}
|
||||
return selectCurrentWatchButtons(getDesktopWatchButtonCandidates());
|
||||
}
|
||||
|
||||
function getLikeButton() {
|
||||
const buttons = getButtons();
|
||||
return isSegmentedButtonLayout()
|
||||
? querySelector(extConfig.selectors.buttons.likeButton.segmented) ??
|
||||
querySelector(extConfig.selectors.buttons.likeButton.segmentedGetButtons, getButtons())
|
||||
: querySelector(extConfig.selectors.buttons.likeButton.notSegmented, getButtons());
|
||||
? querySelector(extConfig.selectors.buttons.likeButton.segmented, buttons) ??
|
||||
querySelector(extConfig.selectors.buttons.likeButton.segmentedGetButtons, buttons)
|
||||
: querySelector(extConfig.selectors.buttons.likeButton.notSegmented, buttons);
|
||||
}
|
||||
|
||||
function getLikeTextContainer() {
|
||||
@@ -53,21 +152,22 @@ function getLikeTextContainer() {
|
||||
}
|
||||
|
||||
function getDislikeButton() {
|
||||
const buttons = getButtons();
|
||||
if (isSegmentedButtonLayout()) {
|
||||
return (
|
||||
querySelector(extConfig.selectors.buttons.dislikeButton.segmented) ??
|
||||
querySelector(extConfig.selectors.buttons.dislikeButton.segmentedGetButtons, getButtons())
|
||||
querySelector(extConfig.selectors.buttons.dislikeButton.segmented, buttons) ??
|
||||
querySelector(extConfig.selectors.buttons.dislikeButton.segmentedGetButtons, buttons)
|
||||
);
|
||||
}
|
||||
|
||||
const notSegmentedMatch = querySelector(extConfig.selectors.buttons.dislikeButton.notSegmented, getButtons());
|
||||
const notSegmentedMatch = querySelector(extConfig.selectors.buttons.dislikeButton.notSegmented, buttons);
|
||||
|
||||
if (notSegmentedMatch != null) {
|
||||
return notSegmentedMatch;
|
||||
}
|
||||
|
||||
if (isShorts()) {
|
||||
return querySelector(extConfig.selectors.buttons.dislikeButton.shortsFallback, getButtons());
|
||||
return querySelector(extConfig.selectors.buttons.dislikeButton.shortsFallback, buttons);
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -141,4 +241,6 @@ export {
|
||||
getLikeTextContainer,
|
||||
getDislikeTextContainer,
|
||||
checkForSignInButton,
|
||||
hasRenderedBox,
|
||||
markButtonsForVideo,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
/** @jest-environment jsdom */
|
||||
|
||||
const { extConfig } = require("./state");
|
||||
const { getButtons, getDislikeButton, getLikeButton, hasRenderedBox, markButtonsForVideo } = require("./buttons");
|
||||
|
||||
function controls(id, videoId, attributes = "") {
|
||||
return `
|
||||
<ytd-menu-renderer class="ytd-watch-metadata" ${attributes}>
|
||||
<div id="${id}" data-video-id="${videoId}">
|
||||
<segmented-like-dislike-button-view-model>
|
||||
<like-button-view-model id="segmented-like-button"><button aria-pressed="false"></button></like-button-view-model>
|
||||
<dislike-button-view-model id="segmented-dislike-button"><button aria-pressed="false"></button></dislike-button-view-model>
|
||||
</segmented-like-dislike-button-view-model>
|
||||
</div>
|
||||
</ytd-menu-renderer>`;
|
||||
}
|
||||
|
||||
function setBox(element, { height = 48, width = 320, x = 20, y = 20 } = {}) {
|
||||
element.getBoundingClientRect = () => ({
|
||||
bottom: y + height,
|
||||
height,
|
||||
left: x,
|
||||
right: x + width,
|
||||
top: y,
|
||||
width,
|
||||
x,
|
||||
y,
|
||||
});
|
||||
}
|
||||
|
||||
describe("desktop watch button ownership", () => {
|
||||
beforeEach(() => {
|
||||
history.replaceState({}, "", "/watch?v=BBBBBBBBBBB");
|
||||
extConfig.selectors.buttons.regular.desktopMenu = ["ytd-menu-renderer.ytd-watch-metadata > div"];
|
||||
extConfig.selectors.buttons.regular.desktopNoMenu = ["#top-level-buttons-computed"];
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
test("prefers viewport-intersecting destination controls over a positive-size offscreen sibling in the same root", () => {
|
||||
document.body.innerHTML = `
|
||||
<ytd-watch-flexy video-id="BBBBBBBBBBB">
|
||||
${controls("stale", "AAAAAAAAAAA")}
|
||||
${controls("current", "BBBBBBBBBBB")}
|
||||
</ytd-watch-flexy>`;
|
||||
setBox(document.querySelector("#stale"), { x: -10_000 });
|
||||
setBox(document.querySelector("#current"), { x: 40 });
|
||||
|
||||
expect(getButtons()).toBe(document.querySelector("#current"));
|
||||
});
|
||||
|
||||
test("prefers unowned destination controls over initialized outgoing controls when both overlap in the current root", () => {
|
||||
document.body.innerHTML = `
|
||||
<ytd-watch-flexy video-id="BBBBBBBBBBB">
|
||||
${controls("stale", "AAAAAAAAAAA")}
|
||||
${controls("current", "BBBBBBBBBBB")}
|
||||
</ytd-watch-flexy>`;
|
||||
const stale = document.querySelector("#stale");
|
||||
const current = document.querySelector("#current");
|
||||
setBox(stale, { x: 40 });
|
||||
setBox(current, { x: 40 });
|
||||
markButtonsForVideo(stale, "AAAAAAAAAAA");
|
||||
|
||||
expect(getButtons()).toBe(current);
|
||||
});
|
||||
|
||||
test("falls back to a reused marked control when it is the only current-root candidate", () => {
|
||||
document.body.innerHTML = `
|
||||
<ytd-watch-flexy video-id="BBBBBBBBBBB">
|
||||
${controls("reused", "AAAAAAAAAAA")}
|
||||
</ytd-watch-flexy>`;
|
||||
const reused = document.querySelector("#reused");
|
||||
setBox(reused, { x: 40 });
|
||||
markButtonsForVideo(reused, "AAAAAAAAAAA");
|
||||
|
||||
expect(getButtons()).toBe(reused);
|
||||
});
|
||||
|
||||
test("ignores hidden, transparent, and inert stale candidates before current controls", () => {
|
||||
document.body.innerHTML = `
|
||||
<ytd-watch-flexy video-id="BBBBBBBBBBB">
|
||||
<div hidden>${controls("hidden-stale", "AAAAAAAAAAA")}</div>
|
||||
<div style="opacity: 0">${controls("transparent-stale", "AAAAAAAAAAA")}</div>
|
||||
<div inert>${controls("inert-stale", "AAAAAAAAAAA")}</div>
|
||||
${controls("current", "BBBBBBBBBBB")}
|
||||
</ytd-watch-flexy>`;
|
||||
["#hidden-stale", "#transparent-stale", "#inert-stale", "#current"].forEach((selector) =>
|
||||
setBox(document.querySelector(selector)),
|
||||
);
|
||||
|
||||
expect(getButtons()).toBe(document.querySelector("#current"));
|
||||
expect(hasRenderedBox(document.querySelector("#hidden-stale"))).toBe(false);
|
||||
expect(hasRenderedBox(document.querySelector("#transparent-stale"))).toBe(false);
|
||||
expect(hasRenderedBox(document.querySelector("#inert-stale"))).toBe(false);
|
||||
});
|
||||
|
||||
test("does not bind rendered controls owned by an explicit outgoing video root", () => {
|
||||
document.body.innerHTML = `
|
||||
<ytd-watch-flexy video-id="AAAAAAAAAAA">
|
||||
${controls("outgoing", "AAAAAAAAAAA")}
|
||||
</ytd-watch-flexy>`;
|
||||
setBox(document.querySelector("#outgoing"));
|
||||
|
||||
expect(getButtons()).toBeUndefined();
|
||||
});
|
||||
|
||||
test("accepts fixed-position current controls even though they have no offset parent", () => {
|
||||
document.body.innerHTML = `
|
||||
<ytd-watch-flexy video-id="BBBBBBBBBBB">
|
||||
${controls("current", "BBBBBBBBBBB", 'style="position: fixed"')}
|
||||
</ytd-watch-flexy>`;
|
||||
const current = document.querySelector("#current");
|
||||
setBox(current);
|
||||
|
||||
expect(current.offsetParent).toBeNull();
|
||||
expect(hasRenderedBox(current)).toBe(true);
|
||||
expect(getButtons()).toBe(current);
|
||||
});
|
||||
|
||||
test("uses an offscreen current-root candidate as a fallback when no viewport candidate exists", () => {
|
||||
document.body.innerHTML = `
|
||||
<ytd-watch-flexy video-id="BBBBBBBBBBB">
|
||||
${controls("current", "BBBBBBBBBBB")}
|
||||
</ytd-watch-flexy>`;
|
||||
const current = document.querySelector("#current");
|
||||
setBox(current, { x: -10_000 });
|
||||
|
||||
expect(hasRenderedBox(current)).toBe(true);
|
||||
expect(getButtons()).toBe(current);
|
||||
});
|
||||
|
||||
test("scopes segmented reaction controls to the selected destination container", () => {
|
||||
const originalSegmentedContainer = extConfig.selectors.buttons.segmentedContainer;
|
||||
extConfig.selectors.buttons.segmentedContainer = ["segmented-like-dislike-button-view-model"];
|
||||
try {
|
||||
document.body.innerHTML = `
|
||||
<ytd-watch-flexy video-id="BBBBBBBBBBB">
|
||||
${controls("stale", "AAAAAAAAAAA")}
|
||||
${controls("current", "BBBBBBBBBBB")}
|
||||
</ytd-watch-flexy>`;
|
||||
setBox(document.querySelector("#stale"), { x: -10_000 });
|
||||
setBox(document.querySelector("#current"), { x: 40 });
|
||||
|
||||
const current = document.querySelector("#current");
|
||||
expect(getButtons()).toBe(current);
|
||||
expect(getLikeButton()).toBe(current.querySelector("like-button-view-model"));
|
||||
expect(getDislikeButton()).toBe(current.querySelector("dislike-button-view-model"));
|
||||
} finally {
|
||||
extConfig.selectors.buttons.segmentedContainer = originalSegmentedContainer;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,24 +1,25 @@
|
||||
import { getBrowser, getVideoId, numberFormat, createObserver, querySelector } from "./utils";
|
||||
import { checkForSignInButton, getButtons, getDislikeButton, getLikeButton } from "./buttons";
|
||||
import {
|
||||
NEUTRAL_STATE,
|
||||
LIKED_STATE,
|
||||
DISLIKED_STATE,
|
||||
setDislikes,
|
||||
extConfig,
|
||||
storedData,
|
||||
setLikes,
|
||||
getLikeCountFromButton,
|
||||
} from "./state";
|
||||
import { setDislikes, extConfig, storedData, setLikes, getLikeCountFromButton } from "./state";
|
||||
import { createRateBar } from "./bar";
|
||||
import {
|
||||
LIKE_ACTION,
|
||||
DISLIKE_ACTION,
|
||||
resolveVoteTransition,
|
||||
applyVoteTransitionCounts,
|
||||
shouldSubmitVote,
|
||||
} from "../../common/vote-transition";
|
||||
|
||||
function sendVote(vote) {
|
||||
if (extConfig.disableVoteSubmission !== true) {
|
||||
getBrowser().runtime.sendMessage({
|
||||
if (shouldSubmitVote({ disableVoteSubmission: extConfig.disableVoteSubmission })) {
|
||||
const result = getBrowser().runtime.sendMessage({
|
||||
message: "send_vote",
|
||||
vote: vote,
|
||||
videoId: getVideoId(window.location.href),
|
||||
});
|
||||
if (result && typeof result.catch === "function") {
|
||||
result.catch((error) => console.error("Vote submission failed", error));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,25 +28,16 @@ function updateDOMDislikes() {
|
||||
createRateBar(storedData.likes, storedData.dislikes);
|
||||
}
|
||||
|
||||
function likeClicked() {
|
||||
function handleVoteAction(action) {
|
||||
if (checkForSignInButton() === false) {
|
||||
if (storedData.previousState === DISLIKED_STATE) {
|
||||
sendVote(1);
|
||||
if (storedData.dislikes > 0) storedData.dislikes--;
|
||||
storedData.likes++;
|
||||
updateDOMDislikes();
|
||||
storedData.previousState = LIKED_STATE;
|
||||
} else if (storedData.previousState === NEUTRAL_STATE) {
|
||||
sendVote(1);
|
||||
storedData.likes++;
|
||||
updateDOMDislikes();
|
||||
storedData.previousState = LIKED_STATE;
|
||||
} else if ((storedData.previousState = LIKED_STATE)) {
|
||||
sendVote(0);
|
||||
if (storedData.likes > 0) storedData.likes--;
|
||||
updateDOMDislikes();
|
||||
storedData.previousState = NEUTRAL_STATE;
|
||||
}
|
||||
const transition = resolveVoteTransition(storedData.previousState, action);
|
||||
const counts = applyVoteTransitionCounts(storedData.likes, storedData.dislikes, transition);
|
||||
sendVote(transition.value);
|
||||
storedData.likes = counts.likes;
|
||||
storedData.dislikes = counts.dislikes;
|
||||
storedData.previousState = transition.nextState;
|
||||
updateDOMDislikes();
|
||||
|
||||
if (extConfig.numberDisplayReformatLikes === true) {
|
||||
const nativeLikes = getLikeCountFromButton();
|
||||
if (nativeLikes !== false) {
|
||||
@@ -55,45 +47,29 @@ function likeClicked() {
|
||||
}
|
||||
}
|
||||
|
||||
function dislikeClicked() {
|
||||
if (checkForSignInButton() == false) {
|
||||
if (storedData.previousState === NEUTRAL_STATE) {
|
||||
sendVote(-1);
|
||||
storedData.dislikes++;
|
||||
updateDOMDislikes();
|
||||
storedData.previousState = DISLIKED_STATE;
|
||||
} else if (storedData.previousState === DISLIKED_STATE) {
|
||||
sendVote(0);
|
||||
if (storedData.dislikes > 0) storedData.dislikes--;
|
||||
updateDOMDislikes();
|
||||
storedData.previousState = NEUTRAL_STATE;
|
||||
} else if (storedData.previousState === LIKED_STATE) {
|
||||
sendVote(-1);
|
||||
if (storedData.likes > 0) storedData.likes--;
|
||||
storedData.dislikes++;
|
||||
updateDOMDislikes();
|
||||
storedData.previousState = DISLIKED_STATE;
|
||||
if (extConfig.numberDisplayReformatLikes === true) {
|
||||
const nativeLikes = getLikeCountFromButton();
|
||||
if (nativeLikes !== false) {
|
||||
setLikes(numberFormat(nativeLikes));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
function likeClicked() {
|
||||
handleVoteAction(LIKE_ACTION);
|
||||
}
|
||||
|
||||
function dislikeClicked() {
|
||||
handleVoteAction(DISLIKE_ACTION);
|
||||
}
|
||||
|
||||
const boundLikeButtons = new WeakSet();
|
||||
const boundDislikeButtons = new WeakSet();
|
||||
|
||||
function addLikeDislikeEventListener() {
|
||||
if (window.rydPreNavigateLikeButton !== getLikeButton()) {
|
||||
getLikeButton().addEventListener("click", likeClicked);
|
||||
getLikeButton().addEventListener("touchstart", likeClicked);
|
||||
if (getDislikeButton()) {
|
||||
getDislikeButton().addEventListener("click", dislikeClicked);
|
||||
getDislikeButton().addEventListener("touchstart", dislikeClicked);
|
||||
getDislikeButton().addEventListener("focusin", updateDOMDislikes);
|
||||
getDislikeButton().addEventListener("focusout", updateDOMDislikes);
|
||||
}
|
||||
window.rydPreNavigateLikeButton = getLikeButton();
|
||||
const likeButton = getLikeButton();
|
||||
const dislikeButton = getDislikeButton();
|
||||
if (likeButton && !boundLikeButtons.has(likeButton)) {
|
||||
likeButton.addEventListener("click", likeClicked);
|
||||
boundLikeButtons.add(likeButton);
|
||||
}
|
||||
if (dislikeButton && !boundDislikeButtons.has(dislikeButton)) {
|
||||
dislikeButton.addEventListener("click", dislikeClicked);
|
||||
dislikeButton.addEventListener("focusin", updateDOMDislikes);
|
||||
dislikeButton.addEventListener("focusout", updateDOMDislikes);
|
||||
boundDislikeButtons.add(dislikeButton);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
function createInitializationCycleRunner(runCycle) {
|
||||
if (typeof runCycle !== "function") {
|
||||
throw new TypeError("An initialization cycle function is required.");
|
||||
}
|
||||
|
||||
let activePromise = null;
|
||||
let rerunRequested = false;
|
||||
|
||||
async function request() {
|
||||
if (activePromise) {
|
||||
rerunRequested = true;
|
||||
return activePromise;
|
||||
}
|
||||
|
||||
activePromise = (async () => {
|
||||
do {
|
||||
rerunRequested = false;
|
||||
await runCycle();
|
||||
} while (rerunRequested);
|
||||
})();
|
||||
|
||||
try {
|
||||
return await activePromise;
|
||||
} finally {
|
||||
activePromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isRunning: () => activePromise !== null,
|
||||
request,
|
||||
};
|
||||
}
|
||||
|
||||
export { createInitializationCycleRunner };
|
||||
@@ -0,0 +1,58 @@
|
||||
import { createInitializationCycleRunner } from "./initialization-cycle";
|
||||
|
||||
function deferred() {
|
||||
let resolve;
|
||||
const promise = new Promise((resolvePromise) => {
|
||||
resolve = resolvePromise;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
test("queues a fresh initialization when navigation arrives during an in-flight cycle", async () => {
|
||||
const outgoing = deferred();
|
||||
const calls = [];
|
||||
const runner = createInitializationCycleRunner(async () => {
|
||||
calls.push(calls.length === 0 ? "A" : "B");
|
||||
if (calls.length === 1) await outgoing.promise;
|
||||
});
|
||||
|
||||
const first = runner.request();
|
||||
const navigation = runner.request();
|
||||
|
||||
expect(runner.isRunning()).toBe(true);
|
||||
expect(calls).toEqual(["A"]);
|
||||
|
||||
outgoing.resolve();
|
||||
await Promise.all([first, navigation]);
|
||||
|
||||
expect(calls).toEqual(["A", "B"]);
|
||||
expect(runner.isRunning()).toBe(false);
|
||||
});
|
||||
|
||||
test("coalesces repeated navigation signals into one pending cycle", async () => {
|
||||
const outgoing = deferred();
|
||||
const runCycle = jest.fn(async () => {
|
||||
if (runCycle.mock.calls.length === 1) await outgoing.promise;
|
||||
});
|
||||
const runner = createInitializationCycleRunner(runCycle);
|
||||
|
||||
const first = runner.request();
|
||||
const queued = [runner.request(), runner.request(), runner.request()];
|
||||
outgoing.resolve();
|
||||
await Promise.all([first, ...queued]);
|
||||
|
||||
expect(runCycle).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test("continues accepting independent cycles after a queued rerun", async () => {
|
||||
const runner = createInitializationCycleRunner(jest.fn(async () => {}));
|
||||
|
||||
await runner.request();
|
||||
await runner.request();
|
||||
|
||||
expect(runner.isRunning()).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects invalid cycle callbacks", () => {
|
||||
expect(() => createInitializationCycleRunner()).toThrow(TypeError);
|
||||
});
|
||||
@@ -11,9 +11,7 @@ import {
|
||||
createObserver,
|
||||
} from "./utils";
|
||||
import { config, getApiEndpoint, DEV_API_URL, PROD_API_URL, isDevelopment } from "./config";
|
||||
const LIKED_STATE = "LIKED_STATE";
|
||||
const DISLIKED_STATE = "DISLIKED_STATE";
|
||||
const NEUTRAL_STATE = "NEUTRAL_STATE";
|
||||
import { LIKED_STATE, DISLIKED_STATE, NEUTRAL_STATE } from "../../common/vote-transition";
|
||||
|
||||
const DEFAULT_SELECTORS = {
|
||||
dislikeTextContainer: [
|
||||
@@ -140,6 +138,7 @@ let storedData = {
|
||||
likes: 0,
|
||||
dislikes: 0,
|
||||
previousState: NEUTRAL_STATE,
|
||||
videoId: null,
|
||||
};
|
||||
|
||||
function isMobile() {
|
||||
@@ -317,7 +316,10 @@ function processResponse(response, storedData) {
|
||||
}
|
||||
|
||||
// Tells the user if the API is down
|
||||
function displayError(error) {
|
||||
function displayError(error, videoId = getVideoId(window.location.href)) {
|
||||
if (getVideoId(window.location.href) !== videoId) {
|
||||
return;
|
||||
}
|
||||
getDislikeTextContainer().innerText = localize("textTempUnavailable");
|
||||
}
|
||||
|
||||
@@ -326,35 +328,54 @@ async function setState(storedData) {
|
||||
window.__rydSetStateCalls = (window.__rydSetStateCalls || 0) + 1;
|
||||
}
|
||||
storedData.previousState = isVideoDisliked() ? DISLIKED_STATE : isVideoLiked() ? LIKED_STATE : NEUTRAL_STATE;
|
||||
let statsSet = false;
|
||||
console.log("Video is loaded. Adding buttons...");
|
||||
|
||||
let videoId = getVideoId(window.location.href);
|
||||
let likeCount = getLikeCountFromButton() || null;
|
||||
|
||||
let response = await fetch(getApiEndpoint(`/votes?videoId=${videoId}&likeCount=${likeCount || ""}`), {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
},
|
||||
})
|
||||
.then((response) => {
|
||||
if (!response.ok) displayError(response.error);
|
||||
return response;
|
||||
})
|
||||
.then((response) => response.json())
|
||||
.catch(displayError);
|
||||
const videoId = getVideoId(window.location.href);
|
||||
const likeCount = getLikeCountFromButton() || null;
|
||||
let response;
|
||||
try {
|
||||
const request = await fetch(getApiEndpoint(`/votes?videoId=${videoId}&likeCount=${likeCount || ""}`), {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
},
|
||||
});
|
||||
if (!request.ok) {
|
||||
displayError(request.error, videoId);
|
||||
return;
|
||||
}
|
||||
response = await request.json();
|
||||
} catch (error) {
|
||||
displayError(error, videoId);
|
||||
return;
|
||||
}
|
||||
console.log("response from api:");
|
||||
console.log(JSON.stringify(response));
|
||||
if (response !== undefined && !("traceId" in response) && !statsSet) {
|
||||
processResponse(response, storedData);
|
||||
if (getVideoId(window.location.href) !== videoId) {
|
||||
return;
|
||||
}
|
||||
if (!response || typeof response !== "object" || "traceId" in response) {
|
||||
displayError(response, videoId);
|
||||
return;
|
||||
}
|
||||
processResponse(response, storedData);
|
||||
storedData.videoId = videoId;
|
||||
}
|
||||
|
||||
async function setInitialState() {
|
||||
await setState(storedData);
|
||||
}
|
||||
|
||||
function hasLoadedStateForVideo(videoId) {
|
||||
return storedData.videoId === videoId;
|
||||
}
|
||||
|
||||
function restoreCurrentState() {
|
||||
storedData.previousState = isVideoDisliked() ? DISLIKED_STATE : isVideoLiked() ? LIKED_STATE : NEUTRAL_STATE;
|
||||
setDislikes(numberFormat(storedData.dislikes));
|
||||
createRateBar(storedData.likes, storedData.dislikes);
|
||||
}
|
||||
|
||||
async function initExtConfig() {
|
||||
initializeDisableVoteSubmission();
|
||||
initializeDisableLogging();
|
||||
@@ -508,4 +529,6 @@ export {
|
||||
initExtConfig,
|
||||
storedData,
|
||||
isLikesDisabled,
|
||||
hasLoadedStateForVideo,
|
||||
restoreCurrentState,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* @jest-environment jsdom
|
||||
*/
|
||||
|
||||
jest.mock("./buttons", () => {
|
||||
const dislikeTextContainer = {
|
||||
innerText: "native dislike",
|
||||
removeAttribute: jest.fn(),
|
||||
};
|
||||
const likeButton = {
|
||||
classList: { contains: () => false },
|
||||
native: { getAttribute: (name) => (name === "aria-label" ? "35 likes" : "false") },
|
||||
};
|
||||
const dislikeButton = {
|
||||
classList: { contains: () => false },
|
||||
native: { getAttribute: () => "false" },
|
||||
};
|
||||
return {
|
||||
getButtons: () => ({ children: [] }),
|
||||
getDislikeButton: () => dislikeButton,
|
||||
getDislikeTextContainer: () => dislikeTextContainer,
|
||||
getLikeButton: () => likeButton,
|
||||
getLikeTextContainer: () => ({ innerText: "35" }),
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock("./bar", () => ({
|
||||
createRateBar: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("./config", () => ({
|
||||
DEV_API_URL: "https://example.test",
|
||||
PROD_API_URL: "https://example.test",
|
||||
config: {},
|
||||
getApiEndpoint: (pathname) => `https://example.test${pathname}`,
|
||||
isDevelopment: () => false,
|
||||
}));
|
||||
|
||||
jest.mock("./utils", () => ({
|
||||
createObserver: () => ({ observe: jest.fn() }),
|
||||
getBrowser: () => undefined,
|
||||
getColorFromTheme: () => undefined,
|
||||
getVideoId: (url) => new URL(url).searchParams.get("v"),
|
||||
initializeLogging: () => undefined,
|
||||
localize: (key) => key,
|
||||
numberFormat: (value) => String(value),
|
||||
querySelector: (selectors, root) => root?.native ?? null,
|
||||
}));
|
||||
|
||||
import { createRateBar } from "./bar";
|
||||
import { getDislikeTextContainer } from "./buttons";
|
||||
import { setState } from "./state";
|
||||
|
||||
const dislikeTextContainer = getDislikeTextContainer();
|
||||
|
||||
function deferred() {
|
||||
let resolve;
|
||||
let reject;
|
||||
const promise = new Promise((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise;
|
||||
reject = rejectPromise;
|
||||
});
|
||||
return { promise, reject, resolve };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
dislikeTextContainer.innerText = "native dislike";
|
||||
dislikeTextContainer.removeAttribute.mockClear();
|
||||
createRateBar.mockClear();
|
||||
global.fetch = jest.fn();
|
||||
window.history.replaceState(null, "", "/watch?v=abcdefghijk");
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
delete global.fetch;
|
||||
});
|
||||
|
||||
test("a delayed outgoing request failure cannot overwrite the destination before its successful initialization", async () => {
|
||||
const outgoing = deferred();
|
||||
const destinationResponse = {
|
||||
ok: true,
|
||||
json: jest.fn(async () => ({ dislikes: 65, likes: 35, rating: 3.5 })),
|
||||
};
|
||||
fetch.mockReturnValueOnce(outgoing.promise).mockResolvedValueOnce(destinationResponse);
|
||||
const testState = { dislikes: 0, likes: 0, previousState: "neutral", videoId: null };
|
||||
|
||||
const outgoingInitialization = setState(testState);
|
||||
await Promise.resolve();
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
|
||||
window.history.replaceState(null, "", "/watch?v=zyxwvutsrqp");
|
||||
outgoing.reject(new TypeError("outgoing request failed"));
|
||||
await outgoingInitialization;
|
||||
|
||||
expect(dislikeTextContainer.innerText).toBe("native dislike");
|
||||
expect(createRateBar).not.toHaveBeenCalled();
|
||||
|
||||
await setState(testState);
|
||||
|
||||
expect(dislikeTextContainer.innerText).toBe("65");
|
||||
expect(createRateBar).toHaveBeenCalledWith(35, 65);
|
||||
expect(testState.videoId).toBe("zyxwvutsrqp");
|
||||
});
|
||||
|
||||
test.each([
|
||||
["non-2xx", { ok: false, error: new Error("unavailable") }],
|
||||
[
|
||||
"malformed JSON",
|
||||
{
|
||||
ok: true,
|
||||
json: async () => {
|
||||
throw new SyntaxError("invalid JSON");
|
||||
},
|
||||
},
|
||||
],
|
||||
])("a delayed outgoing %s response cannot write an error into the destination controls", async (name, response) => {
|
||||
const outgoing = deferred();
|
||||
fetch.mockReturnValueOnce(outgoing.promise);
|
||||
const testState = { dislikes: 0, likes: 0, previousState: "neutral", videoId: null };
|
||||
|
||||
const initialization = setState(testState);
|
||||
await Promise.resolve();
|
||||
window.history.replaceState(null, "", "/watch?v=zyxwvutsrqp");
|
||||
// Resolve the request only after navigation so every failure path exercises the stale-video guard.
|
||||
outgoing.resolve(response);
|
||||
await initialization;
|
||||
|
||||
expect(dislikeTextContainer.innerText).toBe("native dislike");
|
||||
expect(createRateBar).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
function createStorageCaller(storageArea, getLastError = () => undefined) {
|
||||
return function call(methodName, ...args) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
const finish = (callback) => (value) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
callback(value);
|
||||
};
|
||||
const resolveOnce = finish(resolve);
|
||||
const rejectOnce = finish(reject);
|
||||
const callback = (result) => {
|
||||
const lastError = getLastError();
|
||||
if (lastError) {
|
||||
rejectOnce(new Error(lastError.message || String(lastError)));
|
||||
} else {
|
||||
resolveOnce(result);
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const maybePromise = storageArea[methodName](...args, callback);
|
||||
if (maybePromise && typeof maybePromise.then === "function") {
|
||||
maybePromise.then(resolveOnce, rejectOnce);
|
||||
}
|
||||
} catch (callbackError) {
|
||||
try {
|
||||
const maybePromise = storageArea[methodName](...args);
|
||||
Promise.resolve(maybePromise).then(resolveOnce, rejectOnce);
|
||||
} catch (promiseError) {
|
||||
rejectOnce(promiseError ?? callbackError);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
function createBrowserCredentialStore(storageArea, getLastError) {
|
||||
if (!storageArea?.get || !storageArea?.set) {
|
||||
throw new TypeError("A browser storage area is required");
|
||||
}
|
||||
|
||||
const call = createStorageCaller(storageArea, getLastError);
|
||||
return {
|
||||
async load() {
|
||||
const result = await call("get", ["userId", "registrationConfirmed"]);
|
||||
if (!result?.userId || result.registrationConfirmed !== true) return null;
|
||||
return { userId: result.userId, registrationConfirmed: true };
|
||||
},
|
||||
|
||||
async save(credentials) {
|
||||
await call("set", {
|
||||
userId: credentials.userId,
|
||||
registrationConfirmed: credentials.registrationConfirmed === true,
|
||||
});
|
||||
},
|
||||
|
||||
async clear() {
|
||||
if (typeof storageArea.remove === "function") {
|
||||
await call("remove", ["userId", "registrationConfirmed"]);
|
||||
} else {
|
||||
await call("set", { userId: null, registrationConfirmed: false });
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export { createBrowserCredentialStore };
|
||||
@@ -0,0 +1,80 @@
|
||||
import { createBrowserCredentialStore } from "./vote-client-adapter";
|
||||
|
||||
describe("createBrowserCredentialStore", () => {
|
||||
it("implements the credential contract with callback-based Chrome storage", async () => {
|
||||
const values = {};
|
||||
const storageArea = {
|
||||
get: jest.fn((keys, callback) => {
|
||||
callback(Object.fromEntries(keys.filter((key) => key in values).map((key) => [key, values[key]])));
|
||||
}),
|
||||
set: jest.fn((nextValues, callback) => {
|
||||
Object.assign(values, nextValues);
|
||||
callback();
|
||||
}),
|
||||
remove: jest.fn((keys, callback) => {
|
||||
keys.forEach((key) => delete values[key]);
|
||||
callback();
|
||||
}),
|
||||
};
|
||||
const store = createBrowserCredentialStore(storageArea, () => undefined);
|
||||
|
||||
expect(await store.load()).toBeNull();
|
||||
await store.save({ userId: "callback-user", registrationConfirmed: true });
|
||||
expect(await store.load()).toEqual({ userId: "callback-user", registrationConfirmed: true });
|
||||
expect(values).toEqual({ userId: "callback-user", registrationConfirmed: true });
|
||||
|
||||
await store.clear();
|
||||
expect(values).toEqual({});
|
||||
expect(storageArea.remove).toHaveBeenCalledWith(["userId", "registrationConfirmed"], expect.any(Function));
|
||||
});
|
||||
|
||||
it("implements the same contract with Promise-based browser storage", async () => {
|
||||
const values = { userId: "promise-user", registrationConfirmed: true };
|
||||
const storageArea = {
|
||||
get: jest.fn(async (keys) => Object.fromEntries(keys.map((key) => [key, values[key]]))),
|
||||
set: jest.fn(async (nextValues) => Object.assign(values, nextValues)),
|
||||
remove: jest.fn(async (keys) => keys.forEach((key) => delete values[key])),
|
||||
};
|
||||
const store = createBrowserCredentialStore(storageArea);
|
||||
|
||||
expect(await store.load()).toEqual({ userId: "promise-user", registrationConfirmed: true });
|
||||
await store.save({ userId: "replacement-user", registrationConfirmed: true });
|
||||
expect(values).toEqual({ userId: "replacement-user", registrationConfirmed: true });
|
||||
await store.clear();
|
||||
expect(await store.load()).toBeNull();
|
||||
});
|
||||
|
||||
it("does not expose a partially confirmed legacy identity", async () => {
|
||||
const storageArea = {
|
||||
get: jest.fn(async () => ({ userId: "unconfirmed-user", registrationConfirmed: false })),
|
||||
set: jest.fn(async () => undefined),
|
||||
};
|
||||
|
||||
expect(await createBrowserCredentialStore(storageArea).load()).toBeNull();
|
||||
});
|
||||
|
||||
it("clears through set when remove is unavailable", async () => {
|
||||
const storageArea = {
|
||||
get: jest.fn(async () => ({})),
|
||||
set: jest.fn(async () => undefined),
|
||||
};
|
||||
|
||||
await createBrowserCredentialStore(storageArea).clear();
|
||||
expect(storageArea.set).toHaveBeenCalledWith({ userId: null, registrationConfirmed: false }, expect.any(Function));
|
||||
});
|
||||
|
||||
it("propagates callback storage failures", async () => {
|
||||
const storageArea = {
|
||||
get: jest.fn((_keys, callback) => callback({})),
|
||||
set: jest.fn((_values, callback) => callback()),
|
||||
};
|
||||
const store = createBrowserCredentialStore(storageArea, () => ({ message: "storage unavailable" }));
|
||||
|
||||
await expect(store.load()).rejects.toThrow("storage unavailable");
|
||||
});
|
||||
|
||||
it("rejects an invalid storage adapter", () => {
|
||||
expect(() => createBrowserCredentialStore()).toThrow(TypeError);
|
||||
expect(() => createBrowserCredentialStore({ get() {} })).toThrow(TypeError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,317 @@
|
||||
const DEFAULT_API_BASE_URL = "https://returnyoutubedislikeapi.com";
|
||||
const USER_ID_CHARSET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||
const VALID_VIDEO_ID = /^[A-Za-z0-9_-]{11}$/;
|
||||
const VALID_VOTE_VALUES = new Set([-1, 0, 1]);
|
||||
|
||||
class VoteClientError extends Error {
|
||||
constructor(message, options = {}) {
|
||||
super(message);
|
||||
this.name = "VoteClientError";
|
||||
this.status = options.status;
|
||||
this.cause = options.cause;
|
||||
}
|
||||
}
|
||||
|
||||
function countLeadingZeroes(bytes, limit = Infinity) {
|
||||
let zeroes = 0;
|
||||
|
||||
for (const originalValue of bytes) {
|
||||
let value = originalValue;
|
||||
if (value === 0) {
|
||||
zeroes += 8;
|
||||
} else {
|
||||
let count = 1;
|
||||
if (value >>> 4 === 0) {
|
||||
count += 4;
|
||||
value <<= 4;
|
||||
}
|
||||
if (value >>> 6 === 0) {
|
||||
count += 2;
|
||||
value <<= 2;
|
||||
}
|
||||
zeroes += count - (value >>> 7);
|
||||
break;
|
||||
}
|
||||
|
||||
if (zeroes >= limit) break;
|
||||
}
|
||||
|
||||
return zeroes;
|
||||
}
|
||||
|
||||
function generateUserId(cryptoImpl = globalThis.crypto, length = 36) {
|
||||
if (!Number.isInteger(length) || length <= 0) {
|
||||
throw new TypeError("User ID length must be a positive integer");
|
||||
}
|
||||
if (!cryptoImpl?.getRandomValues) {
|
||||
throw new VoteClientError("Web Crypto random generation is unavailable");
|
||||
}
|
||||
|
||||
const values = new Uint32Array(length);
|
||||
cryptoImpl.getRandomValues(values);
|
||||
let result = "";
|
||||
for (const value of values) {
|
||||
result += USER_ID_CHARSET[value % USER_ID_CHARSET.length];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function decodeBase64(value) {
|
||||
if (typeof atob !== "function") {
|
||||
throw new VoteClientError("Base64 decoding is unavailable");
|
||||
}
|
||||
return Uint8Array.from(atob(value), (character) => character.charCodeAt(0));
|
||||
}
|
||||
|
||||
function encodeBase64(bytes) {
|
||||
if (typeof btoa !== "function") {
|
||||
throw new VoteClientError("Base64 encoding is unavailable");
|
||||
}
|
||||
return btoa(String.fromCharCode(...bytes));
|
||||
}
|
||||
|
||||
async function solvePuzzle(puzzle, cryptoImpl = globalThis.crypto, maxAttempts) {
|
||||
if (!puzzle || typeof puzzle.challenge !== "string" || !Number.isInteger(puzzle.difficulty)) {
|
||||
throw new VoteClientError("The API returned an invalid puzzle");
|
||||
}
|
||||
if (!cryptoImpl?.subtle?.digest) {
|
||||
throw new VoteClientError("Web Crypto hashing is unavailable");
|
||||
}
|
||||
|
||||
const challenge = decodeBase64(puzzle.challenge);
|
||||
if (challenge.length !== 16) {
|
||||
throw new VoteClientError("The API returned an invalid puzzle challenge");
|
||||
}
|
||||
|
||||
const attempts = maxAttempts ?? Math.pow(2, puzzle.difficulty) * 3;
|
||||
if (!Number.isSafeInteger(attempts) || attempts <= 0) {
|
||||
throw new VoteClientError("The puzzle attempt limit is invalid");
|
||||
}
|
||||
|
||||
const buffer = new ArrayBuffer(20);
|
||||
const byteView = new Uint8Array(buffer);
|
||||
const integerView = new Uint32Array(buffer);
|
||||
byteView.set(challenge, 4);
|
||||
|
||||
for (let counter = 0; counter < attempts; counter++) {
|
||||
integerView[0] = counter;
|
||||
const hash = await cryptoImpl.subtle.digest("SHA-512", buffer);
|
||||
if (countLeadingZeroes(new Uint8Array(hash), puzzle.difficulty) >= puzzle.difficulty) {
|
||||
return { solution: encodeBase64(byteView.slice(0, 4)) };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function isConfirmedCredential(value) {
|
||||
return Boolean(value?.userId && value.registrationConfirmed === true);
|
||||
}
|
||||
|
||||
function createVoteClient({
|
||||
apiBaseUrl = DEFAULT_API_BASE_URL,
|
||||
fetchImpl = globalThis.fetch?.bind(globalThis),
|
||||
credentialStore,
|
||||
cryptoImpl = globalThis.crypto,
|
||||
puzzleAttempts = 2,
|
||||
votePuzzleAttempts = 3,
|
||||
} = {}) {
|
||||
if (typeof fetchImpl !== "function") throw new TypeError("fetchImpl is required");
|
||||
if (!credentialStore?.load || !credentialStore?.save || !credentialStore?.clear) {
|
||||
throw new TypeError("credentialStore must provide load, save, and clear");
|
||||
}
|
||||
if (!Number.isInteger(puzzleAttempts) || puzzleAttempts <= 0) {
|
||||
throw new TypeError("puzzleAttempts must be a positive integer");
|
||||
}
|
||||
if (!Number.isInteger(votePuzzleAttempts) || votePuzzleAttempts <= 0) {
|
||||
throw new TypeError("votePuzzleAttempts must be a positive integer");
|
||||
}
|
||||
|
||||
const baseUrl = apiBaseUrl.replace(/\/$/, "");
|
||||
const voteQueues = new Map();
|
||||
let registrationPromise = null;
|
||||
let registrationIsForced = false;
|
||||
|
||||
async function readJson(response, operation) {
|
||||
try {
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
throw new VoteClientError(`${operation} returned invalid JSON`, { status: response.status, cause: error });
|
||||
}
|
||||
}
|
||||
|
||||
function isSuccessful(response) {
|
||||
if (typeof response.ok === "boolean") return response.ok;
|
||||
return response.status >= 200 && response.status < 300;
|
||||
}
|
||||
|
||||
async function request(path, options, operation) {
|
||||
let response;
|
||||
try {
|
||||
response = await fetchImpl(`${baseUrl}${path}`, options);
|
||||
} catch (error) {
|
||||
throw new VoteClientError(`${operation} request failed`, { cause: error });
|
||||
}
|
||||
|
||||
if (!response || !Number.isInteger(response.status)) {
|
||||
throw new VoteClientError(`${operation} returned an invalid response`);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
async function postJson(path, body, operation) {
|
||||
const response = await request(
|
||||
path,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
operation,
|
||||
);
|
||||
return response;
|
||||
}
|
||||
|
||||
async function registerNewCredential() {
|
||||
const userId = generateUserId(cryptoImpl);
|
||||
|
||||
for (let attempt = 0; attempt < puzzleAttempts; attempt++) {
|
||||
const path = `/puzzle/registration?userId=${encodeURIComponent(userId)}`;
|
||||
const puzzleResponse = await request(
|
||||
path,
|
||||
{ method: "GET", headers: { Accept: "application/json" } },
|
||||
"Registration puzzle",
|
||||
);
|
||||
if (!isSuccessful(puzzleResponse)) {
|
||||
throw new VoteClientError("Registration puzzle request was rejected", { status: puzzleResponse.status });
|
||||
}
|
||||
|
||||
const puzzle = await readJson(puzzleResponse, "Registration puzzle");
|
||||
const solvedPuzzle = await solvePuzzle(puzzle, cryptoImpl);
|
||||
if (!solvedPuzzle) continue;
|
||||
|
||||
const confirmResponse = await postJson(path, solvedPuzzle, "Registration confirmation");
|
||||
if (!isSuccessful(confirmResponse)) {
|
||||
throw new VoteClientError("Registration confirmation was rejected", { status: confirmResponse.status });
|
||||
}
|
||||
const confirmed = await readJson(confirmResponse, "Registration confirmation");
|
||||
if (confirmed !== true) {
|
||||
throw new VoteClientError("Registration confirmation failed");
|
||||
}
|
||||
|
||||
const credential = { userId, registrationConfirmed: true };
|
||||
await credentialStore.save(credential);
|
||||
return { userId };
|
||||
}
|
||||
|
||||
throw new VoteClientError("Unable to solve the registration puzzle");
|
||||
}
|
||||
|
||||
async function ensureRegisteredInternal(force) {
|
||||
if (force) {
|
||||
await credentialStore.clear();
|
||||
} else {
|
||||
const credential = await credentialStore.load();
|
||||
if (isConfirmedCredential(credential)) return { userId: credential.userId };
|
||||
}
|
||||
return registerNewCredential();
|
||||
}
|
||||
|
||||
function trackRegistration(work, force) {
|
||||
let trackedPromise;
|
||||
trackedPromise = work.finally(() => {
|
||||
if (registrationPromise === trackedPromise) {
|
||||
registrationPromise = null;
|
||||
registrationIsForced = false;
|
||||
}
|
||||
});
|
||||
registrationPromise = trackedPromise;
|
||||
registrationIsForced = force;
|
||||
return trackedPromise;
|
||||
}
|
||||
|
||||
function ensureRegistered(options = {}) {
|
||||
const force = options.force === true;
|
||||
if (!registrationPromise) {
|
||||
return trackRegistration(ensureRegisteredInternal(force), force);
|
||||
}
|
||||
if (!force || registrationIsForced) return registrationPromise;
|
||||
|
||||
const pendingRegistration = registrationPromise;
|
||||
return trackRegistration(
|
||||
pendingRegistration.catch(() => undefined).then(() => ensureRegisteredInternal(true)),
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
async function performVote(videoId, value, authenticationRetriesRemaining) {
|
||||
let { userId } = await ensureRegistered();
|
||||
|
||||
for (let attempt = 0; attempt < votePuzzleAttempts; attempt++) {
|
||||
const voteResponse = await postJson("/interact/vote", { userId, videoId, value }, "Vote submission");
|
||||
|
||||
if (voteResponse.status === 401) {
|
||||
if (authenticationRetriesRemaining <= 0) {
|
||||
throw new VoteClientError("Vote submission was unauthorized after re-registration", { status: 401 });
|
||||
}
|
||||
({ userId } = await ensureRegistered({ force: true }));
|
||||
return performVote(videoId, value, authenticationRetriesRemaining - 1);
|
||||
}
|
||||
if (!isSuccessful(voteResponse)) {
|
||||
throw new VoteClientError("Vote submission was rejected", { status: voteResponse.status });
|
||||
}
|
||||
|
||||
const puzzle = await readJson(voteResponse, "Vote submission");
|
||||
const solvedPuzzle = await solvePuzzle(puzzle, cryptoImpl);
|
||||
if (!solvedPuzzle) continue;
|
||||
|
||||
const confirmResponse = await postJson(
|
||||
"/interact/confirmVote",
|
||||
{ ...solvedPuzzle, userId, videoId },
|
||||
"Vote confirmation",
|
||||
);
|
||||
if (confirmResponse.status === 401) {
|
||||
if (authenticationRetriesRemaining <= 0) {
|
||||
throw new VoteClientError("Vote confirmation was unauthorized after re-registration", { status: 401 });
|
||||
}
|
||||
await ensureRegistered({ force: true });
|
||||
return performVote(videoId, value, authenticationRetriesRemaining - 1);
|
||||
}
|
||||
if (!isSuccessful(confirmResponse)) {
|
||||
throw new VoteClientError("Vote confirmation was rejected", { status: confirmResponse.status });
|
||||
}
|
||||
|
||||
const confirmed = await readJson(confirmResponse, "Vote confirmation");
|
||||
if (confirmed !== true) throw new VoteClientError("Vote confirmation failed");
|
||||
return true;
|
||||
}
|
||||
|
||||
throw new VoteClientError("Unable to solve the vote puzzle");
|
||||
}
|
||||
|
||||
function submitVote(videoId, value) {
|
||||
if (typeof videoId !== "string" || !VALID_VIDEO_ID.test(videoId)) {
|
||||
return Promise.reject(new TypeError("videoId must be an 11-character YouTube video ID"));
|
||||
}
|
||||
if (!VALID_VOTE_VALUES.has(value)) {
|
||||
return Promise.reject(new TypeError("value must be -1, 0, or 1"));
|
||||
}
|
||||
|
||||
const previous = voteQueues.get(videoId) ?? Promise.resolve();
|
||||
const current = previous.catch(() => undefined).then(() => performVote(videoId, value, 1));
|
||||
voteQueues.set(videoId, current);
|
||||
current.then(
|
||||
() => {
|
||||
if (voteQueues.get(videoId) === current) voteQueues.delete(videoId);
|
||||
},
|
||||
() => {
|
||||
if (voteQueues.get(videoId) === current) voteQueues.delete(videoId);
|
||||
},
|
||||
);
|
||||
return current;
|
||||
}
|
||||
|
||||
return { ensureRegistered, submitVote };
|
||||
}
|
||||
|
||||
export { DEFAULT_API_BASE_URL, VoteClientError, countLeadingZeroes, generateUserId, solvePuzzle, createVoteClient };
|
||||
@@ -0,0 +1,720 @@
|
||||
import { countLeadingZeroes, createVoteClient, generateUserId, solvePuzzle } from "./vote-client";
|
||||
import { webcrypto } from "crypto";
|
||||
|
||||
const API_BASE_URL = "https://api.example";
|
||||
const VIDEO_A = "dQw4w9WgXcQ";
|
||||
const VIDEO_B = "abcdefghijk";
|
||||
const CHALLENGE = Buffer.alloc(16, 7).toString("base64");
|
||||
const ZERO_SOLUTION = "AAAAAA==";
|
||||
const GENERATED_USER_ID = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghij";
|
||||
|
||||
function jsonResponse(body, status = 200) {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
json: jest.fn().mockResolvedValue(body),
|
||||
};
|
||||
}
|
||||
|
||||
function invalidJsonResponse(status = 200) {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
json: jest.fn().mockRejectedValue(new SyntaxError("invalid JSON")),
|
||||
};
|
||||
}
|
||||
|
||||
function deferred() {
|
||||
let resolve;
|
||||
let reject;
|
||||
const promise = new Promise((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise;
|
||||
reject = rejectPromise;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
async function waitUntil(predicate) {
|
||||
for (let attempt = 0; attempt < 30; attempt++) {
|
||||
if (predicate()) return;
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
throw new Error("Timed out waiting for asynchronous vote-client work");
|
||||
}
|
||||
|
||||
function createCredentialStore(initialCredentials = null) {
|
||||
let credentials = initialCredentials ? { ...initialCredentials } : null;
|
||||
|
||||
return {
|
||||
load: jest.fn(async () => (credentials ? { ...credentials } : null)),
|
||||
save: jest.fn(async (nextCredentials) => {
|
||||
credentials = { ...nextCredentials };
|
||||
}),
|
||||
clear: jest.fn(async () => {
|
||||
credentials = null;
|
||||
}),
|
||||
peek: () => (credentials ? { ...credentials } : null),
|
||||
};
|
||||
}
|
||||
|
||||
function createCryptoImpl(hashFactory = () => new Uint8Array(64)) {
|
||||
return {
|
||||
getRandomValues: jest.fn((values) => {
|
||||
for (let index = 0; index < values.length; index++) {
|
||||
values[index] = index;
|
||||
}
|
||||
return values;
|
||||
}),
|
||||
subtle: {
|
||||
digest: jest.fn(async (algorithm, value) => {
|
||||
const hash = hashFactory(algorithm, new Uint8Array(value));
|
||||
return hash.buffer.slice(hash.byteOffset, hash.byteOffset + hash.byteLength);
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createClient({
|
||||
fetchImpl,
|
||||
credentialStore,
|
||||
cryptoImpl = createCryptoImpl(),
|
||||
puzzleAttempts,
|
||||
votePuzzleAttempts,
|
||||
} = {}) {
|
||||
return createVoteClient({
|
||||
apiBaseUrl: API_BASE_URL,
|
||||
fetchImpl,
|
||||
credentialStore,
|
||||
cryptoImpl,
|
||||
...(puzzleAttempts === undefined ? {} : { puzzleAttempts }),
|
||||
...(votePuzzleAttempts === undefined ? {} : { votePuzzleAttempts }),
|
||||
});
|
||||
}
|
||||
|
||||
function registrationPuzzle() {
|
||||
return { challenge: CHALLENGE, difficulty: 1 };
|
||||
}
|
||||
|
||||
function votePuzzle() {
|
||||
return { challenge: CHALLENGE, difficulty: 1 };
|
||||
}
|
||||
|
||||
function parseBody(call) {
|
||||
return JSON.parse(call[1].body);
|
||||
}
|
||||
|
||||
describe("proof-of-work helpers", () => {
|
||||
it.each([
|
||||
[[], 0],
|
||||
[[0x80], 0],
|
||||
[[0x40], 1],
|
||||
[[0x08], 4],
|
||||
[[0x00, 0x01], 15],
|
||||
[[0x00, 0x00, 0x10], 19],
|
||||
])("counts leading zero bits in %p", (bytes, expected) => {
|
||||
expect(countLeadingZeroes(Uint8Array.from(bytes))).toBe(expected);
|
||||
});
|
||||
|
||||
it("stops once the requested leading-zero limit is reached", () => {
|
||||
expect(countLeadingZeroes(Uint8Array.from([0x00, 0x00, 0xff]), 8)).toBe(8);
|
||||
});
|
||||
|
||||
it("generates an alphanumeric ID of the requested length using injected crypto", () => {
|
||||
const cryptoImpl = createCryptoImpl();
|
||||
|
||||
expect(generateUserId(cryptoImpl, 8)).toBe("ABCDEFGH");
|
||||
expect(cryptoImpl.getRandomValues).toHaveBeenCalledTimes(1);
|
||||
expect(cryptoImpl.getRandomValues.mock.calls[0][0]).toBeInstanceOf(Uint32Array);
|
||||
});
|
||||
|
||||
it("rejects invalid ID lengths and unavailable random generation", () => {
|
||||
expect(() => generateUserId(createCryptoImpl(), 0)).toThrow(TypeError);
|
||||
expect(() => generateUserId({}, 36)).toThrow(/crypto|random/i);
|
||||
});
|
||||
|
||||
it("solves the SHA-512 puzzle and returns the four-byte counter as base64", async () => {
|
||||
let digestCall = 0;
|
||||
const cryptoImpl = createCryptoImpl(() => {
|
||||
digestCall++;
|
||||
const hash = new Uint8Array(64);
|
||||
hash[0] = digestCall === 1 ? 0xff : 0x0f;
|
||||
return hash;
|
||||
});
|
||||
|
||||
await expect(solvePuzzle({ challenge: CHALLENGE, difficulty: 4 }, cryptoImpl, 2)).resolves.toEqual({
|
||||
solution: "AQAAAA==",
|
||||
});
|
||||
expect(cryptoImpl.subtle.digest).toHaveBeenCalledTimes(2);
|
||||
expect(cryptoImpl.subtle.digest.mock.calls[0][0]).toBe("SHA-512");
|
||||
expect(Array.from(new Uint8Array(cryptoImpl.subtle.digest.mock.calls[1][1]).slice(0, 4))).toEqual([1, 0, 0, 0]);
|
||||
expect(Array.from(new Uint8Array(cryptoImpl.subtle.digest.mock.calls[1][1]).slice(4))).toEqual(
|
||||
Array.from(Buffer.from(CHALLENGE, "base64")),
|
||||
);
|
||||
});
|
||||
|
||||
it("matches a known low-difficulty SHA-512 proof-of-work vector", async () => {
|
||||
await expect(solvePuzzle({ challenge: CHALLENGE, difficulty: 8 }, webcrypto, 116)).resolves.toEqual({
|
||||
solution: "cwAAAA==",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null when the hash-attempt bound is exhausted", async () => {
|
||||
const cryptoImpl = createCryptoImpl(() => new Uint8Array(64).fill(0xff));
|
||||
|
||||
await expect(solvePuzzle({ challenge: CHALLENGE, difficulty: 1 }, cryptoImpl, 3)).resolves.toBeNull();
|
||||
expect(cryptoImpl.subtle.digest).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createVoteClient registration", () => {
|
||||
it("validates required client dependencies and retry bounds", () => {
|
||||
expect(() => createVoteClient()).toThrow(TypeError);
|
||||
expect(() => createVoteClient({ fetchImpl: jest.fn(), credentialStore: {} })).toThrow(TypeError);
|
||||
expect(() =>
|
||||
createVoteClient({
|
||||
fetchImpl: jest.fn(),
|
||||
credentialStore: createCredentialStore(),
|
||||
puzzleAttempts: 0,
|
||||
}),
|
||||
).toThrow(TypeError);
|
||||
expect(() =>
|
||||
createVoteClient({
|
||||
fetchImpl: jest.fn(),
|
||||
credentialStore: createCredentialStore(),
|
||||
votePuzzleAttempts: 0,
|
||||
}),
|
||||
).toThrow(TypeError);
|
||||
});
|
||||
|
||||
it("registers cold credentials with exact request shapes and persists the confirmed identity", async () => {
|
||||
const credentialStore = createCredentialStore();
|
||||
const fetchImpl = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce(jsonResponse(registrationPuzzle()))
|
||||
.mockResolvedValueOnce(jsonResponse(true));
|
||||
const client = createClient({ fetchImpl, credentialStore });
|
||||
|
||||
await expect(client.ensureRegistered()).resolves.toEqual({ userId: GENERATED_USER_ID });
|
||||
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(2);
|
||||
expect(fetchImpl.mock.calls[0]).toEqual([
|
||||
`${API_BASE_URL}/puzzle/registration?userId=${GENERATED_USER_ID}`,
|
||||
{ method: "GET", headers: { Accept: "application/json" } },
|
||||
]);
|
||||
expect(fetchImpl.mock.calls[1][0]).toBe(`${API_BASE_URL}/puzzle/registration?userId=${GENERATED_USER_ID}`);
|
||||
expect(fetchImpl.mock.calls[1][1]).toMatchObject({
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
expect(parseBody(fetchImpl.mock.calls[1])).toEqual({ solution: ZERO_SOLUTION });
|
||||
expect(credentialStore.save).toHaveBeenCalledWith({
|
||||
userId: GENERATED_USER_ID,
|
||||
registrationConfirmed: true,
|
||||
});
|
||||
expect(credentialStore.peek()).toEqual({ userId: GENERATED_USER_ID, registrationConfirmed: true });
|
||||
expect(credentialStore.clear).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reuses warm confirmed credentials without making a registration request", async () => {
|
||||
const credentials = { userId: "existing-user", registrationConfirmed: true };
|
||||
const credentialStore = createCredentialStore(credentials);
|
||||
const fetchImpl = jest.fn();
|
||||
const client = createClient({ fetchImpl, credentialStore });
|
||||
|
||||
await expect(client.ensureRegistered()).resolves.toEqual({ userId: credentials.userId });
|
||||
|
||||
expect(fetchImpl).not.toHaveBeenCalled();
|
||||
expect(credentialStore.save).not.toHaveBeenCalled();
|
||||
expect(credentialStore.clear).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reuses a persisted identity after client recreation", async () => {
|
||||
const credentialStore = createCredentialStore();
|
||||
const fetchImpl = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce(jsonResponse(registrationPuzzle()))
|
||||
.mockResolvedValueOnce(jsonResponse(true));
|
||||
|
||||
await expect(createClient({ fetchImpl, credentialStore }).ensureRegistered()).resolves.toEqual({
|
||||
userId: GENERATED_USER_ID,
|
||||
});
|
||||
await expect(createClient({ fetchImpl, credentialStore }).ensureRegistered()).resolves.toEqual({
|
||||
userId: GENERATED_USER_ID,
|
||||
});
|
||||
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(2);
|
||||
expect(credentialStore.save).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("deduplicates concurrent cold registration", async () => {
|
||||
const credentialStore = createCredentialStore();
|
||||
const registrationResponse = deferred();
|
||||
const fetchImpl = jest
|
||||
.fn()
|
||||
.mockImplementationOnce(() => registrationResponse.promise)
|
||||
.mockResolvedValueOnce(jsonResponse(true));
|
||||
const client = createClient({ fetchImpl, credentialStore });
|
||||
|
||||
const first = client.ensureRegistered();
|
||||
const second = client.ensureRegistered();
|
||||
await waitUntil(() => fetchImpl.mock.calls.length === 1);
|
||||
|
||||
registrationResponse.resolve(jsonResponse(registrationPuzzle()));
|
||||
|
||||
await expect(Promise.all([first, second])).resolves.toEqual([
|
||||
{ userId: GENERATED_USER_ID },
|
||||
{ userId: GENERATED_USER_ID },
|
||||
]);
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(2);
|
||||
expect(credentialStore.save).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("bounds fresh registration challenges when no puzzle can be solved", async () => {
|
||||
const credentialStore = createCredentialStore();
|
||||
const cryptoImpl = createCryptoImpl(() => new Uint8Array(64).fill(0xff));
|
||||
const fetchImpl = jest.fn().mockResolvedValue(jsonResponse(registrationPuzzle()));
|
||||
const client = createClient({ fetchImpl, credentialStore, cryptoImpl, puzzleAttempts: 2 });
|
||||
|
||||
await expect(client.ensureRegistered()).rejects.toThrow(/registration|puzzle/i);
|
||||
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(2);
|
||||
expect(
|
||||
fetchImpl.mock.calls.every(([url, options]) => url.includes("/puzzle/registration") && options.method === "GET"),
|
||||
).toBe(true);
|
||||
expect(cryptoImpl.subtle.digest).toHaveBeenCalledTimes(12);
|
||||
expect(credentialStore.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fails without persisting credentials when registration confirmation is false", async () => {
|
||||
const credentialStore = createCredentialStore();
|
||||
const fetchImpl = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce(jsonResponse(registrationPuzzle()))
|
||||
.mockResolvedValueOnce(jsonResponse(false));
|
||||
const client = createClient({ fetchImpl, credentialStore, puzzleAttempts: 1 });
|
||||
|
||||
await expect(client.ensureRegistered()).rejects.toThrow(/registration|confirm/i);
|
||||
expect(credentialStore.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("surfaces registration HTTP failures and does not continue to confirmation", async () => {
|
||||
const credentialStore = createCredentialStore();
|
||||
const fetchImpl = jest.fn().mockResolvedValue(jsonResponse({ error: "unavailable" }, 503));
|
||||
const client = createClient({ fetchImpl, credentialStore });
|
||||
|
||||
await expect(client.ensureRegistered()).rejects.toThrow(/503|registration/i);
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
||||
expect(credentialStore.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("surfaces registration network and malformed JSON failures without recursion", async () => {
|
||||
const networkStore = createCredentialStore();
|
||||
const networkFetch = jest.fn().mockRejectedValue(new Error("offline"));
|
||||
await expect(
|
||||
createClient({ fetchImpl: networkFetch, credentialStore: networkStore }).ensureRegistered(),
|
||||
).rejects.toThrow(/request failed/i);
|
||||
expect(networkFetch).toHaveBeenCalledTimes(1);
|
||||
expect(networkStore.save).not.toHaveBeenCalled();
|
||||
|
||||
const malformedStore = createCredentialStore();
|
||||
const malformedFetch = jest.fn().mockResolvedValue(invalidJsonResponse());
|
||||
await expect(
|
||||
createClient({ fetchImpl: malformedFetch, credentialStore: malformedStore }).ensureRegistered(),
|
||||
).rejects.toThrow(/invalid JSON/i);
|
||||
expect(malformedFetch).toHaveBeenCalledTimes(1);
|
||||
expect(malformedStore.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("propagates credential load and save failures", async () => {
|
||||
const loadFailureStore = createCredentialStore();
|
||||
loadFailureStore.load.mockRejectedValue(new Error("load failed"));
|
||||
const unusedFetch = jest.fn();
|
||||
await expect(
|
||||
createClient({ fetchImpl: unusedFetch, credentialStore: loadFailureStore }).ensureRegistered(),
|
||||
).rejects.toThrow("load failed");
|
||||
expect(unusedFetch).not.toHaveBeenCalled();
|
||||
|
||||
const saveFailureStore = createCredentialStore();
|
||||
saveFailureStore.save.mockRejectedValue(new Error("save failed"));
|
||||
const fetchImpl = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce(jsonResponse(registrationPuzzle()))
|
||||
.mockResolvedValueOnce(jsonResponse(true));
|
||||
await expect(createClient({ fetchImpl, credentialStore: saveFailureStore }).ensureRegistered()).rejects.toThrow(
|
||||
"save failed",
|
||||
);
|
||||
expect(saveFailureStore.peek()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("createVoteClient submission", () => {
|
||||
it.each([null, undefined, "", "short", "way-too-long-video-id", "invalid$id"])(
|
||||
"rejects invalid video ID %p before making a request",
|
||||
async (videoId) => {
|
||||
const credentialStore = createCredentialStore({ userId: "existing-user", registrationConfirmed: true });
|
||||
const fetchImpl = jest.fn();
|
||||
const client = createClient({ fetchImpl, credentialStore });
|
||||
|
||||
await expect(client.submitVote(videoId, 1)).rejects.toThrow(TypeError);
|
||||
expect(fetchImpl).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it.each([-2, 2, null, undefined, "1"])("rejects invalid vote value %p before making a request", async (value) => {
|
||||
const credentialStore = createCredentialStore({ userId: "existing-user", registrationConfirmed: true });
|
||||
const fetchImpl = jest.fn();
|
||||
const client = createClient({ fetchImpl, credentialStore });
|
||||
|
||||
await expect(client.submitVote(VIDEO_A, value)).rejects.toThrow(TypeError);
|
||||
expect(fetchImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("submits and confirms a vote with exact payloads", async () => {
|
||||
const credentialStore = createCredentialStore({ userId: "existing-user", registrationConfirmed: true });
|
||||
const fetchImpl = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce(jsonResponse(votePuzzle()))
|
||||
.mockResolvedValueOnce(jsonResponse(true));
|
||||
const client = createClient({ fetchImpl, credentialStore });
|
||||
|
||||
await expect(client.submitVote(VIDEO_A, -1)).resolves.toBe(true);
|
||||
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(2);
|
||||
expect(fetchImpl.mock.calls[0][0]).toBe(`${API_BASE_URL}/interact/vote`);
|
||||
expect(fetchImpl.mock.calls[0][1]).toMatchObject({
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
expect(parseBody(fetchImpl.mock.calls[0])).toEqual({
|
||||
userId: "existing-user",
|
||||
videoId: VIDEO_A,
|
||||
value: -1,
|
||||
});
|
||||
expect(fetchImpl.mock.calls[1][0]).toBe(`${API_BASE_URL}/interact/confirmVote`);
|
||||
expect(fetchImpl.mock.calls[1][1]).toMatchObject({
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
expect(parseBody(fetchImpl.mock.calls[1])).toEqual({
|
||||
solution: ZERO_SOLUTION,
|
||||
userId: "existing-user",
|
||||
videoId: VIDEO_A,
|
||||
});
|
||||
});
|
||||
|
||||
it("shares one cold registration across concurrent first votes", async () => {
|
||||
const credentialStore = createCredentialStore();
|
||||
const registrationResponse = deferred();
|
||||
const fetchImpl = jest.fn(async (url) => {
|
||||
if (url.includes("/puzzle/registration")) {
|
||||
if (fetchImpl.mock.calls.filter(([calledUrl]) => calledUrl.includes("/puzzle/registration")).length === 1) {
|
||||
return registrationResponse.promise;
|
||||
}
|
||||
return jsonResponse(true);
|
||||
}
|
||||
if (url.endsWith("/interact/vote")) return jsonResponse(votePuzzle());
|
||||
return jsonResponse(true);
|
||||
});
|
||||
const client = createClient({ fetchImpl, credentialStore });
|
||||
|
||||
const first = client.submitVote(VIDEO_A, 1);
|
||||
const second = client.submitVote(VIDEO_B, -1);
|
||||
await waitUntil(() => fetchImpl.mock.calls.length === 1);
|
||||
registrationResponse.resolve(jsonResponse(registrationPuzzle()));
|
||||
|
||||
await expect(Promise.all([first, second])).resolves.toEqual([true, true]);
|
||||
expect(fetchImpl.mock.calls.filter(([url]) => url.includes("/puzzle/registration"))).toHaveLength(2);
|
||||
expect(credentialStore.save).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
fetchImpl.mock.calls.filter(([url]) => url.endsWith("/interact/vote")).map((call) => parseBody(call).userId),
|
||||
).toEqual([GENERATED_USER_ID, GENERATED_USER_ID]);
|
||||
});
|
||||
|
||||
it("clears credentials, registers once, and retries once after a 401", async () => {
|
||||
const credentialStore = createCredentialStore({ userId: "expired-user", registrationConfirmed: true });
|
||||
const fetchImpl = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce(jsonResponse(null, 401))
|
||||
.mockResolvedValueOnce(jsonResponse(registrationPuzzle()))
|
||||
.mockResolvedValueOnce(jsonResponse(true))
|
||||
.mockResolvedValueOnce(jsonResponse(votePuzzle()))
|
||||
.mockResolvedValueOnce(jsonResponse(true));
|
||||
const client = createClient({ fetchImpl, credentialStore });
|
||||
|
||||
await expect(client.submitVote(VIDEO_A, 1)).resolves.toBe(true);
|
||||
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(5);
|
||||
expect(credentialStore.clear).toHaveBeenCalledTimes(1);
|
||||
expect(credentialStore.save).toHaveBeenCalledTimes(1);
|
||||
expect(parseBody(fetchImpl.mock.calls[0])).toEqual({
|
||||
userId: "expired-user",
|
||||
videoId: VIDEO_A,
|
||||
value: 1,
|
||||
});
|
||||
expect(fetchImpl.mock.calls[1][0]).toBe(`${API_BASE_URL}/puzzle/registration?userId=${GENERATED_USER_ID}`);
|
||||
expect(parseBody(fetchImpl.mock.calls[3])).toEqual({
|
||||
userId: GENERATED_USER_ID,
|
||||
videoId: VIDEO_A,
|
||||
value: 1,
|
||||
});
|
||||
expect(parseBody(fetchImpl.mock.calls[4])).toEqual({
|
||||
solution: ZERO_SOLUTION,
|
||||
userId: GENERATED_USER_ID,
|
||||
videoId: VIDEO_A,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not retry registration more than once when the retried vote is also unauthorized", async () => {
|
||||
const credentialStore = createCredentialStore({ userId: "expired-user", registrationConfirmed: true });
|
||||
const fetchImpl = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce(jsonResponse(null, 401))
|
||||
.mockResolvedValueOnce(jsonResponse(registrationPuzzle()))
|
||||
.mockResolvedValueOnce(jsonResponse(true))
|
||||
.mockResolvedValueOnce(jsonResponse(null, 401));
|
||||
const client = createClient({ fetchImpl, credentialStore });
|
||||
|
||||
await expect(client.submitVote(VIDEO_A, 0)).rejects.toThrow(/401|unauthorized/i);
|
||||
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(4);
|
||||
expect(credentialStore.clear).toHaveBeenCalledTimes(1);
|
||||
expect(credentialStore.save).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("honors forced re-registration while a normal credential read is in flight", async () => {
|
||||
const expiredCredentials = { userId: "expired-user", registrationConfirmed: true };
|
||||
const credentialStore = createCredentialStore(expiredCredentials);
|
||||
const firstUnauthorizedVote = deferred();
|
||||
let expiredVoteCount = 0;
|
||||
const fetchImpl = jest.fn(async (url, options) => {
|
||||
if (url.endsWith("/interact/vote")) {
|
||||
const body = JSON.parse(options.body);
|
||||
if (body.userId === expiredCredentials.userId) {
|
||||
expiredVoteCount++;
|
||||
return expiredVoteCount === 1 ? firstUnauthorizedVote.promise : jsonResponse(null, 401);
|
||||
}
|
||||
return jsonResponse(votePuzzle());
|
||||
}
|
||||
if (url.includes("/puzzle/registration")) {
|
||||
return options.method === "GET" ? jsonResponse(registrationPuzzle()) : jsonResponse(true);
|
||||
}
|
||||
return jsonResponse(true);
|
||||
});
|
||||
const client = createClient({ fetchImpl, credentialStore });
|
||||
|
||||
const submission = client.submitVote(VIDEO_A, 1);
|
||||
await waitUntil(() => expiredVoteCount === 1);
|
||||
|
||||
const pendingCredentialRead = deferred();
|
||||
credentialStore.load.mockImplementationOnce(() => pendingCredentialRead.promise);
|
||||
const ordinaryRegistration = client.ensureRegistered();
|
||||
await waitUntil(() => credentialStore.load.mock.calls.length === 2);
|
||||
|
||||
firstUnauthorizedVote.resolve(jsonResponse(null, 401));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(credentialStore.clear).not.toHaveBeenCalled();
|
||||
|
||||
pendingCredentialRead.resolve(expiredCredentials);
|
||||
await expect(ordinaryRegistration).resolves.toEqual({ userId: expiredCredentials.userId });
|
||||
await expect(submission).resolves.toBe(true);
|
||||
|
||||
expect(expiredVoteCount).toBe(1);
|
||||
expect(credentialStore.clear).toHaveBeenCalledTimes(1);
|
||||
expect(credentialStore.save).toHaveBeenCalledWith({
|
||||
userId: GENERATED_USER_ID,
|
||||
registrationConfirmed: true,
|
||||
});
|
||||
expect(fetchImpl.mock.calls.filter(([url]) => url.includes("/puzzle/registration"))).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("stops when clearing stale credentials fails after a 401", async () => {
|
||||
const credentialStore = createCredentialStore({ userId: "expired-user", registrationConfirmed: true });
|
||||
credentialStore.clear.mockRejectedValue(new Error("clear failed"));
|
||||
const fetchImpl = jest.fn().mockResolvedValue(jsonResponse(null, 401));
|
||||
const client = createClient({ fetchImpl, credentialStore });
|
||||
|
||||
await expect(client.submitVote(VIDEO_A, 1)).rejects.toThrow("clear failed");
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
||||
expect(credentialStore.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses a third fresh vote puzzle after the first two solvers are exhausted", async () => {
|
||||
const credentialStore = createCredentialStore({ userId: "existing-user", registrationConfirmed: true });
|
||||
let digestCall = 0;
|
||||
const cryptoImpl = createCryptoImpl(() => {
|
||||
digestCall++;
|
||||
return new Uint8Array(64).fill(digestCall <= 12 ? 0xff : 0);
|
||||
});
|
||||
const fetchImpl = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce(jsonResponse(votePuzzle()))
|
||||
.mockResolvedValueOnce(jsonResponse(votePuzzle()))
|
||||
.mockResolvedValueOnce(jsonResponse(votePuzzle()))
|
||||
.mockResolvedValueOnce(jsonResponse(true));
|
||||
const client = createClient({ fetchImpl, credentialStore, cryptoImpl });
|
||||
|
||||
await expect(client.submitVote(VIDEO_A, 1)).resolves.toBe(true);
|
||||
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(4);
|
||||
expect(fetchImpl.mock.calls.map(([url]) => url)).toEqual([
|
||||
`${API_BASE_URL}/interact/vote`,
|
||||
`${API_BASE_URL}/interact/vote`,
|
||||
`${API_BASE_URL}/interact/vote`,
|
||||
`${API_BASE_URL}/interact/confirmVote`,
|
||||
]);
|
||||
expect(fetchImpl.mock.calls.slice(0, 3).map((call) => parseBody(call))).toEqual([
|
||||
{ userId: "existing-user", videoId: VIDEO_A, value: 1 },
|
||||
{ userId: "existing-user", videoId: VIDEO_A, value: 1 },
|
||||
{ userId: "existing-user", videoId: VIDEO_A, value: 1 },
|
||||
]);
|
||||
expect(parseBody(fetchImpl.mock.calls[3])).toEqual({
|
||||
solution: ZERO_SOLUTION,
|
||||
userId: "existing-user",
|
||||
videoId: VIDEO_A,
|
||||
});
|
||||
expect(cryptoImpl.subtle.digest).toHaveBeenCalledTimes(13);
|
||||
});
|
||||
|
||||
it("bounds fresh vote challenges after all three puzzles are exhausted", async () => {
|
||||
const credentialStore = createCredentialStore({ userId: "existing-user", registrationConfirmed: true });
|
||||
const cryptoImpl = createCryptoImpl(() => new Uint8Array(64).fill(0xff));
|
||||
const fetchImpl = jest.fn().mockResolvedValue(jsonResponse(votePuzzle()));
|
||||
const client = createClient({ fetchImpl, credentialStore, cryptoImpl });
|
||||
|
||||
await expect(client.submitVote(VIDEO_A, 1)).rejects.toThrow(/vote|puzzle/i);
|
||||
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(3);
|
||||
expect(fetchImpl.mock.calls.every(([url]) => url === `${API_BASE_URL}/interact/vote`)).toBe(true);
|
||||
expect(fetchImpl.mock.calls.map((call) => parseBody(call))).toEqual([
|
||||
{ userId: "existing-user", videoId: VIDEO_A, value: 1 },
|
||||
{ userId: "existing-user", videoId: VIDEO_A, value: 1 },
|
||||
{ userId: "existing-user", videoId: VIDEO_A, value: 1 },
|
||||
]);
|
||||
expect(cryptoImpl.subtle.digest).toHaveBeenCalledTimes(18);
|
||||
});
|
||||
|
||||
it("rejects a failed confirmation instead of reporting success", async () => {
|
||||
const credentialStore = createCredentialStore({ userId: "existing-user", registrationConfirmed: true });
|
||||
const fetchImpl = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce(jsonResponse(votePuzzle()))
|
||||
.mockResolvedValueOnce(jsonResponse(false));
|
||||
const client = createClient({ fetchImpl, credentialStore });
|
||||
|
||||
await expect(client.submitVote(VIDEO_A, 1)).rejects.toThrow(/confirm/i);
|
||||
});
|
||||
|
||||
it("surfaces a non-authentication vote rejection without registering or confirming", async () => {
|
||||
const credentialStore = createCredentialStore({ userId: "existing-user", registrationConfirmed: true });
|
||||
const fetchImpl = jest.fn().mockResolvedValue(jsonResponse({ error: "unavailable" }, 503));
|
||||
const client = createClient({ fetchImpl, credentialStore });
|
||||
|
||||
await expect(client.submitVote(VIDEO_A, 1)).rejects.toThrow(/503|rejected|submission/i);
|
||||
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
||||
expect(credentialStore.clear).not.toHaveBeenCalled();
|
||||
expect(credentialStore.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("surfaces a confirmation HTTP failure instead of reporting success", async () => {
|
||||
const credentialStore = createCredentialStore({ userId: "existing-user", registrationConfirmed: true });
|
||||
const fetchImpl = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce(jsonResponse(votePuzzle()))
|
||||
.mockResolvedValueOnce(jsonResponse({ error: "unavailable" }, 503));
|
||||
const client = createClient({ fetchImpl, credentialStore });
|
||||
|
||||
await expect(client.submitVote(VIDEO_A, -1)).rejects.toThrow(/503|confirm|rejected/i);
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("surfaces vote and confirmation malformed JSON without retrying indefinitely", async () => {
|
||||
const credentialStore = createCredentialStore({ userId: "existing-user", registrationConfirmed: true });
|
||||
const malformedVoteFetch = jest.fn().mockResolvedValue(invalidJsonResponse());
|
||||
await expect(
|
||||
createClient({ fetchImpl: malformedVoteFetch, credentialStore }).submitVote(VIDEO_A, 1),
|
||||
).rejects.toThrow(/invalid JSON/i);
|
||||
expect(malformedVoteFetch).toHaveBeenCalledTimes(1);
|
||||
|
||||
const malformedConfirmationFetch = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce(jsonResponse(votePuzzle()))
|
||||
.mockResolvedValueOnce(invalidJsonResponse());
|
||||
await expect(
|
||||
createClient({ fetchImpl: malformedConfirmationFetch, credentialStore }).submitVote(VIDEO_A, -1),
|
||||
).rejects.toThrow(/invalid JSON/i);
|
||||
expect(malformedConfirmationFetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("surfaces vote and confirmation network failures without recursion", async () => {
|
||||
const credentialStore = createCredentialStore({ userId: "existing-user", registrationConfirmed: true });
|
||||
const voteNetworkFetch = jest.fn().mockRejectedValue(new Error("offline"));
|
||||
await expect(createClient({ fetchImpl: voteNetworkFetch, credentialStore }).submitVote(VIDEO_A, 1)).rejects.toThrow(
|
||||
/request failed/i,
|
||||
);
|
||||
expect(voteNetworkFetch).toHaveBeenCalledTimes(1);
|
||||
|
||||
const confirmationNetworkFetch = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce(jsonResponse(votePuzzle()))
|
||||
.mockRejectedValueOnce(new Error("offline"));
|
||||
await expect(
|
||||
createClient({ fetchImpl: confirmationNetworkFetch, credentialStore }).submitVote(VIDEO_A, -1),
|
||||
).rejects.toThrow(/request failed/i);
|
||||
expect(confirmationNetworkFetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("serializes submissions for the same video through confirmation", async () => {
|
||||
const credentialStore = createCredentialStore({ userId: "existing-user", registrationConfirmed: true });
|
||||
const firstConfirmation = deferred();
|
||||
let confirmationCount = 0;
|
||||
const fetchImpl = jest.fn(async (url) => {
|
||||
if (url.endsWith("/interact/vote")) return jsonResponse(votePuzzle());
|
||||
confirmationCount++;
|
||||
return confirmationCount === 1 ? firstConfirmation.promise : jsonResponse(true);
|
||||
});
|
||||
const client = createClient({ fetchImpl, credentialStore });
|
||||
|
||||
const first = client.submitVote(VIDEO_A, 1);
|
||||
const second = client.submitVote(VIDEO_A, -1);
|
||||
|
||||
await waitUntil(() => fetchImpl.mock.calls.some(([url]) => url.endsWith("/interact/confirmVote")));
|
||||
expect(fetchImpl.mock.calls.filter(([url]) => url.endsWith("/interact/vote"))).toHaveLength(1);
|
||||
|
||||
firstConfirmation.resolve(jsonResponse(true));
|
||||
await expect(Promise.all([first, second])).resolves.toEqual([true, true]);
|
||||
|
||||
expect(fetchImpl.mock.calls.map(([url]) => url.replace(API_BASE_URL, ""))).toEqual([
|
||||
"/interact/vote",
|
||||
"/interact/confirmVote",
|
||||
"/interact/vote",
|
||||
"/interact/confirmVote",
|
||||
]);
|
||||
expect(parseBody(fetchImpl.mock.calls[0]).value).toBe(1);
|
||||
expect(parseBody(fetchImpl.mock.calls[2]).value).toBe(-1);
|
||||
});
|
||||
|
||||
it("does not block a different video's submission behind an in-flight confirmation", async () => {
|
||||
const credentialStore = createCredentialStore({ userId: "existing-user", registrationConfirmed: true });
|
||||
const videoAConfirmation = deferred();
|
||||
const fetchImpl = jest.fn(async (url, options) => {
|
||||
const body = JSON.parse(options.body);
|
||||
if (url.endsWith("/interact/vote")) return jsonResponse(votePuzzle());
|
||||
return body.videoId === VIDEO_A ? videoAConfirmation.promise : jsonResponse(true);
|
||||
});
|
||||
const client = createClient({ fetchImpl, credentialStore });
|
||||
|
||||
const videoA = client.submitVote(VIDEO_A, 1);
|
||||
const videoB = client.submitVote(VIDEO_B, -1);
|
||||
|
||||
await expect(videoB).resolves.toBe(true);
|
||||
expect(
|
||||
fetchImpl.mock.calls.some(([url, options]) => {
|
||||
return url.endsWith("/interact/confirmVote") && JSON.parse(options.body).videoId === VIDEO_B;
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
videoAConfirmation.resolve(jsonResponse(true));
|
||||
await expect(videoA).resolves.toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
const LIKED_STATE = "LIKED_STATE";
|
||||
const DISLIKED_STATE = "DISLIKED_STATE";
|
||||
const NEUTRAL_STATE = "NEUTRAL_STATE";
|
||||
|
||||
const LIKE_ACTION = "like";
|
||||
const DISLIKE_ACTION = "dislike";
|
||||
|
||||
const TRANSITIONS = {
|
||||
[NEUTRAL_STATE]: {
|
||||
[LIKE_ACTION]: { nextState: LIKED_STATE, value: 1, likesDelta: 1, dislikesDelta: 0 },
|
||||
[DISLIKE_ACTION]: { nextState: DISLIKED_STATE, value: -1, likesDelta: 0, dislikesDelta: 1 },
|
||||
},
|
||||
[LIKED_STATE]: {
|
||||
[LIKE_ACTION]: { nextState: NEUTRAL_STATE, value: 0, likesDelta: -1, dislikesDelta: 0 },
|
||||
[DISLIKE_ACTION]: { nextState: DISLIKED_STATE, value: -1, likesDelta: -1, dislikesDelta: 1 },
|
||||
},
|
||||
[DISLIKED_STATE]: {
|
||||
[LIKE_ACTION]: { nextState: LIKED_STATE, value: 1, likesDelta: 1, dislikesDelta: -1 },
|
||||
[DISLIKE_ACTION]: { nextState: NEUTRAL_STATE, value: 0, likesDelta: 0, dislikesDelta: -1 },
|
||||
},
|
||||
};
|
||||
|
||||
function resolveVoteTransition(previousState, action) {
|
||||
const transition = TRANSITIONS[previousState]?.[action];
|
||||
if (!transition) {
|
||||
throw new TypeError(`Unsupported vote transition: ${previousState} -> ${action}`);
|
||||
}
|
||||
return { ...transition };
|
||||
}
|
||||
|
||||
function applyVoteTransitionCounts(likes, dislikes, transition) {
|
||||
if (!transition || !Number.isFinite(transition.likesDelta) || !Number.isFinite(transition.dislikesDelta)) {
|
||||
throw new TypeError("A valid vote transition is required");
|
||||
}
|
||||
|
||||
const normalizedLikes = Number.isFinite(likes) ? likes : 0;
|
||||
const normalizedDislikes = Number.isFinite(dislikes) ? dislikes : 0;
|
||||
return {
|
||||
likes: Math.max(0, normalizedLikes + transition.likesDelta),
|
||||
dislikes: Math.max(0, normalizedDislikes + transition.dislikesDelta),
|
||||
};
|
||||
}
|
||||
|
||||
function shouldSubmitVote({ disableVoteSubmission = false, signedOut = false } = {}) {
|
||||
return disableVoteSubmission !== true && signedOut !== true;
|
||||
}
|
||||
|
||||
export {
|
||||
LIKED_STATE,
|
||||
DISLIKED_STATE,
|
||||
NEUTRAL_STATE,
|
||||
LIKE_ACTION,
|
||||
DISLIKE_ACTION,
|
||||
resolveVoteTransition,
|
||||
applyVoteTransitionCounts,
|
||||
shouldSubmitVote,
|
||||
};
|
||||
@@ -0,0 +1,101 @@
|
||||
import {
|
||||
LIKED_STATE,
|
||||
DISLIKED_STATE,
|
||||
NEUTRAL_STATE,
|
||||
resolveVoteTransition,
|
||||
applyVoteTransitionCounts,
|
||||
shouldSubmitVote,
|
||||
} from "./vote-transition";
|
||||
|
||||
describe("resolveVoteTransition", () => {
|
||||
it("keeps the state constants compatible with the existing extension state", () => {
|
||||
expect(LIKED_STATE).toBe("LIKED_STATE");
|
||||
expect(DISLIKED_STATE).toBe("DISLIKED_STATE");
|
||||
expect(NEUTRAL_STATE).toBe("NEUTRAL_STATE");
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
description: "likes a neutral video",
|
||||
previousState: NEUTRAL_STATE,
|
||||
action: "like",
|
||||
expected: { nextState: LIKED_STATE, value: 1, likesDelta: 1, dislikesDelta: 0 },
|
||||
},
|
||||
{
|
||||
description: "removes an existing like",
|
||||
previousState: LIKED_STATE,
|
||||
action: "like",
|
||||
expected: { nextState: NEUTRAL_STATE, value: 0, likesDelta: -1, dislikesDelta: 0 },
|
||||
},
|
||||
{
|
||||
description: "changes a dislike to a like",
|
||||
previousState: DISLIKED_STATE,
|
||||
action: "like",
|
||||
expected: { nextState: LIKED_STATE, value: 1, likesDelta: 1, dislikesDelta: -1 },
|
||||
},
|
||||
{
|
||||
description: "dislikes a neutral video",
|
||||
previousState: NEUTRAL_STATE,
|
||||
action: "dislike",
|
||||
expected: { nextState: DISLIKED_STATE, value: -1, likesDelta: 0, dislikesDelta: 1 },
|
||||
},
|
||||
{
|
||||
description: "removes an existing dislike",
|
||||
previousState: DISLIKED_STATE,
|
||||
action: "dislike",
|
||||
expected: { nextState: NEUTRAL_STATE, value: 0, likesDelta: 0, dislikesDelta: -1 },
|
||||
},
|
||||
{
|
||||
description: "changes a like to a dislike",
|
||||
previousState: LIKED_STATE,
|
||||
action: "dislike",
|
||||
expected: { nextState: DISLIKED_STATE, value: -1, likesDelta: -1, dislikesDelta: 1 },
|
||||
},
|
||||
])("$description", ({ previousState, action, expected }) => {
|
||||
expect(resolveVoteTransition(previousState, action)).toEqual(expected);
|
||||
});
|
||||
|
||||
it.each([undefined, null, "", "UNKNOWN_STATE", 1])("rejects invalid previous state %p", (previousState) => {
|
||||
expect(() => resolveVoteTransition(previousState, "like")).toThrow(TypeError);
|
||||
});
|
||||
|
||||
it.each([undefined, null, "", "LIKED_STATE", "upvote", 1])("rejects invalid action %p", (action) => {
|
||||
expect(() => resolveVoteTransition(NEUTRAL_STATE, action)).toThrow(TypeError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyVoteTransitionCounts", () => {
|
||||
it("applies a transition without mutating it", () => {
|
||||
const transition = resolveVoteTransition(NEUTRAL_STATE, "dislike");
|
||||
|
||||
expect(applyVoteTransitionCounts(10, 4, transition)).toEqual({ likes: 10, dislikes: 5 });
|
||||
expect(transition).toEqual({ nextState: DISLIKED_STATE, value: -1, likesDelta: 0, dislikesDelta: 1 });
|
||||
});
|
||||
|
||||
it.each([
|
||||
[LIKED_STATE, "like", 0, 5, { likes: 0, dislikes: 5 }],
|
||||
[DISLIKED_STATE, "dislike", 5, 0, { likes: 5, dislikes: 0 }],
|
||||
[LIKED_STATE, "dislike", 0, 0, { likes: 0, dislikes: 1 }],
|
||||
[DISLIKED_STATE, "like", 0, 0, { likes: 1, dislikes: 0 }],
|
||||
])("never produces negative counts for %s -> %s", (state, action, likes, dislikes, expected) => {
|
||||
expect(applyVoteTransitionCounts(likes, dislikes, resolveVoteTransition(state, action))).toEqual(expected);
|
||||
});
|
||||
|
||||
it("normalizes non-finite counts and rejects malformed transitions", () => {
|
||||
const transition = resolveVoteTransition(NEUTRAL_STATE, "like");
|
||||
expect(applyVoteTransitionCounts(Number.NaN, Infinity, transition)).toEqual({ likes: 1, dislikes: 0 });
|
||||
expect(() => applyVoteTransitionCounts(1, 1, null)).toThrow(TypeError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldSubmitVote", () => {
|
||||
it.each([
|
||||
[{}, true],
|
||||
[{ disableVoteSubmission: false, signedOut: false }, true],
|
||||
[{ disableVoteSubmission: true, signedOut: false }, false],
|
||||
[{ disableVoteSubmission: false, signedOut: true }, false],
|
||||
[{ disableVoteSubmission: true, signedOut: true }, false],
|
||||
])("gates submission for %p", (options, expected) => {
|
||||
expect(shouldSubmitVote(options)).toBe(expected);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
# Shared extension/userscript browser contract
|
||||
|
||||
`shared-live-scenarios.js` publishes the ordered behavioral scenario IDs used by both runtimes. The runtime adapters
|
||||
bind the selected runtime, version, and exact generated live-build ID into the existing live driver while retaining
|
||||
runtime-specific ratio-bar, tooltip, credential-store, transport, and Shorts-control capabilities.
|
||||
|
||||
`hermetic-artifact-smoke.js` makes three shared scenarios executable against both generated artifacts and one
|
||||
extension-specific stale-response race executable against the generated MV3 artifact:
|
||||
|
||||
- `watch-render` verifies the first rendered count and visible ratio bar.
|
||||
- `watch-spa-side-panel` clicks the related-video link from A to B using non-proportional 90/10 and 35/65 fixtures.
|
||||
It retains hidden copies of A's initialized controls both before and inside the new current root, replaces B's action
|
||||
container during settling, and then requires exactly one B-owned count/bar/tooltip with no RYD bars left in any
|
||||
retained tree. Readiness must remain valid for 300 ms, after which the exact invariant is sampled continuously for
|
||||
another second. The result includes first-valid latency and sample counts.
|
||||
- `watch-spa-dislike-activation` reuses that exact A-to-B replacement setup, clicks the one visible B-owned Dislike
|
||||
button once, and requires one ordered `/interact/vote` plus `/interact/confirmVote` chain for B with value `-1` and
|
||||
the same confirmed 36-character user ID. Confirmation must return HTTP 200 with the literal JSON value `true`, and
|
||||
the exact one-vote/one-confirmation invariant must remain stable for another second so a duplicated listener fails.
|
||||
The userscript chain is observed through its routed fake backend; the extension chain is observed at the loopback MV3
|
||||
background server. Both paths reject unexpected or escaped traffic and page errors, unhandled rejections, and console
|
||||
errors.
|
||||
- `extension-watch-spa-delayed-outgoing-failure` holds A's `/votes` response, navigates to B, releases A as malformed,
|
||||
and requires B to initialize exactly once without ever showing A's error or data. This proves that navigation queued
|
||||
during an in-flight initialization is not swallowed and that stale failures are as harmless as stale successes.
|
||||
|
||||
```powershell
|
||||
node Extensions/e2e/hermetic-artifact-smoke.js
|
||||
```
|
||||
|
||||
The userscript is injected into an isolated context with GM shims. The extension is loaded as a real unpacked MV3
|
||||
artifact in an isolated persistent Chromium profile, and the smoke requires its service worker to start.
|
||||
|
||||
## Extension API safety
|
||||
|
||||
The extension background eagerly registers as soon as its service worker starts. That can happen before Playwright can
|
||||
install a context route, so routing the production origin after `launchPersistentContext()` is not a safe hermetic
|
||||
strategy.
|
||||
|
||||
The adapter therefore starts a loopback fake API first, copies the current `dist/chrome` artifact into an owned OS
|
||||
temporary directory, replaces the exact production origin in the background bundle, and adds only the corresponding
|
||||
loopback host permission. It refuses non-loopback origins or a missing replacement point. The content-script bundle is
|
||||
left byte-for-byte intact: Chromium blocks HTTPS-page content scripts from reaching loopback under Private Network
|
||||
Access, so a BrowserContext route fulfills its normal API-origin requests instead. That route is installed before any
|
||||
YouTube page is created or navigated. The temporary background bundle also replaces the exact first-install changelog
|
||||
listener with a no-op so a fresh profile cannot open the changelog and request its remote font before the catch-all is
|
||||
ready. A missing exact listener fails preparation. The tracked build output is never modified. Both the derived
|
||||
artifact and its browser profile are removed after the run.
|
||||
|
||||
A future dedicated Webpack test build can replace this derivation with the following equivalent hook:
|
||||
|
||||
1. Define an API-base compile constant only when a hermetic-build flag is enabled.
|
||||
2. Reject a hermetic API base whose parsed hostname is not `127.0.0.1`, `::1`, or `localhost`.
|
||||
3. Use that constant from `Extensions/combined/src/config.js` for both background and content-script bundles.
|
||||
4. Add the loopback host match to the generated test manifest only; never to release manifests.
|
||||
5. Start the fake server before building so its allocated origin is compiled into the artifact, then launch the
|
||||
persistent context.
|
||||
|
||||
The artifact smoke is part of `npm run test:e2e:systematic` and `npm run test:all`. Its colocated Jest tests cover the
|
||||
shared catalog, runtime binding, capability validation, loopback guard, derived-artifact transformation, signal
|
||||
collection, and cleanup behavior. The browser run rejects stale/current ownership mistakes, missed initialization,
|
||||
duplicate activation, unexpected traffic, console errors, page errors, unhandled rejections, and a missing auxiliary
|
||||
extension script.
|
||||
|
||||
## Authenticated live-build identity
|
||||
|
||||
The authenticated Brave suite is intentionally separate from this hermetic runner. `build:live:userscript` and
|
||||
`build:live:extension` generate a fresh 32-character build ID, compile it into the page marker, and write the same ID to
|
||||
the runtime's `live-build.json`. The live suite reads that file and requires an exact marker match before every shared
|
||||
scenario. Rebuilding without reinstalling or reloading the runtime therefore fails even when the semantic version did
|
||||
not change. See `Extensions/UserScript/e2e/live/README.md` for the complete preparation and safety contract.
|
||||
@@ -0,0 +1,122 @@
|
||||
function defaultNow() {
|
||||
return Date.now();
|
||||
}
|
||||
|
||||
function defaultSleep(milliseconds) {
|
||||
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
||||
}
|
||||
|
||||
function validateTiming({ intervalMs, stableForMs, timeoutMs }) {
|
||||
for (const [name, value] of Object.entries({ intervalMs, stableForMs, timeoutMs })) {
|
||||
if (!Number.isFinite(value) || value < 0) {
|
||||
throw new TypeError(`${name} must be a non-negative finite number.`);
|
||||
}
|
||||
}
|
||||
if (intervalMs === 0) throw new TypeError("intervalMs must be greater than zero.");
|
||||
if (stableForMs > timeoutMs) throw new TypeError("stableForMs cannot exceed timeoutMs.");
|
||||
}
|
||||
|
||||
function compactSamples(samples, limit = 12) {
|
||||
const selected = samples.length <= limit ? samples : [...samples.slice(0, 3), ...samples.slice(-(limit - 3))];
|
||||
return selected.map(({ elapsedMs, ok, value }) => ({ elapsedMs, ok, value }));
|
||||
}
|
||||
|
||||
async function waitForStableInvariant({
|
||||
intervalMs = 50,
|
||||
isValid,
|
||||
label,
|
||||
now = defaultNow,
|
||||
read,
|
||||
sleep = defaultSleep,
|
||||
stableForMs = 500,
|
||||
timeoutMs = 5_000,
|
||||
}) {
|
||||
if (typeof read !== "function") throw new TypeError("read must be a function.");
|
||||
if (typeof isValid !== "function") throw new TypeError("isValid must be a function.");
|
||||
if (typeof now !== "function") throw new TypeError("now must be a function.");
|
||||
if (typeof sleep !== "function") throw new TypeError("sleep must be a function.");
|
||||
if (typeof label !== "string" || label.trim() === "") throw new TypeError("label must be a non-empty string.");
|
||||
validateTiming({ intervalMs, stableForMs, timeoutMs });
|
||||
|
||||
const startedAt = now();
|
||||
let firstValidMs = null;
|
||||
let stableSince = null;
|
||||
let invalidSamples = 0;
|
||||
const samples = [];
|
||||
|
||||
while (true) {
|
||||
const value = await read();
|
||||
const sampledAt = now();
|
||||
const elapsedMs = sampledAt - startedAt;
|
||||
const ok = Boolean(await isValid(value));
|
||||
samples.push({ elapsedMs, ok, value });
|
||||
|
||||
if (ok) {
|
||||
if (firstValidMs === null) firstValidMs = elapsedMs;
|
||||
if (stableSince === null) stableSince = sampledAt;
|
||||
if (sampledAt - stableSince >= stableForMs) {
|
||||
return {
|
||||
elapsedMs,
|
||||
firstValidMs,
|
||||
invalidSamples,
|
||||
sampleCount: samples.length,
|
||||
stableForMs: sampledAt - stableSince,
|
||||
value,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
invalidSamples += 1;
|
||||
stableSince = null;
|
||||
}
|
||||
|
||||
if (elapsedMs >= timeoutMs) {
|
||||
const error = new Error(
|
||||
`${label} did not remain valid for ${stableForMs}ms within ${timeoutMs}ms. ` +
|
||||
`Samples: ${JSON.stringify(compactSamples(samples))}`,
|
||||
);
|
||||
error.samples = samples;
|
||||
throw error;
|
||||
}
|
||||
await sleep(Math.min(intervalMs, timeoutMs - elapsedMs));
|
||||
}
|
||||
}
|
||||
|
||||
async function assertInvariantContinuously({
|
||||
durationMs = 1_000,
|
||||
intervalMs = 50,
|
||||
isValid,
|
||||
label,
|
||||
now = defaultNow,
|
||||
read,
|
||||
sleep = defaultSleep,
|
||||
}) {
|
||||
if (typeof read !== "function") throw new TypeError("read must be a function.");
|
||||
if (typeof isValid !== "function") throw new TypeError("isValid must be a function.");
|
||||
if (typeof label !== "string" || label.trim() === "") throw new TypeError("label must be a non-empty string.");
|
||||
validateTiming({ intervalMs, stableForMs: durationMs, timeoutMs: durationMs });
|
||||
|
||||
const startedAt = now();
|
||||
const samples = [];
|
||||
while (true) {
|
||||
const value = await read();
|
||||
const elapsedMs = now() - startedAt;
|
||||
const ok = Boolean(await isValid(value));
|
||||
samples.push({ elapsedMs, ok, value });
|
||||
if (!ok) {
|
||||
const error = new Error(
|
||||
`${label} became invalid after ${elapsedMs}ms. Samples: ${JSON.stringify(compactSamples(samples))}`,
|
||||
);
|
||||
error.samples = samples;
|
||||
throw error;
|
||||
}
|
||||
if (elapsedMs >= durationMs) {
|
||||
return { elapsedMs, sampleCount: samples.length, value };
|
||||
}
|
||||
await sleep(Math.min(intervalMs, durationMs - elapsedMs));
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
assertInvariantContinuously,
|
||||
waitForStableInvariant,
|
||||
};
|
||||
@@ -0,0 +1,92 @@
|
||||
const { assertInvariantContinuously, waitForStableInvariant } = require("./continuous-invariants");
|
||||
|
||||
function createClock() {
|
||||
let time = 0;
|
||||
return {
|
||||
now: () => time,
|
||||
sleep: async (milliseconds) => {
|
||||
time += milliseconds;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("continuous browser invariants", () => {
|
||||
test("reports readiness latency and requires an uninterrupted stable window", async () => {
|
||||
const clock = createClock();
|
||||
const values = [false, false, true, true, false, true, true, true];
|
||||
let index = 0;
|
||||
|
||||
const result = await waitForStableInvariant({
|
||||
intervalMs: 100,
|
||||
isValid: Boolean,
|
||||
label: "ratio bar",
|
||||
now: clock.now,
|
||||
read: async () => values[Math.min(index++, values.length - 1)],
|
||||
sleep: clock.sleep,
|
||||
stableForMs: 200,
|
||||
timeoutMs: 1_000,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
elapsedMs: 700,
|
||||
firstValidMs: 200,
|
||||
invalidSamples: 3,
|
||||
sampleCount: 8,
|
||||
stableForMs: 200,
|
||||
value: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("includes sampled phase evidence when stability never arrives", async () => {
|
||||
const clock = createClock();
|
||||
let phase = "missing";
|
||||
|
||||
await expect(
|
||||
waitForStableInvariant({
|
||||
intervalMs: 100,
|
||||
isValid: (sample) => sample.phase === "ready",
|
||||
label: "destination ownership",
|
||||
now: clock.now,
|
||||
read: async () => ({ phase: (phase = phase === "missing" ? "ready" : "missing") }),
|
||||
sleep: clock.sleep,
|
||||
stableForMs: 200,
|
||||
timeoutMs: 400,
|
||||
}),
|
||||
).rejects.toThrow(/destination ownership.*Samples:.*phase/);
|
||||
});
|
||||
|
||||
test("continuous assertion rejects an invalid first sample", async () => {
|
||||
const clock = createClock();
|
||||
let calls = 0;
|
||||
|
||||
await expect(
|
||||
assertInvariantContinuously({
|
||||
durationMs: 200,
|
||||
intervalMs: 100,
|
||||
isValid: Boolean,
|
||||
label: "settled watch UI",
|
||||
now: clock.now,
|
||||
read: async () => calls++ > 0,
|
||||
sleep: clock.sleep,
|
||||
}),
|
||||
).rejects.toThrow(/settled watch UI became invalid after 0ms/);
|
||||
});
|
||||
|
||||
test.each([
|
||||
["negative timeout", { timeoutMs: -1 }],
|
||||
["zero interval", { intervalMs: 0 }],
|
||||
["stable duration beyond timeout", { stableForMs: 101, timeoutMs: 100 }],
|
||||
])("rejects %s", async (_name, timing) => {
|
||||
await expect(
|
||||
waitForStableInvariant({
|
||||
intervalMs: 10,
|
||||
isValid: Boolean,
|
||||
label: "invalid timing",
|
||||
read: async () => true,
|
||||
stableForMs: 10,
|
||||
timeoutMs: 100,
|
||||
...timing,
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,544 @@
|
||||
const fs = require("node:fs");
|
||||
const { EventEmitter } = require("node:events");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const {
|
||||
ARTIFACT_SMOKE_SCENARIO_ID,
|
||||
ARTIFACT_WATCH_SPA_SCENARIO_ID,
|
||||
ARTIFACT_WATCH_SPA_VOTE_SCENARIO_ID,
|
||||
SHARED_ARTIFACT_SCENARIO_IDS,
|
||||
assertLoopbackOrigin,
|
||||
createPageSignalCollector,
|
||||
isArtifactVoteHandshakeValid,
|
||||
isSpaDestinationValid,
|
||||
prepareHermeticExtensionArtifact,
|
||||
readArtifactVoteHandshake,
|
||||
runArtifactWatchRenderScenario,
|
||||
runArtifactWatchSpaScenario,
|
||||
runArtifactWatchSpaVoteScenario,
|
||||
} = require("./hermetic-artifact-smoke");
|
||||
|
||||
const PRODUCTION_API_ORIGIN = "https://returnyoutubedislikeapi.com";
|
||||
const temporaryDirectories = [];
|
||||
|
||||
function createExtensionFixture() {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "ryd-mv3-source-fixture-"));
|
||||
temporaryDirectories.push(directory);
|
||||
fs.writeFileSync(
|
||||
path.join(directory, "manifest.json"),
|
||||
JSON.stringify({ host_permissions: ["*://returnyoutubedislikeapi.com/*"], manifest_version: 3 }),
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(directory, "ryd.background.js"),
|
||||
`fetch("${PRODUCTION_API_ORIGIN}/register")\napi.runtime.onInstalled.addListener((details) => {\n maybeShowChangelog(details);\n});`,
|
||||
);
|
||||
fs.writeFileSync(path.join(directory, "ryd.content-script.js"), `fetch("${PRODUCTION_API_ORIGIN}/votes")`);
|
||||
fs.writeFileSync(path.join(directory, "menu-fixer.js"), "document.documentElement.dataset.menuFixerLoaded = 'true';");
|
||||
return directory;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const directory of temporaryDirectories.splice(0)) {
|
||||
fs.rmSync(directory, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("the artifact smoke is the shared watch-render scenario", () => {
|
||||
expect(ARTIFACT_SMOKE_SCENARIO_ID).toBe("watch-render");
|
||||
expect(ARTIFACT_WATCH_SPA_SCENARIO_ID).toBe("watch-spa-side-panel");
|
||||
expect(ARTIFACT_WATCH_SPA_VOTE_SCENARIO_ID).toBe("watch-spa-dislike-activation");
|
||||
expect(SHARED_ARTIFACT_SCENARIO_IDS).toEqual([
|
||||
"watch-render",
|
||||
"watch-spa-side-panel",
|
||||
"watch-spa-dislike-activation",
|
||||
]);
|
||||
});
|
||||
|
||||
function validSpaSnapshot() {
|
||||
const outgoing = {
|
||||
barCount: 0,
|
||||
containerCount: 0,
|
||||
controlVideoIds: ["abcdefghijk"],
|
||||
hidden: true,
|
||||
present: true,
|
||||
wrapperCount: 0,
|
||||
};
|
||||
return {
|
||||
actionHostCount: 1,
|
||||
barOwnedByDestination: true,
|
||||
containerOwnedByDestination: true,
|
||||
count: "65",
|
||||
currentVideoId: "zyxwvutsrqp",
|
||||
destinationBarCount: 1,
|
||||
destinationContainerCount: 1,
|
||||
destinationControlCount: 1,
|
||||
destinationWrapperCount: 1,
|
||||
fillRatio: 0.35,
|
||||
globalBarCount: 1,
|
||||
globalContainerCount: 1,
|
||||
globalWrapperCount: 1,
|
||||
insideOutgoing: { ...outgoing },
|
||||
retainedBefore: { ...outgoing },
|
||||
retainedDestination: { ...outgoing, controlVideoIds: ["zyxwvutsrqp"] },
|
||||
tooltipText: "35 / 65",
|
||||
urlVideoId: "zyxwvutsrqp",
|
||||
visibleContainer: true,
|
||||
visibleFill: true,
|
||||
};
|
||||
}
|
||||
|
||||
const ARTIFACT_USER_ID = "A".repeat(36);
|
||||
|
||||
function validVoteHandshake(change = {}) {
|
||||
return {
|
||||
confirmation: {
|
||||
body: { solution: "AAAAAA==", userId: ARTIFACT_USER_ID, videoId: "zyxwvutsrqp" },
|
||||
responded: true,
|
||||
responseBody: true,
|
||||
responseStatus: 200,
|
||||
},
|
||||
confirmationCount: 1,
|
||||
expectedValue: -1,
|
||||
expectedVideoId: "zyxwvutsrqp",
|
||||
interactionCount: 2,
|
||||
interactionPaths: ["/interact/vote", "/interact/confirmVote"],
|
||||
sharedUserId: ARTIFACT_USER_ID,
|
||||
vote: {
|
||||
body: { userId: ARTIFACT_USER_ID, value: -1, videoId: "zyxwvutsrqp" },
|
||||
},
|
||||
voteCount: 1,
|
||||
...change,
|
||||
};
|
||||
}
|
||||
|
||||
test.each(["http://127.0.0.1:43127", "http://localhost:43127", "http://[::1]:43127"])(
|
||||
"accepts a loopback-only API origin: %s",
|
||||
(origin) => {
|
||||
expect(assertLoopbackOrigin(origin)).toBe(origin);
|
||||
},
|
||||
);
|
||||
|
||||
test.each(["https://returnyoutubedislikeapi.com", "https://api.example.test", "http://192.168.1.20:43127"])(
|
||||
"rejects a non-loopback API origin: %s",
|
||||
(origin) => {
|
||||
expect(() => assertLoopbackOrigin(origin)).toThrow("Refusing to prepare a hermetic extension artifact");
|
||||
},
|
||||
);
|
||||
|
||||
test("redirects eager MV3 background traffic while leaving routed content-script traffic intact", () => {
|
||||
const sourceDirectory = createExtensionFixture();
|
||||
const prepared = prepareHermeticExtensionArtifact(sourceDirectory, "http://127.0.0.1:43127");
|
||||
temporaryDirectories.push(prepared.temporaryRoot);
|
||||
|
||||
expect(prepared.replacements).toEqual({ "ryd.background.js": 1, firstInstallChangelogListener: 1 });
|
||||
expect(prepared.routedBundles).toEqual(["ryd.content-script.js"]);
|
||||
expect(fs.readFileSync(path.join(prepared.extensionDirectory, "ryd.background.js"), "utf8")).toContain(
|
||||
"http://127.0.0.1:43127/register",
|
||||
);
|
||||
expect(fs.readFileSync(path.join(prepared.extensionDirectory, "ryd.background.js"), "utf8")).toContain(
|
||||
"api.runtime.onInstalled.addListener(() => {});",
|
||||
);
|
||||
expect(fs.readFileSync(path.join(prepared.extensionDirectory, "ryd.content-script.js"), "utf8")).toContain(
|
||||
`${PRODUCTION_API_ORIGIN}/votes`,
|
||||
);
|
||||
expect(fs.readFileSync(path.join(prepared.extensionDirectory, "menu-fixer.js"), "utf8")).toContain("menuFixerLoaded");
|
||||
expect(JSON.parse(fs.readFileSync(path.join(prepared.extensionDirectory, "manifest.json"), "utf8"))).toMatchObject({
|
||||
host_permissions: expect.arrayContaining(["http://127.0.0.1/*"]),
|
||||
manifest_version: 3,
|
||||
});
|
||||
expect(fs.readFileSync(path.join(sourceDirectory, "ryd.background.js"), "utf8")).toContain(PRODUCTION_API_ORIGIN);
|
||||
});
|
||||
|
||||
test("rejects an extension artifact whose injected auxiliary script was dropped by the build", () => {
|
||||
const sourceDirectory = createExtensionFixture();
|
||||
fs.rmSync(path.join(sourceDirectory, "menu-fixer.js"));
|
||||
|
||||
expect(() => prepareHermeticExtensionArtifact(sourceDirectory, "http://127.0.0.1:43127")).toThrow(
|
||||
/missing menu-fixer\.js/,
|
||||
);
|
||||
});
|
||||
|
||||
test.each(["userscript", "extension"])("runs one shared artifact scenario contract for %s", async (runtime) => {
|
||||
const events = [];
|
||||
const adapter = {
|
||||
assertNoPageSignals: jest.fn(async (scenarioId) => events.push(`signals:${scenarioId}`)),
|
||||
close: jest.fn(async () => events.push("close")),
|
||||
openWatch: jest.fn(async (videoId) => events.push(`open:${videoId}`)),
|
||||
runtime,
|
||||
start: jest.fn(async () => events.push("start")),
|
||||
waitForWatchResult: jest.fn(async (videoId) => ({
|
||||
count: "25",
|
||||
fillVisible: true,
|
||||
rateBarVisible: true,
|
||||
videoId,
|
||||
})),
|
||||
};
|
||||
|
||||
await expect(runArtifactWatchRenderScenario(adapter, { videoId: "abcdefghijk" })).resolves.toMatchObject({
|
||||
count: "25",
|
||||
runtime,
|
||||
scenarioId: "watch-render",
|
||||
videoId: "abcdefghijk",
|
||||
});
|
||||
expect(events).toEqual(["start", "open:abcdefghijk", "signals:watch-render", "close"]);
|
||||
});
|
||||
|
||||
test.each(["userscript", "extension"])("runs the same continuous A-to-B SPA contract for %s", async (runtime) => {
|
||||
const events = [];
|
||||
const adapter = {
|
||||
assertNoPageSignals: jest.fn(async (scenarioId) => events.push(`signals:${scenarioId}`)),
|
||||
assertSpaNetwork: jest.fn(async () => ({ fromVideoRequests: 1, interactionRequests: 0, toVideoRequests: 1 })),
|
||||
close: jest.fn(async () => events.push("close")),
|
||||
navigateSpaWatch: jest.fn(async () => ({
|
||||
destination: { destinationReplaced: true },
|
||||
outgoing: { beforeBarCount: 1 },
|
||||
})),
|
||||
openSpaWatch: jest.fn(async (videoId) => events.push(`open:${videoId}`)),
|
||||
readSpaWatchSnapshot: jest.fn(async () => validSpaSnapshot()),
|
||||
runtime,
|
||||
start: jest.fn(async () => events.push("start")),
|
||||
waitForWatchResult: jest.fn(async (videoId) => ({ count: "10", fillRatio: 0.9, videoId })),
|
||||
};
|
||||
|
||||
await expect(
|
||||
runArtifactWatchSpaScenario(adapter, {
|
||||
intervalMs: 1,
|
||||
stabilityDurationMs: 0,
|
||||
stableForMs: 0,
|
||||
timeoutMs: 1,
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
destination: { count: "65", fillRatio: 0.35, videoId: "zyxwvutsrqp" },
|
||||
initial: { count: "10", fillRatio: 0.9, videoId: "abcdefghijk" },
|
||||
readiness: { maxFirstValidMs: 1_000 },
|
||||
runtime,
|
||||
scenarioId: "watch-spa-side-panel",
|
||||
traffic: { fromVideoRequests: 1, interactionRequests: 0, toVideoRequests: 1 },
|
||||
});
|
||||
expect(adapter.navigateSpaWatch).toHaveBeenCalledWith("abcdefghijk", "zyxwvutsrqp");
|
||||
expect(events).toEqual(["start", "open:abcdefghijk", "signals:watch-spa-side-panel", "close"]);
|
||||
});
|
||||
|
||||
test("recognizes only one ordered, successful destination dislike handshake", () => {
|
||||
const records = [
|
||||
{ method: "POST", pathname: "/puzzle/registration" },
|
||||
{
|
||||
body: { userId: ARTIFACT_USER_ID, value: -1, videoId: "zyxwvutsrqp" },
|
||||
method: "POST",
|
||||
pathname: "/interact/vote",
|
||||
},
|
||||
{
|
||||
body: { solution: "AAAAAA==", userId: ARTIFACT_USER_ID, videoId: "zyxwvutsrqp" },
|
||||
method: "POST",
|
||||
pathname: "/interact/confirmVote",
|
||||
respondedAt: 123,
|
||||
responseBody: true,
|
||||
responseStatus: 200,
|
||||
},
|
||||
];
|
||||
|
||||
const handshake = readArtifactVoteHandshake(records, 1, "zyxwvutsrqp", -1);
|
||||
expect(handshake).toEqual(validVoteHandshake());
|
||||
expect(isArtifactVoteHandshakeValid(handshake)).toBe(true);
|
||||
});
|
||||
|
||||
test.each([
|
||||
["duplicate listener requests", (value) => ({ ...value, interactionCount: 4, voteCount: 2, confirmationCount: 2 })],
|
||||
["reversed request order", (value) => ({ ...value, interactionPaths: ["/interact/confirmVote", "/interact/vote"] })],
|
||||
[
|
||||
"the wrong destination video",
|
||||
(value) => ({ ...value, vote: { body: { ...value.vote.body, videoId: "abcdefghijk" } } }),
|
||||
],
|
||||
["the wrong vote value", (value) => ({ ...value, vote: { body: { ...value.vote.body, value: 1 } } })],
|
||||
[
|
||||
"different vote and confirmation identities",
|
||||
(value) => ({
|
||||
...value,
|
||||
confirmation: {
|
||||
...value.confirmation,
|
||||
body: { ...value.confirmation.body, userId: "B".repeat(36) },
|
||||
},
|
||||
}),
|
||||
],
|
||||
["a malformed identity", (value) => ({ ...value, sharedUserId: "short-user" })],
|
||||
["a false confirmation", (value) => ({ ...value, confirmation: { ...value.confirmation, responseBody: false } })],
|
||||
[
|
||||
"a failed confirmation status",
|
||||
(value) => ({ ...value, confirmation: { ...value.confirmation, responseStatus: 500 } }),
|
||||
],
|
||||
[
|
||||
"a malformed proof solution",
|
||||
(value) => ({
|
||||
...value,
|
||||
confirmation: { ...value.confirmation, body: { ...value.confirmation.body, solution: "AA==" } },
|
||||
}),
|
||||
],
|
||||
["an extra vote field", (value) => ({ ...value, vote: { body: { ...value.vote.body, duplicate: true } } })],
|
||||
])("rejects a post-SPA vote handshake with %s", (_label, mutate) => {
|
||||
expect(isArtifactVoteHandshakeValid(mutate(validVoteHandshake()))).toBe(false);
|
||||
});
|
||||
|
||||
test.each(["userscript", "extension"])(
|
||||
"runs one post-SPA dislike activation and confirmation contract for %s",
|
||||
async (runtime) => {
|
||||
const events = [];
|
||||
const adapter = {
|
||||
activateSpaDislike: jest.fn(async (videoId) => ({
|
||||
ariaPressedBefore: "false",
|
||||
interactionStartIndex: 7,
|
||||
videoId,
|
||||
})),
|
||||
assertNoPageSignals: jest.fn(async (scenarioId) => events.push(`signals:${scenarioId}`)),
|
||||
assertSpaVoteNetwork: jest.fn(async () => ({ fromVideoRequests: 1, toVideoRequests: 1 })),
|
||||
close: jest.fn(async () => events.push("close")),
|
||||
navigateSpaWatch: jest.fn(async () => ({
|
||||
destination: { destinationReplaced: true },
|
||||
outgoing: { beforeBarCount: 1 },
|
||||
})),
|
||||
openSpaWatch: jest.fn(async (videoId) => events.push(`open:${videoId}`)),
|
||||
readSpaVoteHandshake: jest.fn(async () => validVoteHandshake()),
|
||||
readSpaWatchSnapshot: jest.fn(async () => validSpaSnapshot()),
|
||||
runtime,
|
||||
start: jest.fn(async () => events.push("start")),
|
||||
waitForWatchResult: jest.fn(async (videoId) => ({ count: "10", fillRatio: 0.9, videoId })),
|
||||
};
|
||||
|
||||
await expect(
|
||||
runArtifactWatchSpaVoteScenario(adapter, {
|
||||
handshakeStableForMs: 0,
|
||||
handshakeTimeoutMs: 1,
|
||||
intervalMs: 1,
|
||||
stabilityDurationMs: 0,
|
||||
stableForMs: 0,
|
||||
timeoutMs: 1,
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
activation: { ariaPressedBefore: "false", videoId: "zyxwvutsrqp" },
|
||||
destination: { count: "65", fillRatio: 0.35, videoId: "zyxwvutsrqp" },
|
||||
handshake: {
|
||||
confirmationRequests: 1,
|
||||
confirmationStatus: 200,
|
||||
confirmed: true,
|
||||
interactionRequests: 2,
|
||||
userId: ARTIFACT_USER_ID,
|
||||
value: -1,
|
||||
videoId: "zyxwvutsrqp",
|
||||
voteRequests: 1,
|
||||
},
|
||||
runtime,
|
||||
scenarioId: "watch-spa-dislike-activation",
|
||||
traffic: { confirmationRequests: 1, interactionRequests: 2, voteRequests: 1 },
|
||||
});
|
||||
expect(adapter.activateSpaDislike).toHaveBeenCalledTimes(1);
|
||||
expect(adapter.activateSpaDislike).toHaveBeenCalledWith("zyxwvutsrqp");
|
||||
expect(adapter.readSpaVoteHandshake).toHaveBeenCalledWith(7, "zyxwvutsrqp", -1);
|
||||
expect(adapter.assertSpaVoteNetwork).toHaveBeenCalledWith("abcdefghijk", "zyxwvutsrqp", 7);
|
||||
expect(events).toEqual(["start", "open:abcdefghijk", "signals:watch-spa-dislike-activation", "close"]);
|
||||
},
|
||||
);
|
||||
|
||||
test("rejects a duplicated post-SPA vote chain and still closes the adapter", async () => {
|
||||
const duplicatedHandshake = validVoteHandshake({
|
||||
confirmationCount: 2,
|
||||
interactionCount: 4,
|
||||
interactionPaths: ["/interact/vote", "/interact/confirmVote", "/interact/vote", "/interact/confirmVote"],
|
||||
voteCount: 2,
|
||||
});
|
||||
const adapter = {
|
||||
activateSpaDislike: jest.fn(async (videoId) => ({ interactionStartIndex: 0, videoId })),
|
||||
assertNoPageSignals: jest.fn(),
|
||||
assertSpaVoteNetwork: jest.fn(),
|
||||
close: jest.fn(),
|
||||
navigateSpaWatch: jest.fn(async () => ({
|
||||
destination: { destinationReplaced: true },
|
||||
outgoing: { beforeBarCount: 1 },
|
||||
})),
|
||||
openSpaWatch: jest.fn(),
|
||||
readSpaVoteHandshake: jest.fn(async () => duplicatedHandshake),
|
||||
readSpaWatchSnapshot: jest.fn(async () => validSpaSnapshot()),
|
||||
runtime: "userscript",
|
||||
start: jest.fn(),
|
||||
waitForWatchResult: jest.fn(async (videoId) => ({ count: "10", fillRatio: 0.9, videoId })),
|
||||
};
|
||||
|
||||
await expect(
|
||||
runArtifactWatchSpaVoteScenario(adapter, {
|
||||
handshakeStableForMs: 0,
|
||||
handshakeTimeoutMs: 1,
|
||||
intervalMs: 1,
|
||||
stabilityDurationMs: 0,
|
||||
stableForMs: 0,
|
||||
timeoutMs: 1,
|
||||
}),
|
||||
).rejects.toThrow("post-SPA dislike handshake did not remain valid");
|
||||
expect(adapter.assertSpaVoteNetwork).not.toHaveBeenCalled();
|
||||
expect(adapter.assertNoPageSignals).not.toHaveBeenCalled();
|
||||
expect(adapter.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("rejects a destination that becomes correct after the explicit latency budget", async () => {
|
||||
const adapter = {
|
||||
assertNoPageSignals: jest.fn(),
|
||||
assertSpaNetwork: jest.fn(),
|
||||
close: jest.fn(),
|
||||
navigateSpaWatch: jest.fn(async () => ({
|
||||
destination: { destinationReplaced: true },
|
||||
outgoing: { beforeBarCount: 1 },
|
||||
})),
|
||||
openSpaWatch: jest.fn(),
|
||||
readSpaWatchSnapshot: jest.fn(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
return validSpaSnapshot();
|
||||
}),
|
||||
runtime: "userscript",
|
||||
start: jest.fn(),
|
||||
waitForWatchResult: jest.fn(async (videoId) => ({ count: "10", fillRatio: 0.9, videoId })),
|
||||
};
|
||||
|
||||
await expect(
|
||||
runArtifactWatchSpaScenario(adapter, {
|
||||
intervalMs: 1,
|
||||
maxFirstValidMs: 1,
|
||||
stabilityDurationMs: 0,
|
||||
stableForMs: 0,
|
||||
timeoutMs: 20,
|
||||
}),
|
||||
).rejects.toThrow(/first became valid after .*the budget is 1ms/);
|
||||
expect(adapter.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test.each([
|
||||
["duplicate global bar", { globalBarCount: 2 }],
|
||||
["stale outgoing bar", { insideOutgoing: { ...validSpaSnapshot().insideOutgoing, barCount: 1 } }],
|
||||
["wrong destination ratio", { fillRatio: 0.9 }],
|
||||
["wrong destination count", { count: "10" }],
|
||||
])("rejects a settled SPA snapshot with %s", (_label, change) => {
|
||||
expect(
|
||||
isSpaDestinationValid(
|
||||
{ ...validSpaSnapshot(), ...change },
|
||||
{
|
||||
expectedCount: 65,
|
||||
expectedRatio: 0.35,
|
||||
fromVideoId: "abcdefghijk",
|
||||
toVideoId: "zyxwvutsrqp",
|
||||
},
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("always closes an artifact adapter after a failed visual assertion", async () => {
|
||||
const adapter = {
|
||||
assertNoPageSignals: jest.fn(),
|
||||
close: jest.fn(),
|
||||
openWatch: jest.fn(),
|
||||
runtime: "extension",
|
||||
start: jest.fn(),
|
||||
waitForWatchResult: jest.fn(async () => ({
|
||||
count: "25",
|
||||
fillVisible: true,
|
||||
rateBarVisible: false,
|
||||
videoId: "abcdefghijk",
|
||||
})),
|
||||
};
|
||||
|
||||
await expect(runArtifactWatchRenderScenario(adapter, { videoId: "abcdefghijk" })).rejects.toThrow(
|
||||
"extension did not render a visible watch ratio bar",
|
||||
);
|
||||
expect(adapter.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("turns an otherwise successful artifact result into a failure when the page emitted an error", async () => {
|
||||
const adapter = {
|
||||
assertNoPageSignals: jest.fn(async () => {
|
||||
throw new Error("unexpected browser signals");
|
||||
}),
|
||||
close: jest.fn(),
|
||||
openWatch: jest.fn(),
|
||||
runtime: "userscript",
|
||||
start: jest.fn(),
|
||||
waitForWatchResult: jest.fn(async (videoId) => ({
|
||||
count: "25",
|
||||
fillVisible: true,
|
||||
rateBarVisible: true,
|
||||
videoId,
|
||||
})),
|
||||
};
|
||||
|
||||
await expect(runArtifactWatchRenderScenario(adapter, { videoId: "abcdefghijk" })).rejects.toThrow(
|
||||
"unexpected browser signals",
|
||||
);
|
||||
expect(adapter.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
function createPageDouble() {
|
||||
const page = new EventEmitter();
|
||||
page.addInitScript = jest.fn(async () => {});
|
||||
page.evaluate = jest.fn(async (callback) => callback());
|
||||
page.exposeBinding = jest.fn(async (name, callback) => {
|
||||
page.exposedBinding = { callback, name };
|
||||
});
|
||||
return page;
|
||||
}
|
||||
|
||||
function consoleMessage(
|
||||
type,
|
||||
text,
|
||||
location = { columnNumber: 5, lineNumber: 4, url: "https://www.youtube.com/watch" },
|
||||
) {
|
||||
return {
|
||||
location: () => location,
|
||||
text: () => text,
|
||||
type: () => type,
|
||||
};
|
||||
}
|
||||
|
||||
test.each(["userscript", "extension"])(
|
||||
"collects clean page signals through one shared %s collector",
|
||||
async (runtime) => {
|
||||
const page = createPageDouble();
|
||||
const collector = await createPageSignalCollector(page, runtime);
|
||||
|
||||
page.emit("console", consoleMessage("warning", "harmless warning"));
|
||||
|
||||
await expect(collector.assertClean("watch-render")).resolves.toEqual({
|
||||
consoleErrors: [],
|
||||
pageErrors: [],
|
||||
runtime,
|
||||
unhandledRejections: [],
|
||||
});
|
||||
expect(page.exposeBinding).toHaveBeenCalledWith("__rydArtifactReportUnhandledRejection", expect.any(Function));
|
||||
expect(page.addInitScript).toHaveBeenCalledTimes(1);
|
||||
},
|
||||
);
|
||||
|
||||
test.each([
|
||||
["console error", (page) => page.emit("console", consoleMessage("error", "fixture exploded")), "fixture exploded"],
|
||||
[
|
||||
"failed browser resource load",
|
||||
(page) => page.emit("console", consoleMessage("error", "Failed to load resource: net::ERR_FILE_NOT_FOUND")),
|
||||
"ERR_FILE_NOT_FOUND",
|
||||
],
|
||||
[
|
||||
"failed console assertion",
|
||||
(page) => page.emit("console", consoleMessage("assert", "bad assertion")),
|
||||
"bad assertion",
|
||||
],
|
||||
["page error", (page) => page.emit("pageerror", new TypeError("page exploded")), "page exploded"],
|
||||
[
|
||||
"unhandled rejection",
|
||||
(page) =>
|
||||
page.exposedBinding.callback(
|
||||
{ frame: { url: () => "https://www.youtube.com/watch?v=abcdefghijk" } },
|
||||
{ message: "promise exploded", name: "Error", stack: "Error: promise exploded" },
|
||||
),
|
||||
"promise exploded",
|
||||
],
|
||||
])("fails a successful scenario on %s with diagnostics", async (_label, emitSignal, expectedDiagnostic) => {
|
||||
const page = createPageDouble();
|
||||
const collector = await createPageSignalCollector(page, "userscript");
|
||||
emitSignal(page);
|
||||
|
||||
await expect(collector.assertClean("watch-render")).rejects.toThrow(
|
||||
new RegExp(`userscript emitted unexpected browser signals.*${expectedDiagnostic}`, "s"),
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
const assert = require("node:assert/strict");
|
||||
|
||||
function deepFreeze(value) {
|
||||
if (!value || typeof value !== "object" || Object.isFrozen(value)) return value;
|
||||
Object.values(value).forEach(deepFreeze);
|
||||
return Object.freeze(value);
|
||||
}
|
||||
|
||||
const LIVE_RUNTIME_PROFILES = deepFreeze({
|
||||
extension: {
|
||||
capabilities: {
|
||||
backgroundVoteTransport: true,
|
||||
credentialStore: "browser.storage",
|
||||
ownsShortsDislikeControl: false,
|
||||
shortsControlModel: "native-youtube",
|
||||
shortsVisualModel: "native-pair",
|
||||
},
|
||||
buildMarkerAttribute: "data-ryd-extension-build",
|
||||
markerAttribute: "data-ryd-extension-version",
|
||||
runtime: "extension",
|
||||
selectors: {
|
||||
rateBar: "#ryd-bar",
|
||||
rateBarContainer: "#ryd-bar-container",
|
||||
shortsDislikeControl: null,
|
||||
tooltipContent: "#ryd-dislike-tooltip",
|
||||
tooltipTrigger: ".ryd-tooltip",
|
||||
},
|
||||
},
|
||||
userscript: {
|
||||
capabilities: {
|
||||
backgroundVoteTransport: false,
|
||||
credentialStore: "gm-storage",
|
||||
ownsShortsDislikeControl: true,
|
||||
shortsControlModel: "synthetic-owned",
|
||||
shortsVisualModel: "strict-synthetic",
|
||||
},
|
||||
buildMarkerAttribute: "data-ryd-userscript-build",
|
||||
markerAttribute: "data-ryd-userscript-version",
|
||||
runtime: "userscript",
|
||||
selectors: {
|
||||
rateBar: "#return-youtube-dislike-bar",
|
||||
rateBarContainer: "#return-youtube-dislike-bar-container",
|
||||
shortsDislikeControl: "[data-ryd-synthetic-shorts-dislike]",
|
||||
tooltipContent: ".ryd-tooltip-label",
|
||||
tooltipTrigger: ".ryd-tooltip",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
function requireDriverMethod(driver, method, scenarioId = null) {
|
||||
if (typeof driver?.[method] === "function") return;
|
||||
const scenario = scenarioId ? ` for shared scenario ${scenarioId}` : "";
|
||||
throw new TypeError(`The ${driver ? "live driver" : "missing driver"}${scenario} must implement ${method}().`);
|
||||
}
|
||||
|
||||
function assertRuntimeArgument(value, expected, label) {
|
||||
if (value === undefined) return;
|
||||
assert.equal(value, expected, `${label} does not match the selected live runtime adapter.`);
|
||||
}
|
||||
|
||||
function createRuntimeBoundDriver(driver, runtime, expectedVersion, expectedBuildId) {
|
||||
return new Proxy(driver, {
|
||||
get(target, property, receiver) {
|
||||
if (property === "assertRuntime") {
|
||||
return (requestedRuntime, requestedVersion, requestedBuildId) => {
|
||||
assertRuntimeArgument(requestedRuntime, runtime, "The asserted runtime");
|
||||
assertRuntimeArgument(requestedVersion, expectedVersion, "The asserted runtime version");
|
||||
assertRuntimeArgument(requestedBuildId, expectedBuildId, "The asserted live build ID");
|
||||
requireDriverMethod(target, "assertRuntime");
|
||||
return target.assertRuntime(runtime, expectedVersion, expectedBuildId);
|
||||
};
|
||||
}
|
||||
|
||||
if (property === "assertCurrentShortsControl") {
|
||||
return (videoId, requestedRuntime) => {
|
||||
assertRuntimeArgument(requestedRuntime, runtime, "The Shorts-control runtime");
|
||||
requireDriverMethod(target, "assertCurrentShortsControl");
|
||||
return target.assertCurrentShortsControl(videoId, runtime);
|
||||
};
|
||||
}
|
||||
|
||||
if (property === "captureWatchRatioVisual") {
|
||||
return (requestedRuntime, screenshotPath, options) => {
|
||||
assertRuntimeArgument(requestedRuntime, runtime, "The watch-visual runtime");
|
||||
requireDriverMethod(target, "captureWatchRatioVisual");
|
||||
return options === undefined
|
||||
? target.captureWatchRatioVisual(runtime, screenshotPath)
|
||||
: target.captureWatchRatioVisual(runtime, screenshotPath, options);
|
||||
};
|
||||
}
|
||||
|
||||
if (property === "captureReactionStateVisual") {
|
||||
return (request) => {
|
||||
assertRuntimeArgument(request?.runtime, runtime, "The reaction-visual runtime");
|
||||
requireDriverMethod(target, "captureReactionStateVisual");
|
||||
return target.captureReactionStateVisual({ ...request, runtime });
|
||||
};
|
||||
}
|
||||
|
||||
const value = Reflect.get(target, property, receiver);
|
||||
return typeof value === "function" ? value.bind(target) : value;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
class LiveRuntimeAdapter {
|
||||
constructor({ driver, expectedBuildId, expectedVersion, runtime }) {
|
||||
const profile = LIVE_RUNTIME_PROFILES[runtime];
|
||||
if (!profile) throw new TypeError(`Unsupported live runtime adapter: ${runtime}`);
|
||||
if (!driver || (typeof driver !== "object" && typeof driver !== "function")) {
|
||||
throw new TypeError("A live YouTube driver is required.");
|
||||
}
|
||||
if (typeof expectedVersion !== "string" || expectedVersion.trim() === "") {
|
||||
throw new TypeError("A non-empty expected runtime version is required.");
|
||||
}
|
||||
if (!/^[a-f0-9]{32}$/.test(expectedBuildId)) {
|
||||
throw new TypeError("The expected live build ID must be a 32-character lowercase hexadecimal value.");
|
||||
}
|
||||
|
||||
this.capabilities = profile.capabilities;
|
||||
this.driver = createRuntimeBoundDriver(driver, runtime, expectedVersion, expectedBuildId);
|
||||
this.expectedBuildId = expectedBuildId;
|
||||
this.expectedVersion = expectedVersion;
|
||||
this.profile = profile;
|
||||
this.runtime = runtime;
|
||||
this.selectors = profile.selectors;
|
||||
}
|
||||
|
||||
createScenarioOptions(options = {}) {
|
||||
if (!options || typeof options !== "object" || Array.isArray(options)) {
|
||||
throw new TypeError("Shared live scenario options must be an object.");
|
||||
}
|
||||
assertRuntimeArgument(options.runtime, this.runtime, "The configured scenario runtime");
|
||||
assertRuntimeArgument(options.expectedBuildId, this.expectedBuildId, "The configured live build ID");
|
||||
assertRuntimeArgument(options.expectedVersion, this.expectedVersion, "The configured scenario runtime version");
|
||||
return {
|
||||
...options,
|
||||
expectedBuildId: this.expectedBuildId,
|
||||
expectedVersion: this.expectedVersion,
|
||||
runtime: this.runtime,
|
||||
};
|
||||
}
|
||||
|
||||
assertDriverMethods(methods, scenarioId) {
|
||||
methods.forEach((method) => requireDriverMethod(this.driver, method, scenarioId));
|
||||
}
|
||||
}
|
||||
|
||||
function createExtensionLiveRuntimeAdapter(options) {
|
||||
return new LiveRuntimeAdapter({ ...options, runtime: "extension" });
|
||||
}
|
||||
|
||||
function createUserscriptLiveRuntimeAdapter(options) {
|
||||
return new LiveRuntimeAdapter({ ...options, runtime: "userscript" });
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
LIVE_RUNTIME_PROFILES,
|
||||
LiveRuntimeAdapter,
|
||||
createExtensionLiveRuntimeAdapter,
|
||||
createUserscriptLiveRuntimeAdapter,
|
||||
};
|
||||
@@ -0,0 +1,199 @@
|
||||
const defaultImplementations = require("../UserScript/e2e/live/live-scenarios");
|
||||
|
||||
const COMMON_PRECONDITION_METHODS = ["assertRuntime", "assertSignedIn"];
|
||||
|
||||
const SHARED_LIVE_SCENARIOS = Object.freeze([
|
||||
{
|
||||
driverMethods: [
|
||||
...COMMON_PRECONDITION_METHODS,
|
||||
"assertCurrentShortsControl",
|
||||
"navigateFromColdChannelToShort",
|
||||
"navigateToNextShort",
|
||||
"pausePlayback",
|
||||
"withNoProductionInteractions",
|
||||
],
|
||||
id: "channel-shorts-navigation",
|
||||
implementation: "runChannelShortsNavigationScenario",
|
||||
},
|
||||
{
|
||||
driverMethods: [
|
||||
...COMMON_PRECONDITION_METHODS,
|
||||
"navigateFromColdChannelToWatch",
|
||||
"waitForDislikeText",
|
||||
"withNoProductionInteractions",
|
||||
],
|
||||
id: "channel-watch-navigation",
|
||||
implementation: "runChannelWatchNavigationScenario",
|
||||
},
|
||||
{
|
||||
driverMethods: [
|
||||
...COMMON_PRECONDITION_METHODS,
|
||||
"openPlaylist",
|
||||
"waitForDislikeText",
|
||||
"withNoProductionInteractions",
|
||||
],
|
||||
id: "watch-render",
|
||||
implementation: "runWatchRenderScenario",
|
||||
},
|
||||
{
|
||||
driverMethods: [
|
||||
...COMMON_PRECONDITION_METHODS,
|
||||
"openPlaylist",
|
||||
"reload",
|
||||
"waitForDislikeText",
|
||||
"withNoProductionInteractions",
|
||||
],
|
||||
id: "reload",
|
||||
implementation: "runReloadScenario",
|
||||
},
|
||||
{
|
||||
driverMethods: [
|
||||
...COMMON_PRECONDITION_METHODS,
|
||||
"navigateWithinPlaylist",
|
||||
"openPlaylist",
|
||||
"waitForDislikeText",
|
||||
"withNoProductionInteractions",
|
||||
],
|
||||
id: "spa-navigation",
|
||||
implementation: "runSpaNavigationScenario",
|
||||
},
|
||||
{
|
||||
driverMethods: [
|
||||
...COMMON_PRECONDITION_METHODS,
|
||||
"assertCurrentVideo",
|
||||
"captureWatchRatioVisual",
|
||||
"navigateToRelatedWatch",
|
||||
"openWatch",
|
||||
"soakWatchRatioVisual",
|
||||
"withNoProductionInteractions",
|
||||
],
|
||||
id: "sidebar-navigation-stress",
|
||||
implementation: "runSidebarStressScenario",
|
||||
},
|
||||
{
|
||||
driverMethods: [...COMMON_PRECONDITION_METHODS, "openShort", "waitForDislikeText", "withNoProductionInteractions"],
|
||||
id: "shorts-render",
|
||||
implementation: "runShortsRenderScenario",
|
||||
},
|
||||
{
|
||||
driverMethods: [
|
||||
...COMMON_PRECONDITION_METHODS,
|
||||
"assertCurrentShortsControl",
|
||||
"captureWatchRatioVisual",
|
||||
"openWatch",
|
||||
"readViewportSize",
|
||||
"setViewportSize",
|
||||
"waitForDislikeText",
|
||||
"withNoProductionInteractions",
|
||||
],
|
||||
driverMethodsByCapability: {
|
||||
"native-pair": ["captureNativeShortsVisual", "openShort"],
|
||||
"strict-synthetic": ["captureSyntheticShortsVisual", "openShort"],
|
||||
},
|
||||
id: "responsive-visual",
|
||||
implementation: "runResponsiveVisualScenario",
|
||||
},
|
||||
{
|
||||
driverMethods: [
|
||||
...COMMON_PRECONDITION_METHODS,
|
||||
"assertCurrentVideo",
|
||||
"captureReactionStateVisual",
|
||||
"clickAction",
|
||||
"openShort",
|
||||
"openWatch",
|
||||
"readReactionState",
|
||||
"waitForDislikeText",
|
||||
"waitForReactionState",
|
||||
],
|
||||
id: "reaction-matrix",
|
||||
implementation: "runProductionReactionMatrixScenario",
|
||||
},
|
||||
]);
|
||||
|
||||
const SHARED_LIVE_SCENARIO_IDS = Object.freeze(SHARED_LIVE_SCENARIOS.map(({ id }) => id));
|
||||
const SCENARIOS_BY_ID = new Map(SHARED_LIVE_SCENARIOS.map((scenario) => [scenario.id, scenario]));
|
||||
|
||||
function scenarioDriverMethods(scenario, adapter) {
|
||||
const capabilityMethods = scenario.driverMethodsByCapability?.[adapter.capabilities.shortsVisualModel] ?? [];
|
||||
return [...new Set([...scenario.driverMethods, ...capabilityMethods])];
|
||||
}
|
||||
|
||||
function assertSharedLiveScenarioAdapter(adapter, scenarioIds = SHARED_LIVE_SCENARIO_IDS) {
|
||||
if (
|
||||
!adapter ||
|
||||
typeof adapter.createScenarioOptions !== "function" ||
|
||||
typeof adapter.assertDriverMethods !== "function"
|
||||
) {
|
||||
throw new TypeError("A LiveRuntimeAdapter is required to run shared live scenarios.");
|
||||
}
|
||||
|
||||
scenarioIds.forEach((scenarioId) => {
|
||||
const scenario = SCENARIOS_BY_ID.get(scenarioId);
|
||||
if (!scenario) throw new TypeError(`Unknown shared live scenario: ${scenarioId}`);
|
||||
adapter.assertDriverMethods(scenarioDriverMethods(scenario, adapter), scenarioId);
|
||||
});
|
||||
return [...scenarioIds];
|
||||
}
|
||||
|
||||
function requireService(services, name, scenarioId) {
|
||||
if (typeof services?.[name] === "function") return services[name];
|
||||
throw new TypeError(`Shared scenario ${scenarioId} requires services.${name}().`);
|
||||
}
|
||||
|
||||
function requireImplementation(implementations, scenario) {
|
||||
const implementation = implementations?.[scenario.implementation];
|
||||
if (typeof implementation !== "function") {
|
||||
throw new TypeError(`Shared scenario ${scenario.id} has no ${scenario.implementation}() implementation.`);
|
||||
}
|
||||
return implementation;
|
||||
}
|
||||
|
||||
function createSharedLiveScenarioRunner({ implementations = defaultImplementations } = {}) {
|
||||
async function run(adapter, scenarioId, options = {}, services = {}) {
|
||||
const scenario = SCENARIOS_BY_ID.get(scenarioId);
|
||||
if (!scenario) throw new TypeError(`Unknown shared live scenario: ${scenarioId}`);
|
||||
assertSharedLiveScenarioAdapter(adapter, [scenarioId]);
|
||||
|
||||
const implementation = requireImplementation(implementations, scenario);
|
||||
const scenarioOptions = adapter.createScenarioOptions(options);
|
||||
if (scenarioId === "responsive-visual") {
|
||||
return implementation(adapter.driver, scenarioOptions, services.visualOptions);
|
||||
}
|
||||
if (scenarioId === "sidebar-navigation-stress") {
|
||||
return implementation(adapter.driver, scenarioOptions, services.sidebarOptions);
|
||||
}
|
||||
if (scenarioId === "reaction-matrix") {
|
||||
return implementation(
|
||||
adapter.driver,
|
||||
scenarioOptions,
|
||||
requireService(services, "createRecorder", scenarioId),
|
||||
requireService(services, "consumeVoteApproval", scenarioId),
|
||||
services.visualOptions,
|
||||
);
|
||||
}
|
||||
return implementation(adapter.driver, scenarioOptions);
|
||||
}
|
||||
|
||||
async function runAll(adapter, options = {}, services = {}, scenarioIds = SHARED_LIVE_SCENARIO_IDS) {
|
||||
assertSharedLiveScenarioAdapter(adapter, scenarioIds);
|
||||
const results = [];
|
||||
for (const scenarioId of scenarioIds) {
|
||||
results.push({ id: scenarioId, result: await run(adapter, scenarioId, options, services) });
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
ids: SHARED_LIVE_SCENARIO_IDS,
|
||||
run,
|
||||
runAll,
|
||||
validateAdapter: assertSharedLiveScenarioAdapter,
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
SHARED_LIVE_SCENARIO_IDS,
|
||||
SHARED_LIVE_SCENARIOS,
|
||||
assertSharedLiveScenarioAdapter,
|
||||
createSharedLiveScenarioRunner,
|
||||
};
|
||||
@@ -0,0 +1,252 @@
|
||||
const {
|
||||
LIVE_RUNTIME_PROFILES,
|
||||
createExtensionLiveRuntimeAdapter,
|
||||
createUserscriptLiveRuntimeAdapter,
|
||||
} = require("./live-runtime-adapter");
|
||||
const {
|
||||
SHARED_LIVE_SCENARIO_IDS,
|
||||
assertSharedLiveScenarioAdapter,
|
||||
createSharedLiveScenarioRunner,
|
||||
} = require("./shared-live-scenarios");
|
||||
|
||||
const EXPECTED_SCENARIO_IDS = [
|
||||
"channel-shorts-navigation",
|
||||
"channel-watch-navigation",
|
||||
"watch-render",
|
||||
"reload",
|
||||
"spa-navigation",
|
||||
"sidebar-navigation-stress",
|
||||
"shorts-render",
|
||||
"responsive-visual",
|
||||
"reaction-matrix",
|
||||
];
|
||||
const EXPECTED_BUILD_ID = "0123456789abcdef0123456789abcdef";
|
||||
|
||||
const ALL_DRIVER_METHODS = [
|
||||
"assertCurrentShortsControl",
|
||||
"assertCurrentVideo",
|
||||
"assertRuntime",
|
||||
"assertSignedIn",
|
||||
"captureReactionStateVisual",
|
||||
"captureNativeShortsVisual",
|
||||
"captureSyntheticShortsVisual",
|
||||
"captureWatchRatioVisual",
|
||||
"clickAction",
|
||||
"navigateFromColdChannelToShort",
|
||||
"navigateFromColdChannelToWatch",
|
||||
"navigateToNextShort",
|
||||
"navigateToRelatedWatch",
|
||||
"navigateWithinPlaylist",
|
||||
"openPlaylist",
|
||||
"openShort",
|
||||
"openWatch",
|
||||
"pausePlayback",
|
||||
"readReactionState",
|
||||
"readViewportSize",
|
||||
"reload",
|
||||
"setViewportSize",
|
||||
"soakWatchRatioVisual",
|
||||
"waitForDislikeText",
|
||||
"waitForReactionState",
|
||||
"withNoProductionInteractions",
|
||||
];
|
||||
|
||||
function createDriver(overrides = {}) {
|
||||
return {
|
||||
...Object.fromEntries(ALL_DRIVER_METHODS.map((method) => [method, jest.fn()])),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createAdapter(runtime, driver = createDriver(), expectedBuildId = EXPECTED_BUILD_ID) {
|
||||
const options = { driver, expectedBuildId, expectedVersion: runtime === "userscript" ? "3.2.0" : "4.0.4" };
|
||||
return runtime === "userscript"
|
||||
? createUserscriptLiveRuntimeAdapter(options)
|
||||
: createExtensionLiveRuntimeAdapter(options);
|
||||
}
|
||||
|
||||
function createImplementations(events) {
|
||||
const implementationNames = [
|
||||
"runChannelShortsNavigationScenario",
|
||||
"runChannelWatchNavigationScenario",
|
||||
"runWatchRenderScenario",
|
||||
"runReloadScenario",
|
||||
"runSpaNavigationScenario",
|
||||
"runSidebarStressScenario",
|
||||
"runShortsRenderScenario",
|
||||
"runResponsiveVisualScenario",
|
||||
"runProductionReactionMatrixScenario",
|
||||
];
|
||||
return Object.fromEntries(
|
||||
implementationNames.map((name) => [
|
||||
name,
|
||||
jest.fn(async (_driver, options) => {
|
||||
events.push({ implementation: name, runtime: options.runtime });
|
||||
return `${options.runtime}:${name}`;
|
||||
}),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
describe("shared live runtime profiles", () => {
|
||||
test("preserves each runtime's selectors and control-ownership capabilities", () => {
|
||||
expect(LIVE_RUNTIME_PROFILES.userscript).toMatchObject({
|
||||
capabilities: {
|
||||
backgroundVoteTransport: false,
|
||||
credentialStore: "gm-storage",
|
||||
ownsShortsDislikeControl: true,
|
||||
shortsControlModel: "synthetic-owned",
|
||||
shortsVisualModel: "strict-synthetic",
|
||||
},
|
||||
buildMarkerAttribute: "data-ryd-userscript-build",
|
||||
markerAttribute: "data-ryd-userscript-version",
|
||||
selectors: {
|
||||
rateBar: "#return-youtube-dislike-bar",
|
||||
rateBarContainer: "#return-youtube-dislike-bar-container",
|
||||
shortsDislikeControl: "[data-ryd-synthetic-shorts-dislike]",
|
||||
},
|
||||
});
|
||||
expect(LIVE_RUNTIME_PROFILES.extension).toMatchObject({
|
||||
capabilities: {
|
||||
backgroundVoteTransport: true,
|
||||
credentialStore: "browser.storage",
|
||||
ownsShortsDislikeControl: false,
|
||||
shortsControlModel: "native-youtube",
|
||||
shortsVisualModel: "native-pair",
|
||||
},
|
||||
buildMarkerAttribute: "data-ryd-extension-build",
|
||||
markerAttribute: "data-ryd-extension-version",
|
||||
selectors: {
|
||||
rateBar: "#ryd-bar",
|
||||
rateBarContainer: "#ryd-bar-container",
|
||||
shortsDislikeControl: null,
|
||||
},
|
||||
});
|
||||
expect(Object.isFrozen(LIVE_RUNTIME_PROFILES.userscript.capabilities)).toBe(true);
|
||||
expect(Object.isFrozen(LIVE_RUNTIME_PROFILES.extension.selectors)).toBe(true);
|
||||
});
|
||||
|
||||
test.each(["userscript", "extension"])("binds runtime-sensitive driver calls for %s", async (runtime) => {
|
||||
const driver = createDriver();
|
||||
const adapter = createAdapter(runtime, driver);
|
||||
|
||||
await adapter.driver.assertRuntime(runtime, adapter.expectedVersion, adapter.expectedBuildId);
|
||||
await adapter.driver.assertCurrentShortsControl("abcdefghijk", runtime);
|
||||
await adapter.driver.captureWatchRatioVisual(runtime, "watch.png");
|
||||
await adapter.driver.captureReactionStateVisual({
|
||||
expectedState: "neutral",
|
||||
isShort: false,
|
||||
runtime,
|
||||
screenshotPath: "reaction.png",
|
||||
videoId: "abcdefghijk",
|
||||
});
|
||||
|
||||
expect(driver.assertRuntime).toHaveBeenCalledWith(runtime, adapter.expectedVersion, adapter.expectedBuildId);
|
||||
expect(driver.assertCurrentShortsControl).toHaveBeenCalledWith("abcdefghijk", runtime);
|
||||
expect(driver.captureWatchRatioVisual).toHaveBeenCalledWith(runtime, "watch.png");
|
||||
expect(driver.captureReactionStateVisual).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ runtime, videoId: "abcdefghijk" }),
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects scenario options and calls for another runtime", async () => {
|
||||
const adapter = createAdapter("userscript");
|
||||
|
||||
expect(() => adapter.createScenarioOptions({ runtime: "extension" })).toThrow(
|
||||
"The configured scenario runtime does not match",
|
||||
);
|
||||
expect(() => adapter.driver.assertRuntime("extension", adapter.expectedVersion)).toThrow(
|
||||
"The asserted runtime does not match",
|
||||
);
|
||||
});
|
||||
|
||||
test.each([undefined, "", "stale", "A".repeat(32)])(
|
||||
"rejects a missing or malformed exact live build ID: %p",
|
||||
(expectedBuildId) => {
|
||||
expect(() =>
|
||||
createUserscriptLiveRuntimeAdapter({
|
||||
driver: createDriver(),
|
||||
expectedBuildId,
|
||||
expectedVersion: "3.2.0",
|
||||
}),
|
||||
).toThrow("expected live build ID must be a 32-character lowercase hexadecimal value");
|
||||
},
|
||||
);
|
||||
|
||||
test("binds the exact generated live build ID into runtime checks", async () => {
|
||||
const driver = createDriver();
|
||||
const expectedBuildId = "0123456789abcdef0123456789abcdef";
|
||||
const adapter = createAdapter("userscript", driver, expectedBuildId);
|
||||
|
||||
await adapter.driver.assertRuntime("userscript", adapter.expectedVersion, expectedBuildId);
|
||||
|
||||
expect(driver.assertRuntime).toHaveBeenCalledWith("userscript", adapter.expectedVersion, expectedBuildId);
|
||||
expect(adapter.createScenarioOptions()).toMatchObject({ expectedBuildId });
|
||||
expect(() => adapter.driver.assertRuntime("userscript", adapter.expectedVersion, "f".repeat(32))).toThrow(
|
||||
"live build ID does not match",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shared live scenario contract", () => {
|
||||
test("publishes one stable ordered scenario ID list", () => {
|
||||
expect(SHARED_LIVE_SCENARIO_IDS).toEqual(EXPECTED_SCENARIO_IDS);
|
||||
expect(Object.isFrozen(SHARED_LIVE_SCENARIO_IDS)).toBe(true);
|
||||
});
|
||||
|
||||
test.each(["userscript", "extension"])("validates the complete contract for %s", (runtime) => {
|
||||
const adapter = createAdapter(runtime);
|
||||
expect(assertSharedLiveScenarioAdapter(adapter)).toEqual(EXPECTED_SCENARIO_IDS);
|
||||
});
|
||||
|
||||
test("requires each runtime's Shorts visual model from the shared responsive scenario", () => {
|
||||
const driver = createDriver();
|
||||
delete driver.captureSyntheticShortsVisual;
|
||||
|
||||
expect(() => assertSharedLiveScenarioAdapter(createAdapter("userscript", driver), ["responsive-visual"])).toThrow(
|
||||
"must implement captureSyntheticShortsVisual()",
|
||||
);
|
||||
expect(assertSharedLiveScenarioAdapter(createAdapter("extension", driver), ["responsive-visual"])).toEqual([
|
||||
"responsive-visual",
|
||||
]);
|
||||
|
||||
const extensionDriver = createDriver();
|
||||
delete extensionDriver.captureNativeShortsVisual;
|
||||
expect(() =>
|
||||
assertSharedLiveScenarioAdapter(createAdapter("extension", extensionDriver), ["responsive-visual"]),
|
||||
).toThrow("must implement captureNativeShortsVisual()");
|
||||
});
|
||||
|
||||
test("executes the same scenario IDs through both runtime adapters", async () => {
|
||||
const events = [];
|
||||
const implementations = createImplementations(events);
|
||||
const runner = createSharedLiveScenarioRunner({ implementations });
|
||||
const services = {
|
||||
consumeVoteApproval: jest.fn(),
|
||||
createRecorder: jest.fn(),
|
||||
visualOptions: { outputDirectory: "evidence" },
|
||||
sidebarOptions: { outputDirectory: "sidebar-evidence" },
|
||||
};
|
||||
|
||||
const userscriptResults = await runner.runAll(createAdapter("userscript"), {}, services);
|
||||
const extensionResults = await runner.runAll(createAdapter("extension"), {}, services);
|
||||
|
||||
expect(userscriptResults.map(({ id }) => id)).toEqual(EXPECTED_SCENARIO_IDS);
|
||||
expect(extensionResults.map(({ id }) => id)).toEqual(EXPECTED_SCENARIO_IDS);
|
||||
expect(events.map(({ runtime }) => runtime)).toEqual([
|
||||
...EXPECTED_SCENARIO_IDS.map(() => "userscript"),
|
||||
...EXPECTED_SCENARIO_IDS.map(() => "extension"),
|
||||
]);
|
||||
expect(Object.values(implementations).every((implementation) => implementation.mock.calls.length === 2)).toBe(true);
|
||||
});
|
||||
|
||||
test("rejects unknown IDs before invoking a scenario implementation", async () => {
|
||||
const implementations = createImplementations([]);
|
||||
const runner = createSharedLiveScenarioRunner({ implementations });
|
||||
|
||||
await expect(runner.run(createAdapter("userscript"), "not-a-scenario")).rejects.toThrow(
|
||||
"Unknown shared live scenario: not-a-scenario",
|
||||
);
|
||||
expect(Object.values(implementations).every((implementation) => implementation.mock.calls.length === 0)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
const { NAVIGATION_MATRIX } = require("../UserScript/e2e/navigation-matrix");
|
||||
const { SHARED_LIVE_SCENARIO_IDS } = require("./shared-live-scenarios");
|
||||
|
||||
const REQUIRED_SURFACE_PAIRS = ["watch->watch", "watch->shorts", "shorts->watch", "shorts->shorts"];
|
||||
const REQUIRED_TRIGGERS = [
|
||||
"autoplay-ended",
|
||||
"direct-link",
|
||||
"dom-corruption",
|
||||
"dom-replacement",
|
||||
"history-back-forward",
|
||||
"next-control",
|
||||
"sidebar-link",
|
||||
];
|
||||
const REQUIRED_DOM_FEATURES = [
|
||||
"active-reel-switch",
|
||||
"connected-collapsed-rate-bar",
|
||||
"connected-hidden-rate-bar",
|
||||
"delayed-hydration",
|
||||
"hidden-outgoing-first",
|
||||
"legacy-segmented-duplicate-ids",
|
||||
"malformed-rate-bar",
|
||||
"no-useful-control-mutation",
|
||||
"positive-size-offscreen-outgoing-first",
|
||||
"prune-current-bar",
|
||||
"replace-controls",
|
||||
"replace-current-action-container",
|
||||
"reuse-exact-control-nodes",
|
||||
"retain-hidden-outgoing",
|
||||
"same-current-root",
|
||||
"stripped-rate-bar-wrapper-class",
|
||||
];
|
||||
const REQUIRED_TIMINGS = [
|
||||
"destination-count-gated",
|
||||
"finish-before-hydration",
|
||||
"no-navigate-finish",
|
||||
"no-navigation-event",
|
||||
"navigate-finish",
|
||||
"same-video",
|
||||
"settled",
|
||||
];
|
||||
const REQUIRED_SHARED_LIVE_SCENARIOS = [
|
||||
"channel-shorts-navigation",
|
||||
"channel-watch-navigation",
|
||||
"watch-render",
|
||||
"reload",
|
||||
"spa-navigation",
|
||||
"sidebar-navigation-stress",
|
||||
"shorts-render",
|
||||
"responsive-visual",
|
||||
"reaction-matrix",
|
||||
];
|
||||
|
||||
function collectCoverage(axis) {
|
||||
return new Set(
|
||||
NAVIGATION_MATRIX.flatMap((scenario) => {
|
||||
const value = scenario.coverage[axis];
|
||||
return Array.isArray(value) ? value : [value];
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
describe("systematic browser coverage contract", () => {
|
||||
test("keeps every declarative scenario uniquely identified and completely classified", () => {
|
||||
expect(new Set(NAVIGATION_MATRIX.map(({ id }) => id)).size).toBe(NAVIGATION_MATRIX.length);
|
||||
for (const scenario of NAVIGATION_MATRIX) {
|
||||
expect(scenario).toEqual(
|
||||
expect.objectContaining({
|
||||
coverage: expect.objectContaining({
|
||||
destination: expect.any(String),
|
||||
dom: expect.any(Array),
|
||||
origin: expect.any(String),
|
||||
timing: expect.any(Array),
|
||||
trigger: expect.any(String),
|
||||
width: expect.any(String),
|
||||
}),
|
||||
destination: expect.objectContaining({ counts: expect.any(Object), kind: expect.any(String) }),
|
||||
id: expect.any(String),
|
||||
origin: expect.objectContaining({ counts: expect.any(Object), kind: expect.any(String) }),
|
||||
viewport: expect.objectContaining({ height: expect.any(Number), width: expect.any(Number) }),
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("covers every watch and Shorts direction and every required navigation trigger", () => {
|
||||
const surfacePairs = new Set(
|
||||
NAVIGATION_MATRIX.map(({ coverage }) => `${coverage.origin}->${coverage.destination}`),
|
||||
);
|
||||
REQUIRED_SURFACE_PAIRS.forEach((pair) => expect(surfacePairs).toContain(pair));
|
||||
const triggers = collectCoverage("trigger");
|
||||
REQUIRED_TRIGGERS.forEach((trigger) => expect(triggers).toContain(trigger));
|
||||
});
|
||||
|
||||
test("keeps the retained, replacement, hydration, pruning, and event-order stress cells", () => {
|
||||
const domFeatures = collectCoverage("dom");
|
||||
REQUIRED_DOM_FEATURES.forEach((feature) => expect(domFeatures).toContain(feature));
|
||||
const timings = collectCoverage("timing");
|
||||
REQUIRED_TIMINGS.forEach((timing) => expect(timings).toContain(timing));
|
||||
});
|
||||
|
||||
test("uses distinguishable ratios whenever a scenario changes videos", () => {
|
||||
for (const scenario of NAVIGATION_MATRIX.filter(
|
||||
({ destination, origin }) => destination.videoId !== origin.videoId,
|
||||
)) {
|
||||
const ratio = ({ likes, dislikes }) => likes / (likes + dislikes);
|
||||
expect(ratio(scenario.origin.counts)).not.toBe(ratio(scenario.destination.counts));
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps the same authenticated scenario catalog for both runtime adapters", () => {
|
||||
expect(SHARED_LIVE_SCENARIO_IDS).toEqual(REQUIRED_SHARED_LIVE_SCENARIOS);
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,10 @@ module.exports = {
|
||||
resetMocks: true,
|
||||
collectCoverage: false,
|
||||
collectCoverageFrom: [
|
||||
"Extensions/common/**/*.js",
|
||||
"Extensions/combined/src/**/*.js",
|
||||
"Extensions/combined/*.js",
|
||||
"Extensions/UserScript/src/**/*.js",
|
||||
],
|
||||
testPathIgnorePatterns: ["/node_modules/", "<rootDir>/Extensions/UserScript/e2e/"],
|
||||
};
|
||||
|
||||
Generated
+89
@@ -18,6 +18,7 @@
|
||||
"@babel/core": "^7.23.5",
|
||||
"@babel/preset-env": "^7.23.5",
|
||||
"@babel/runtime": "^7.23.5",
|
||||
"@playwright/test": "1.62.0",
|
||||
"babel-loader": "^10.0.0",
|
||||
"babel-plugin-rewire": "^1.2.0",
|
||||
"copy-webpack-plugin": "^11.0.0",
|
||||
@@ -91,6 +92,7 @@
|
||||
"version": "7.23.5",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@ampproject/remapping": "^2.2.0",
|
||||
"@babel/code-frame": "^7.23.5",
|
||||
@@ -1740,6 +1742,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
@@ -1763,6 +1766,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -2537,6 +2541,22 @@
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.62.0",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.0.tgz",
|
||||
"integrity": "sha512-9zOJ6ZQRAena31MpOH9VSzIz8Ou3YJ/wtY/eQm5T2uhfhG7/U3COrMS8xOtUrZrp9OgdmzEnIYODye3nY1VqzA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.62.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/@sinclair/typebox": {
|
||||
"version": "0.24.51",
|
||||
"dev": true,
|
||||
@@ -2974,6 +2994,7 @@
|
||||
"version": "8.11.2",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -3003,6 +3024,7 @@
|
||||
"version": "6.12.6",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
"fast-json-stable-stringify": "^2.0.0",
|
||||
@@ -3504,6 +3526,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"caniuse-lite": "^1.0.30001565",
|
||||
"electron-to-chromium": "^1.4.601",
|
||||
@@ -4515,6 +4538,21 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.1",
|
||||
"dev": true,
|
||||
@@ -5912,6 +5950,7 @@
|
||||
"integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cssstyle": "^4.2.1",
|
||||
"data-urls": "^5.0.0",
|
||||
@@ -6644,6 +6683,53 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.62.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.0.tgz",
|
||||
"integrity": "sha512-Z14dG305dgaLu6foB1TXQagFiW8JfSUIUaUuPaKQ6NtBPKF1P/qXcqfh6c6K/icPqdy37JmjbiBXf6JNg6Sylw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.62.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.62.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.0.tgz",
|
||||
"integrity": "sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright/node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prettier": {
|
||||
"version": "3.3.2",
|
||||
"dev": true,
|
||||
@@ -7080,6 +7166,7 @@
|
||||
"version": "8.12.0",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
"json-schema-traverse": "^1.0.0",
|
||||
@@ -7872,6 +7959,7 @@
|
||||
"version": "5.89.0",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/eslint-scope": "^3.7.3",
|
||||
"@types/estree": "^1.0.0",
|
||||
@@ -7920,6 +8008,7 @@
|
||||
"integrity": "sha512-MfwFQ6SfwinsUVi0rNJm7rHZ31GyTcpVE5pgVA3hwFRb7COD4TzjUUwhGWKfO50+xdc2MQPuEBBJoqIMGt3JDw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@discoveryjs/json-ext": "^0.6.1",
|
||||
"@webpack-cli/configtest": "^3.0.1",
|
||||
|
||||
+12
-2
@@ -7,7 +7,16 @@
|
||||
"start": "echo To build for development, please use \"npm run dev\". To build for production, please use \"npm run build\".",
|
||||
"dev": "webpack --mode=development --watch",
|
||||
"build": "webpack --mode=production",
|
||||
"build:userscript": "webpack --mode=production --config webpack.userscript.config.js",
|
||||
"build:live:userscript": "webpack --mode=production --config webpack.userscript.config.js --env liveTest=true",
|
||||
"build:live:extension": "webpack --mode=production --env liveTest=true",
|
||||
"test": "jest",
|
||||
"test:e2e:artifacts": "node Extensions/e2e/hermetic-artifact-smoke.js",
|
||||
"test:e2e:systematic": "npm run build && npm run build:userscript && npm run test:e2e:artifacts && playwright test --config playwright.userscript.config.js",
|
||||
"test:e2e:userscript": "npm run build:userscript && playwright test --config playwright.userscript.config.js",
|
||||
"test:live:youtube": "playwright test --config playwright.live-youtube.config.js",
|
||||
"test:live:youtube:interactive": "node Extensions/UserScript/e2e/live/live-interactive-runner.js",
|
||||
"test:all": "npm test -- --runInBand && npm run test:e2e:systematic",
|
||||
"build:safari": "webpack --mode=production && xcrun safari-web-extension-converter Extensions/combined/dist/safari --project-location Extensions/combined/dist --bundle-identifier com.returnyoutubedislike.safari-ext --force",
|
||||
"prepare": "husky install"
|
||||
},
|
||||
@@ -33,13 +42,14 @@
|
||||
"country-code-lookup": "^0.1.3",
|
||||
"echarts": "^5.5.0",
|
||||
"topojson-client": "^3.1.0",
|
||||
"world-atlas": "^2.0.2",
|
||||
"us-atlas": "^3.0.0"
|
||||
"us-atlas": "^3.0.0",
|
||||
"world-atlas": "^2.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.23.5",
|
||||
"@babel/preset-env": "^7.23.5",
|
||||
"@babel/runtime": "^7.23.5",
|
||||
"@playwright/test": "1.62.0",
|
||||
"babel-loader": "^10.0.0",
|
||||
"babel-plugin-rewire": "^1.2.0",
|
||||
"copy-webpack-plugin": "^11.0.0",
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
const { defineConfig } = require("@playwright/test");
|
||||
|
||||
module.exports = defineConfig({
|
||||
testDir: "./Extensions/UserScript/e2e/live",
|
||||
testMatch: "**/*.live.e2e.js",
|
||||
fullyParallel: false,
|
||||
forbidOnly: true,
|
||||
retries: 0,
|
||||
workers: 1,
|
||||
timeout: 90_000,
|
||||
expect: {
|
||||
timeout: 20_000,
|
||||
},
|
||||
reporter: "list",
|
||||
outputDir: "test-results/live-youtube",
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
const { defineConfig, devices } = require("@playwright/test");
|
||||
|
||||
module.exports = defineConfig({
|
||||
testDir: "./Extensions/UserScript/e2e",
|
||||
testMatch: "**/*.e2e.js",
|
||||
testIgnore: "**/live/**",
|
||||
fullyParallel: false,
|
||||
forbidOnly: Boolean(process.env.CI),
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
timeout: 15_000,
|
||||
expect: {
|
||||
timeout: 5_000,
|
||||
},
|
||||
reporter: process.env.CI ? [["line"], ["html", { open: "never", outputFolder: "playwright-report" }]] : "list",
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
browserName: "chromium",
|
||||
serviceWorkers: "block",
|
||||
trace: "retain-on-failure",
|
||||
screenshot: "only-on-failure",
|
||||
video: "off",
|
||||
},
|
||||
outputDir: "test-results/userscript",
|
||||
});
|
||||
+88
-68
@@ -1,8 +1,10 @@
|
||||
const path = require("path");
|
||||
const fs = require("fs");
|
||||
const CopyPlugin = require("copy-webpack-plugin");
|
||||
const webpack = require("webpack");
|
||||
const { LiveBuildMarkerPlugin, createLiveBuildId } = require("./webpack.live-build-marker");
|
||||
|
||||
const extensionVersion = process.env.npm_package_version.replace("-", ".");
|
||||
const extensionVersion = (process.env.npm_package_version || require("./package.json").version).replace("-", ".");
|
||||
const entries = ["ryd.content-script", "ryd.background", "popup", "ryd.changelog"];
|
||||
|
||||
const ignorePatterns = [
|
||||
@@ -51,8 +53,8 @@ class MirrorJsOutputsPlugin {
|
||||
const existingFiles = await fsp.readdir(targetDir).catch(() => []);
|
||||
await Promise.all(
|
||||
existingFiles
|
||||
.filter((file) => file.endsWith(".js"))
|
||||
.map((file) => fsp.rm(path.join(targetDir, file), { force: true }))
|
||||
.filter((file) => jsAssets.includes(file))
|
||||
.map((file) => fsp.rm(path.join(targetDir, file), { force: true })),
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
@@ -66,71 +68,89 @@ class MirrorJsOutputsPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = (env, argv) => ({
|
||||
entry: Object.fromEntries(
|
||||
entries.map((entry) => [entry, path.join(__dirname, "./Extensions/combined/", `${entry}.js`)]),
|
||||
),
|
||||
output: {
|
||||
filename: "[name].js",
|
||||
path: path.resolve(__dirname, "Extensions/combined/dist"),
|
||||
clean: true,
|
||||
},
|
||||
cache: false,
|
||||
optimization: {
|
||||
minimize: false,
|
||||
},
|
||||
watchOptions: {
|
||||
ignored: "**/dist/**",
|
||||
},
|
||||
plugins: [
|
||||
// exclude locale files in moment
|
||||
new CopyPlugin({
|
||||
patterns: [
|
||||
{
|
||||
from: "./Extensions/combined",
|
||||
to: "./chrome",
|
||||
globOptions: {
|
||||
ignore: ignorePatterns,
|
||||
module.exports = (env = {}, argv = {}) => {
|
||||
const liveTestBuild = env.liveTest === true || env.liveTest === "true";
|
||||
const liveBuildId = createLiveBuildId(liveTestBuild);
|
||||
|
||||
return {
|
||||
entry: Object.fromEntries(
|
||||
entries.map((entry) => [entry, path.join(__dirname, "./Extensions/combined/", `${entry}.js`)]),
|
||||
),
|
||||
output: {
|
||||
filename: "[name].js",
|
||||
path: path.resolve(__dirname, "Extensions/combined/dist"),
|
||||
clean: true,
|
||||
},
|
||||
cache: false,
|
||||
optimization: {
|
||||
minimize: false,
|
||||
},
|
||||
watchOptions: {
|
||||
ignored: "**/dist/**",
|
||||
},
|
||||
plugins: [
|
||||
new webpack.DefinePlugin({
|
||||
__RYD_LIVE_BUILD_ID__: JSON.stringify(liveBuildId),
|
||||
__RYD_LIVE_TEST_BUILD__: JSON.stringify(liveTestBuild),
|
||||
}),
|
||||
...(liveTestBuild
|
||||
? [
|
||||
new LiveBuildMarkerPlugin(liveBuildId, [
|
||||
"chrome/live-build.json",
|
||||
"firefox/live-build.json",
|
||||
"safari/live-build.json",
|
||||
]),
|
||||
]
|
||||
: []),
|
||||
// exclude locale files in moment
|
||||
new CopyPlugin({
|
||||
patterns: [
|
||||
{
|
||||
from: "./Extensions/combined",
|
||||
to: "./chrome",
|
||||
globOptions: {
|
||||
ignore: ignorePatterns,
|
||||
},
|
||||
transform: i18nTransform,
|
||||
},
|
||||
transform: i18nTransform,
|
||||
},
|
||||
{
|
||||
from: "./Extensions/combined/manifest-chrome.json",
|
||||
to: "./chrome/manifest.json",
|
||||
transform: manifestTransform,
|
||||
},
|
||||
{
|
||||
from: "./Extensions/combined",
|
||||
to: "./firefox",
|
||||
globOptions: {
|
||||
ignore: ignorePatterns,
|
||||
{
|
||||
from: "./Extensions/combined/manifest-chrome.json",
|
||||
to: "./chrome/manifest.json",
|
||||
transform: manifestTransform,
|
||||
},
|
||||
transform: i18nTransform,
|
||||
},
|
||||
{
|
||||
from: "./Extensions/combined/manifest-firefox.json",
|
||||
to: "./firefox/manifest.json",
|
||||
transform: manifestTransform,
|
||||
},
|
||||
{
|
||||
from: "./Extensions/combined",
|
||||
to: "./safari",
|
||||
globOptions: {
|
||||
ignore: ignorePatterns,
|
||||
{
|
||||
from: "./Extensions/combined",
|
||||
to: "./firefox",
|
||||
globOptions: {
|
||||
ignore: ignorePatterns,
|
||||
},
|
||||
transform: i18nTransform,
|
||||
},
|
||||
transform: i18nTransform,
|
||||
},
|
||||
{
|
||||
from: "./Extensions/combined/manifest-safari.json",
|
||||
to: "./safari/manifest.json",
|
||||
transform: manifestTransform,
|
||||
},
|
||||
],
|
||||
}),
|
||||
new MirrorJsOutputsPlugin(["chrome", "firefox", "safari"]),
|
||||
],
|
||||
experiments: {
|
||||
topLevelAwait: true,
|
||||
},
|
||||
devtool: argv.mode === "development" ? "inline-source-map" : false,
|
||||
});
|
||||
{
|
||||
from: "./Extensions/combined/manifest-firefox.json",
|
||||
to: "./firefox/manifest.json",
|
||||
transform: manifestTransform,
|
||||
},
|
||||
{
|
||||
from: "./Extensions/combined",
|
||||
to: "./safari",
|
||||
globOptions: {
|
||||
ignore: ignorePatterns,
|
||||
},
|
||||
transform: i18nTransform,
|
||||
},
|
||||
{
|
||||
from: "./Extensions/combined/manifest-safari.json",
|
||||
to: "./safari/manifest.json",
|
||||
transform: manifestTransform,
|
||||
},
|
||||
],
|
||||
}),
|
||||
new MirrorJsOutputsPlugin(["chrome", "firefox", "safari"]),
|
||||
],
|
||||
experiments: {
|
||||
topLevelAwait: true,
|
||||
},
|
||||
devtool: argv.mode === "development" ? "inline-source-map" : false,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
const crypto = require("node:crypto");
|
||||
|
||||
const LIVE_BUILD_ID_PATTERN = /^[a-f0-9]{32}$/;
|
||||
|
||||
function createLiveBuildId(enabled) {
|
||||
return enabled ? crypto.randomBytes(16).toString("hex") : "";
|
||||
}
|
||||
|
||||
class LiveBuildMarkerPlugin {
|
||||
constructor(buildId, assetNames) {
|
||||
if (!LIVE_BUILD_ID_PATTERN.test(buildId)) {
|
||||
throw new TypeError("A live build marker requires a 32-character hexadecimal build ID.");
|
||||
}
|
||||
this.assetNames = assetNames;
|
||||
this.buildId = buildId;
|
||||
}
|
||||
|
||||
apply(compiler) {
|
||||
compiler.hooks.thisCompilation.tap("LiveBuildMarkerPlugin", (compilation) => {
|
||||
compilation.hooks.processAssets.tap(
|
||||
{
|
||||
name: "LiveBuildMarkerPlugin",
|
||||
stage: compiler.webpack.Compilation.PROCESS_ASSETS_STAGE_ADDITIONAL,
|
||||
},
|
||||
() => {
|
||||
const source = new compiler.webpack.sources.RawSource(`${JSON.stringify({ buildId: this.buildId })}\n`);
|
||||
for (const assetName of this.assetNames) {
|
||||
compilation.emitAsset(assetName, source);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
LIVE_BUILD_ID_PATTERN,
|
||||
LiveBuildMarkerPlugin,
|
||||
createLiveBuildId,
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
const { LIVE_BUILD_ID_PATTERN, LiveBuildMarkerPlugin, createLiveBuildId } = require("./webpack.live-build-marker");
|
||||
const createExtensionConfig = require("./webpack.config");
|
||||
const createUserscriptConfig = require("./webpack.userscript.config");
|
||||
|
||||
function liveIdentity(config) {
|
||||
const definePlugin = config.plugins.find(({ constructor }) => constructor.name === "DefinePlugin");
|
||||
const markerPlugin = config.plugins.find((plugin) => plugin instanceof LiveBuildMarkerPlugin);
|
||||
return {
|
||||
definedBuildId: JSON.parse(definePlugin.definitions.__RYD_LIVE_BUILD_ID__),
|
||||
definedLiveFlag: definePlugin.definitions.__RYD_LIVE_TEST_BUILD__,
|
||||
markerAssetNames: markerPlugin?.assetNames,
|
||||
markerBuildId: markerPlugin?.buildId,
|
||||
};
|
||||
}
|
||||
|
||||
describe("live build marker", () => {
|
||||
test("keeps production builds deterministic and gives each live build a fresh exact identity", () => {
|
||||
expect(createLiveBuildId(false)).toBe("");
|
||||
const first = createLiveBuildId(true);
|
||||
const second = createLiveBuildId(true);
|
||||
expect(first).toMatch(LIVE_BUILD_ID_PATTERN);
|
||||
expect(second).toMatch(LIVE_BUILD_ID_PATTERN);
|
||||
expect(second).not.toBe(first);
|
||||
});
|
||||
|
||||
test("refuses malformed marker IDs", () => {
|
||||
expect(() => new LiveBuildMarkerPlugin("stale", ["live-build.json"])).toThrow("32-character hexadecimal build ID");
|
||||
});
|
||||
|
||||
test.each([
|
||||
["userscript", createUserscriptConfig, ["live-build.json"]],
|
||||
[
|
||||
"extension",
|
||||
createExtensionConfig,
|
||||
["chrome/live-build.json", "firefox/live-build.json", "safari/live-build.json"],
|
||||
],
|
||||
])("binds one exact nonce into the %s live bundle and its marker file", (_runtime, createConfig, assetNames) => {
|
||||
const identity = liveIdentity(createConfig({ liveTest: true }, { mode: "production" }));
|
||||
|
||||
expect(identity.definedBuildId).toMatch(LIVE_BUILD_ID_PATTERN);
|
||||
expect(identity.definedBuildId).toBe(identity.markerBuildId);
|
||||
expect(identity.definedLiveFlag).toBe("true");
|
||||
expect(identity.markerAssetNames).toEqual(assetNames);
|
||||
});
|
||||
|
||||
test.each([
|
||||
["userscript", createUserscriptConfig],
|
||||
["extension", createExtensionConfig],
|
||||
])("does not emit or expose a live identity in a normal %s build", (_runtime, createConfig) => {
|
||||
const identity = liveIdentity(createConfig({}, { mode: "production" }));
|
||||
|
||||
expect(identity).toEqual({
|
||||
definedBuildId: "",
|
||||
definedLiveFlag: "false",
|
||||
markerAssetNames: undefined,
|
||||
markerBuildId: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
const path = require("path");
|
||||
const webpack = require("webpack");
|
||||
const userscriptMeta = require("./Extensions/UserScript/userscript.meta");
|
||||
const { LiveBuildMarkerPlugin, createLiveBuildId } = require("./webpack.live-build-marker");
|
||||
|
||||
function buildUserscriptBanner(liveTestBuild) {
|
||||
const metadata = [
|
||||
"// ==UserScript==",
|
||||
`// @name ${userscriptMeta.name}${liveTestBuild ? " [Live Test]" : ""}`,
|
||||
`// @namespace ${userscriptMeta.namespace}`,
|
||||
`// @homepage ${userscriptMeta.homepage}`,
|
||||
`// @version ${userscriptMeta.version}`,
|
||||
`// @encoding ${userscriptMeta.encoding}`,
|
||||
`// @description ${userscriptMeta.description}`,
|
||||
`// @icon ${userscriptMeta.icon}`,
|
||||
`// @author ${userscriptMeta.author}`,
|
||||
...userscriptMeta.match.map((value) => `// @match ${value}`),
|
||||
...userscriptMeta.exclude.map((value) => `// @exclude ${value}`),
|
||||
...userscriptMeta.compatible.map((value) => `// @compatible ${value}`),
|
||||
...(liveTestBuild
|
||||
? []
|
||||
: [`// @downloadURL ${userscriptMeta.downloadURL}`, `// @updateURL ${userscriptMeta.updateURL}`]),
|
||||
...userscriptMeta.grants.map((value) => `// @grant ${value}`),
|
||||
`// @run-at ${userscriptMeta.runAt}`,
|
||||
"// ==/UserScript==",
|
||||
"",
|
||||
"// This file is generated by `npm run build:userscript`.",
|
||||
"// Edit Extensions/UserScript/src instead of the generated file.",
|
||||
];
|
||||
|
||||
return metadata.join("\n");
|
||||
}
|
||||
|
||||
module.exports = (env = {}, argv = {}) => {
|
||||
const liveTestBuild = env.liveTest === true || env.liveTest === "true";
|
||||
const liveBuildId = createLiveBuildId(liveTestBuild);
|
||||
|
||||
return {
|
||||
name: "userscript",
|
||||
mode: argv.mode ?? "production",
|
||||
entry: path.resolve(__dirname, "Extensions/UserScript/src/userscript-entry.js"),
|
||||
output: {
|
||||
path: liveTestBuild
|
||||
? path.resolve(__dirname, "test-results/live-build/userscript")
|
||||
: path.resolve(__dirname, "Extensions/UserScript"),
|
||||
filename: "Return Youtube Dislike.user.js",
|
||||
clean: false,
|
||||
iife: true,
|
||||
},
|
||||
cache: false,
|
||||
optimization: {
|
||||
minimize: false,
|
||||
},
|
||||
plugins: [
|
||||
new webpack.DefinePlugin({
|
||||
__RYD_LIVE_BUILD_ID__: JSON.stringify(liveBuildId),
|
||||
__RYD_LIVE_TEST_BUILD__: JSON.stringify(liveTestBuild),
|
||||
}),
|
||||
...(liveTestBuild ? [new LiveBuildMarkerPlugin(liveBuildId, ["live-build.json"])] : []),
|
||||
new webpack.BannerPlugin({
|
||||
banner: buildUserscriptBanner(liveTestBuild),
|
||||
raw: true,
|
||||
entryOnly: true,
|
||||
}),
|
||||
],
|
||||
devtool: false,
|
||||
performance: {
|
||||
hints: false,
|
||||
},
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user