mirror of
https://github.com/Alvin-Zilverstand/return-youtube-dislike.git
synced 2026-10-28 07:40:27 +01: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:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user