mirror of
https://github.com/Anarios/return-youtube-dislike.git
synced 2026-09-12 10:22:10 +02:00
3657de9b2c
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.
238 lines
7.8 KiB
JavaScript
238 lines
7.8 KiB
JavaScript
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,
|
|
};
|