mirror of
https://github.com/Alvin-Zilverstand/return-youtube-dislike.git
synced 2026-10-28 07:40:27 +01:00
e500088e65
Add Firefox data consent, repair contributor sign-in, and introduce optional Hide clutter buttons. Harden video navigation, voting, and action layouts with shared browser regression coverage and reproducible release packaging.
4655 lines
173 KiB
JavaScript
4655 lines
173 KiB
JavaScript
/**
|
|
* @jest-environment jsdom
|
|
*/
|
|
|
|
const {
|
|
AttributedRuntimeTrafficLedger,
|
|
LiveYoutubeDriver,
|
|
VoteTrafficRecorder,
|
|
assertDislikeCountChangesObservable,
|
|
assertExactVotesRequestAudit,
|
|
assertExactVotesResponseAudit,
|
|
assertWorkerRequestAttributionIsUnambiguous,
|
|
assertElementActionable,
|
|
assertElementReadyForViewportMeasurement,
|
|
assertNativeShortsPairGeometry,
|
|
assertReactionPressedStates,
|
|
assertRenderedDislikeCountMatchesApi,
|
|
assertShortsActionStackGeometry,
|
|
assertSyntheticShortsGeometry,
|
|
assertWatchActionTopologySnapshot,
|
|
assertWatchRatioViewportAlignment,
|
|
assertWatchRatioSurroundings,
|
|
assertNavigationLinkActionable,
|
|
attributeRuntimeRequest,
|
|
channelTabUrl,
|
|
clickHitTestedElement,
|
|
clickHitTestedNavigationLink,
|
|
clickStabilizedExactVideoLink,
|
|
clickWithSingleNavigationRetry,
|
|
croppedScreenshotClip,
|
|
firstVisibleRelatedWatchLink,
|
|
formattedDislikeCountCandidates,
|
|
isShortCandidateEligible,
|
|
isShortsIconVisualReady,
|
|
isExactVideoUrl,
|
|
parseLiveExtensionAcceptHeader,
|
|
prepareElementForViewportMeasurement,
|
|
readCurrentShortsNativeControlState,
|
|
readDislikeControlText,
|
|
readElementActionability,
|
|
readNavigationLinkActionability,
|
|
readShortsReelDiagnosticState,
|
|
readShortsRendererIdentityState,
|
|
scrollElementIntoViewAndWaitForPaint,
|
|
readWatchRatioAppearance,
|
|
readWatchRatioSurroundings,
|
|
readWatchTopRowTopology,
|
|
relatedWatchVideoId,
|
|
} = require("../e2e/live/live-youtube-driver");
|
|
|
|
const SELECTED_EXTENSION_ID = "abcdefghijklmnopabcdefghijklmnop";
|
|
const COMPETING_EXTENSION_ID = "pppppppppppppppppppppppppppppppp";
|
|
const EXPECTED_BUILD_ID = "0123456789abcdef0123456789abcdef";
|
|
const LIVE_ACCEPT_HEADER = `application/json, application/vnd.ryd-live+json; id=${SELECTED_EXTENSION_ID}; build=${EXPECTED_BUILD_ID}`;
|
|
|
|
const VISIBLE_RECT = {
|
|
bottom: 200,
|
|
height: 100,
|
|
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),
|
|
},
|
|
nativeActionHosts: [box(100, 10, 48, 78), box(100, 166, 48, 78)],
|
|
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 widenShortsControlForLocalizedCount(control, { countWidth = 41, shellWidth = 52 } = {}) {
|
|
const center = control.button.x + control.button.width / 2;
|
|
control.host.x = center - shellWidth / 2;
|
|
control.host.width = shellWidth;
|
|
control.label.x = center - shellWidth / 2;
|
|
control.label.width = shellWidth;
|
|
control.count.x = center - countWidth / 2;
|
|
control.count.width = countWidth;
|
|
return control;
|
|
}
|
|
|
|
function widenShortsButtonAroundCenter(control, width) {
|
|
const center = control.button.x + control.button.width / 2;
|
|
control.button.x = center - width / 2;
|
|
control.button.width = width;
|
|
return control;
|
|
}
|
|
|
|
function validNativeShortsPairGeometry() {
|
|
const syntheticGeometry = validSyntheticShortsGeometry();
|
|
const enrich = (geometry, actionIndex) => ({
|
|
...geometry,
|
|
actionIndex,
|
|
reelIndex: 2,
|
|
videoMatches: true,
|
|
});
|
|
return {
|
|
dislike: enrich(syntheticGeometry.synthetic, 4),
|
|
like: enrich(syntheticGeometry.like, 3),
|
|
};
|
|
}
|
|
|
|
function renderShort({ canonicalHref, descriptionHref, href, rendererVideoId } = {}) {
|
|
document.body.innerHTML = `
|
|
<ytd-reel-video-renderer${rendererVideoId ? ` video-id="${rendererVideoId}"` : ""}>
|
|
${href ? `<a href="${href}"></a>` : ""}
|
|
${canonicalHref ? `<a class="ytp-title-link" href="${canonicalHref}"></a>` : ""}
|
|
${descriptionHref ? `<div id="description"><a href="${descriptionHref}"></a></div>` : ""}
|
|
<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([
|
|
["extension", "4.0.4"],
|
|
["userscript", "3.2.0"],
|
|
])(
|
|
"rejects an absent %s runtime instead of treating an unmodified YouTube page as a pass",
|
|
async (runtime, version) => {
|
|
const driver = createDriver({
|
|
extension: null,
|
|
extensionBuild: null,
|
|
userscript: null,
|
|
userscriptBuild: null,
|
|
});
|
|
|
|
await expect(driver.assertRuntime(runtime, version, buildId)).rejects.toThrow(
|
|
`Expected ${runtime} version ${version} to be active`,
|
|
);
|
|
},
|
|
);
|
|
|
|
test.each([undefined, "", "stale", "A".repeat(32)])(
|
|
"refuses to test without a valid exact build ID: %p",
|
|
async (expectedBuildId) => {
|
|
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("uses the canonical player link instead of a description cross-link for reel ownership", () => {
|
|
const { button } = renderShort({
|
|
canonicalHref: "/shorts/canonical01",
|
|
descriptionHref: "/shorts/crosslink01",
|
|
});
|
|
|
|
expect(isShortCandidateEligible(button, activeShortSettings("canonical01"))).toBe(true);
|
|
expect(isShortCandidateEligible(button, activeShortSettings("crosslink01"))).toBe(false);
|
|
expect(readShortsRendererIdentityState(button, { expectedShortVideoId: "crosslink01" })).toMatchObject({
|
|
candidateVideoIds: ["canonical01"],
|
|
videoMatches: false,
|
|
});
|
|
});
|
|
|
|
test("keeps conflicting renderer and canonical identities ineligible", () => {
|
|
const { button } = renderShort({ canonicalHref: "/shorts/canonical01", rendererVideoId: "attribute01" });
|
|
|
|
expect(isShortCandidateEligible(button, activeShortSettings("canonical01"))).toBe(false);
|
|
expect(isShortCandidateEligible(button, activeShortSettings("attribute01"))).toBe(false);
|
|
expect(readShortsRendererIdentityState(button, { expectedShortVideoId: "attribute01" })).toMatchObject({
|
|
candidateVideoIds: ["attribute01", "canonical01"],
|
|
videoMatches: false,
|
|
});
|
|
});
|
|
|
|
test("rejects a matching descendant link when the renderer video-id identifies another Short", () => {
|
|
const { button } = renderShort({ href: "/shorts/current-video", rendererVideoId: "outgoing-video" });
|
|
|
|
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);
|
|
});
|
|
|
|
test.each([
|
|
["zero-opacity", (reel) => (reel.style.opacity = "0")],
|
|
["aria-hidden", (reel) => reel.setAttribute("aria-hidden", "true")],
|
|
])("rejects a geometrically visible control inside a %s stale renderer", (_label, hideRenderer) => {
|
|
const { button, reel } = renderShort({ rendererVideoId: "current-video" });
|
|
hideRenderer(reel);
|
|
|
|
expect(isShortCandidateEligible(button, activeShortSettings("current-video"))).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("readCurrentShortsNativeControlState", () => {
|
|
function renderRail({
|
|
nativeControlCount = 0,
|
|
outsideActionCount = 0,
|
|
playerChromeLabels = [],
|
|
rendererVideoId = null,
|
|
synthetic = false,
|
|
} = {}) {
|
|
const nativeControls = Array.from({ length: nativeControlCount }, (_, index) => {
|
|
const host = index === 0 ? "like-button-view-model" : "button-view-model";
|
|
return `<${host}><button type="button" aria-label="Native ${index + 1}"></button></${host}>`;
|
|
}).join("");
|
|
const outsideActions = Array.from({ length: outsideActionCount }, (_, index) => {
|
|
const host = index === 0 ? "like-button-view-model" : "button-view-model";
|
|
return `<${host}><button aria-label="Future ${index + 1}"></button></${host}>`;
|
|
}).join("");
|
|
const playerChrome = playerChromeLabels
|
|
.map((label) => `<button class="ytp-button" aria-label="${label}"></button>`)
|
|
.join("");
|
|
document.body.innerHTML = `
|
|
<ytd-reel-video-renderer${rendererVideoId ? ` video-id="${rendererVideoId}"` : ""}>
|
|
<a id="canonical" class="ytp-title-link" href="/shorts/abcdefghijk"></a>
|
|
<div id="description"><a href="/shorts/unrelated01">related Short</a></div>
|
|
<div class="html5-video-player"><video></video>${playerChrome}</div>
|
|
<reel-action-bar-view-model>
|
|
${nativeControls}
|
|
${
|
|
synthetic
|
|
? '<div data-ryd-synthetic-shorts-dislike><button type="button" aria-label="Dislike"></button></div>'
|
|
: ""
|
|
}
|
|
</reel-action-bar-view-model>
|
|
<future-action-rail>${outsideActions}</future-action-rail>
|
|
</ytd-reel-video-renderer>
|
|
`;
|
|
for (const element of document.querySelectorAll(
|
|
"ytd-reel-video-renderer, reel-action-bar-view-model, video, button",
|
|
)) {
|
|
element.getBoundingClientRect = jest.fn(() => VISIBLE_RECT);
|
|
}
|
|
}
|
|
|
|
const readState = () =>
|
|
readCurrentShortsNativeControlState({
|
|
actionBarSelector: "reel-action-bar-view-model, .slim-video-action-bar-actions",
|
|
syntheticSelector: "[data-ryd-synthetic-shorts-dislike]",
|
|
videoId: "abcdefghijk",
|
|
});
|
|
|
|
test("classifies a current renderer with no YouTube actions as fully blank", () => {
|
|
renderRail({ playerChromeLabels: ["Pause", "Mute", "More"] });
|
|
|
|
expect(readState()).toMatchObject({
|
|
completeIndependentActionBars: 0,
|
|
completeNativeActionBars: 0,
|
|
independentActionLabels: [],
|
|
matchingCompleteIndependentActionBars: 0,
|
|
matchingCompleteNativeActionBars: 0,
|
|
matchingRenderedReels: 1,
|
|
matchingVisibleIndependentActions: 0,
|
|
matchingVisibleNativeControls: 0,
|
|
renderedReels: 1,
|
|
visibleIndependentActions: 0,
|
|
visibleNativeControls: 0,
|
|
});
|
|
});
|
|
|
|
test("counts native actions but excludes the runtime's synthetic Dislike", () => {
|
|
renderRail({ nativeControlCount: 5, synthetic: true });
|
|
|
|
expect(readState()).toMatchObject({
|
|
completeNativeActionBars: 1,
|
|
labels: ["Native 1", "Native 2", "Native 3", "Native 4", "Native 5"],
|
|
matchingCompleteNativeActionBars: 1,
|
|
matchingRenderedReels: 1,
|
|
matchingVisibleNativeControls: 5,
|
|
visibleNativeControls: 5,
|
|
});
|
|
});
|
|
|
|
test("classifies visible action buttons individually when their action-bar host has no rendered box", () => {
|
|
renderRail({ nativeControlCount: 4 });
|
|
document.querySelector("reel-action-bar-view-model").getBoundingClientRect = jest.fn(() => box(100, 100, 0, 0));
|
|
|
|
expect(readState()).toMatchObject({
|
|
completeNativeActionBars: 1,
|
|
matchingCompleteNativeActionBars: 1,
|
|
matchingVisibleNativeControls: 4,
|
|
visibleNativeControls: 4,
|
|
});
|
|
});
|
|
|
|
test("does not call a page blank when another visible reel has meaningful native controls", () => {
|
|
renderRail({ nativeControlCount: 3, rendererVideoId: "different01" });
|
|
|
|
expect(readState()).toMatchObject({
|
|
matchingRenderedReels: 0,
|
|
matchingVisibleNativeControls: 0,
|
|
visibleNativeControls: 3,
|
|
});
|
|
});
|
|
|
|
test("uses the canonical player identity instead of a description cross-link", () => {
|
|
renderRail({ nativeControlCount: 3 });
|
|
|
|
const state = readCurrentShortsNativeControlState({
|
|
actionBarSelector: "reel-action-bar-view-model, .slim-video-action-bar-actions",
|
|
syntheticSelector: "[data-ryd-synthetic-shorts-dislike]",
|
|
videoId: "unrelated01",
|
|
});
|
|
expect(state).toMatchObject({
|
|
matchingRenderedReels: 0,
|
|
matchingVisibleNativeControls: 0,
|
|
rendererIdentities: [["abcdefghijk"]],
|
|
visibleNativeControls: 3,
|
|
});
|
|
});
|
|
|
|
test("returns the visible video box only from the exact current reel and ignores retained or detached videos", () => {
|
|
document.body.innerHTML = `
|
|
<ytd-reel-video-renderer video-id="outgoing01"><video id="retained"></video></ytd-reel-video-renderer>
|
|
<ytd-reel-video-renderer video-id="abcdefghijk"><video id="current"></video></ytd-reel-video-renderer>`;
|
|
const detached = document.createElement("video");
|
|
document.body.append(detached);
|
|
detached.remove();
|
|
document.querySelectorAll("ytd-reel-video-renderer").forEach((reel) => {
|
|
reel.getBoundingClientRect = jest.fn(() => ({
|
|
bottom: 700,
|
|
height: 620,
|
|
left: 0,
|
|
right: 1024,
|
|
top: 80,
|
|
width: 1024,
|
|
x: 0,
|
|
y: 80,
|
|
}));
|
|
});
|
|
document.querySelector("#retained").getBoundingClientRect = jest.fn(() => ({
|
|
bottom: 768,
|
|
height: 768,
|
|
left: 0,
|
|
right: 1024,
|
|
top: 0,
|
|
width: 1024,
|
|
x: 0,
|
|
y: 0,
|
|
}));
|
|
document.querySelector("#current").getBoundingClientRect = jest.fn(() => ({
|
|
bottom: 640,
|
|
height: 560,
|
|
left: 300,
|
|
right: 700,
|
|
top: 80,
|
|
width: 400,
|
|
x: 300,
|
|
y: 80,
|
|
}));
|
|
|
|
expect(readState()).toMatchObject({
|
|
currentVideoBox: { height: 560, width: 400, x: 300, y: 80 },
|
|
matchingRenderedReels: 1,
|
|
viewport: { height: window.innerHeight, width: window.innerWidth },
|
|
});
|
|
});
|
|
|
|
test("detects visible controls outside known action-bar selectors through the independent fallback", () => {
|
|
renderRail({ outsideActionCount: 2 });
|
|
|
|
expect(readState()).toMatchObject({
|
|
independentActionLabels: ["Future 1", "Future 2"],
|
|
matchingVisibleIndependentActions: 2,
|
|
matchingVisibleNativeControls: 0,
|
|
visibleIndependentActions: 2,
|
|
visibleNativeControls: 0,
|
|
});
|
|
});
|
|
|
|
test("recognizes a complete structural fallback rail after the known selector changes", () => {
|
|
renderRail({ outsideActionCount: 4 });
|
|
|
|
expect(readState()).toMatchObject({
|
|
completeIndependentActionBars: 1,
|
|
matchingCompleteIndependentActionBars: 1,
|
|
matchingVisibleIndependentActions: 4,
|
|
visibleIndependentActions: 4,
|
|
});
|
|
});
|
|
|
|
test("captures selector-independent action details for blank-sample diagnostics", () => {
|
|
renderRail({ outsideActionCount: 1 });
|
|
const diagnostic = readShortsReelDiagnosticState(document.querySelector("ytd-reel-video-renderer"), {
|
|
actionBarSelector: "reel-action-bar-view-model, .slim-video-action-bar-actions",
|
|
syntheticSelector: "[data-ryd-synthetic-shorts-dislike]",
|
|
});
|
|
|
|
expect(diagnostic).toMatchObject({
|
|
actionBars: [expect.objectContaining({ tagName: "reel-action-bar-view-model" })],
|
|
renderedInViewport: true,
|
|
totalInteractiveCount: 1,
|
|
visibleActions: [
|
|
expect.objectContaining({
|
|
ariaLabel: "Future 1",
|
|
renderedInViewport: true,
|
|
synthetic: false,
|
|
tagName: "button",
|
|
}),
|
|
],
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("Shorts native-control wait classification", () => {
|
|
function nativeRailMeasurement({ currentVideoBox = null, visible = false } = {}) {
|
|
return {
|
|
completeIndependentActionBars: 0,
|
|
completeNativeActionBars: visible ? 1 : 0,
|
|
currentVideoBox,
|
|
currentVideoId: "abcdefghijk",
|
|
independentActionLabels: [],
|
|
labels: visible ? ["Like", "Comments", "Share", "Remix"] : [],
|
|
matchingCompleteIndependentActionBars: 0,
|
|
matchingCompleteNativeActionBars: visible ? 1 : 0,
|
|
matchingRenderedReels: 1,
|
|
matchingVisibleIndependentActions: 0,
|
|
matchingVisibleNativeControls: visible ? 4 : 0,
|
|
renderedReels: 1,
|
|
viewport: { height: 768, width: 1024 },
|
|
visibleIndependentActions: 0,
|
|
visibleNativeControls: visible ? 4 : 0,
|
|
};
|
|
}
|
|
|
|
test("treats a reel with only Pause, Mute, and player More controls as a blank sample", async () => {
|
|
document.body.innerHTML = `
|
|
<ytd-reel-video-renderer video-id="abcdefghijk">
|
|
<div class="html5-video-player">
|
|
<button class="ytp-button" aria-label="Pause"></button>
|
|
<button class="ytp-button" aria-label="Mute"></button>
|
|
<button class="ytp-button" aria-label="More"></button>
|
|
</div>
|
|
<reel-action-bar-view-model></reel-action-bar-view-model>
|
|
</ytd-reel-video-renderer>`;
|
|
for (const element of document.querySelectorAll("ytd-reel-video-renderer, reel-action-bar-view-model, button")) {
|
|
element.getBoundingClientRect = jest.fn(() => VISIBLE_RECT);
|
|
}
|
|
const page = {
|
|
evaluate: jest.fn(async (reader, settings) => reader(settings)),
|
|
setDefaultNavigationTimeout: jest.fn(),
|
|
setDefaultTimeout: jest.fn(),
|
|
url: () => "https://www.youtube.com/shorts/abcdefghijk",
|
|
};
|
|
const driver = new LiveYoutubeDriver(page, {});
|
|
|
|
await expect(
|
|
driver.waitForCurrentShortsNativeControls("abcdefghijk", { intervalMs: 1, timeoutMs: 0 }),
|
|
).resolves.toMatchObject({
|
|
independentActionLabels: [],
|
|
reason: "no-visible-native-shorts-actions",
|
|
status: "blank",
|
|
visibleIndependentActions: 0,
|
|
visibleNativeControls: 0,
|
|
});
|
|
});
|
|
|
|
test.each([
|
|
[
|
|
"blank",
|
|
{ matchingVisibleNativeControls: 0, visibleNativeControls: 0 },
|
|
"blank",
|
|
"no-visible-native-shorts-actions",
|
|
],
|
|
[
|
|
"unowned native controls",
|
|
{ matchingVisibleNativeControls: 0, visibleNativeControls: 3 },
|
|
"present",
|
|
"visible-native-shorts-actions-not-owned-by-current-video",
|
|
],
|
|
])("classifies %s after the bounded observation window", async (_label, measurement, status, reason) => {
|
|
const page = {
|
|
evaluate: jest.fn(async () => ({
|
|
completeIndependentActionBars: 0,
|
|
completeNativeActionBars: 0,
|
|
currentVideoId: "abcdefghijk",
|
|
independentActionLabels: [],
|
|
labels: [],
|
|
matchingCompleteIndependentActionBars: 0,
|
|
matchingCompleteNativeActionBars: 0,
|
|
matchingVisibleIndependentActions: 0,
|
|
matchingRenderedReels: 0,
|
|
renderedReels: 1,
|
|
visibleIndependentActions: 0,
|
|
...measurement,
|
|
})),
|
|
setDefaultNavigationTimeout: jest.fn(),
|
|
setDefaultTimeout: jest.fn(),
|
|
url: () => "https://www.youtube.com/shorts/abcdefghijk",
|
|
};
|
|
const reportProgress = jest.fn();
|
|
const driver = new LiveYoutubeDriver(page, {}, { reportProgress });
|
|
|
|
await expect(
|
|
driver.waitForCurrentShortsNativeControls("abcdefghijk", { intervalMs: 1, timeoutMs: 0 }),
|
|
).resolves.toMatchObject({ reason, status, ...measurement });
|
|
expect(reportProgress).toHaveBeenCalledWith(
|
|
status === "blank" ? "shorts-native-controls.blank" : "shorts-native-controls.present-unowned",
|
|
expect.objectContaining({ reason, status }),
|
|
);
|
|
});
|
|
|
|
test("returns immediately when a complete current-video native action bar appears", async () => {
|
|
const page = {
|
|
evaluate: jest.fn(async () => ({
|
|
completeIndependentActionBars: 0,
|
|
completeNativeActionBars: 1,
|
|
currentVideoId: "abcdefghijk",
|
|
independentActionLabels: [],
|
|
labels: ["Like", "Comments", "Share", "Remix"],
|
|
matchingCompleteIndependentActionBars: 0,
|
|
matchingCompleteNativeActionBars: 1,
|
|
matchingVisibleIndependentActions: 0,
|
|
matchingRenderedReels: 1,
|
|
matchingVisibleNativeControls: 4,
|
|
renderedReels: 1,
|
|
visibleIndependentActions: 0,
|
|
visibleNativeControls: 4,
|
|
})),
|
|
setDefaultNavigationTimeout: jest.fn(),
|
|
setDefaultTimeout: jest.fn(),
|
|
url: () => "https://www.youtube.com/shorts/abcdefghijk",
|
|
};
|
|
const driver = new LiveYoutubeDriver(page, {});
|
|
|
|
await expect(
|
|
driver.waitForCurrentShortsNativeControls("abcdefghijk", { timeoutMs: 20_000 }),
|
|
).resolves.toMatchObject({
|
|
matchingCompleteNativeActionBars: 1,
|
|
matchingVisibleNativeControls: 4,
|
|
status: "present",
|
|
});
|
|
expect(page.evaluate).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
test("reports any meaningful current native action immediately so a missing runtime control cannot be skipped", async () => {
|
|
const partial = {
|
|
completeIndependentActionBars: 0,
|
|
completeNativeActionBars: 0,
|
|
currentVideoId: "abcdefghijk",
|
|
independentActionLabels: [],
|
|
labels: ["Like", "Comments"],
|
|
matchingCompleteIndependentActionBars: 0,
|
|
matchingCompleteNativeActionBars: 0,
|
|
matchingRenderedReels: 1,
|
|
matchingVisibleIndependentActions: 0,
|
|
matchingVisibleNativeControls: 2,
|
|
renderedReels: 1,
|
|
visibleIndependentActions: 0,
|
|
visibleNativeControls: 2,
|
|
};
|
|
const page = {
|
|
evaluate: jest
|
|
.fn()
|
|
.mockResolvedValueOnce(partial)
|
|
.mockResolvedValueOnce({
|
|
...partial,
|
|
completeNativeActionBars: 1,
|
|
labels: ["Like", "Comments", "Share", "Remix"],
|
|
matchingCompleteNativeActionBars: 1,
|
|
matchingVisibleNativeControls: 4,
|
|
visibleNativeControls: 4,
|
|
}),
|
|
setDefaultNavigationTimeout: jest.fn(),
|
|
setDefaultTimeout: jest.fn(),
|
|
url: () => "https://www.youtube.com/shorts/abcdefghijk",
|
|
};
|
|
const driver = new LiveYoutubeDriver(page, {});
|
|
|
|
await expect(
|
|
driver.waitForCurrentShortsNativeControls("abcdefghijk", { intervalMs: 1, timeoutMs: 100 }),
|
|
).resolves.toMatchObject({
|
|
matchingCompleteNativeActionBars: 0,
|
|
matchingVisibleNativeControls: 2,
|
|
status: "present",
|
|
});
|
|
expect(page.evaluate).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
test("does not classify a partial visible current action bar as blank at the observation boundary", async () => {
|
|
const measurement = {
|
|
completeIndependentActionBars: 0,
|
|
completeNativeActionBars: 0,
|
|
currentVideoId: "abcdefghijk",
|
|
independentActionLabels: [],
|
|
labels: ["Like", "Comments"],
|
|
matchingCompleteIndependentActionBars: 0,
|
|
matchingCompleteNativeActionBars: 0,
|
|
matchingRenderedReels: 1,
|
|
matchingVisibleIndependentActions: 0,
|
|
matchingVisibleNativeControls: 2,
|
|
renderedReels: 1,
|
|
visibleIndependentActions: 0,
|
|
visibleNativeControls: 2,
|
|
};
|
|
const page = {
|
|
evaluate: jest.fn(async () => measurement),
|
|
setDefaultNavigationTimeout: jest.fn(),
|
|
setDefaultTimeout: jest.fn(),
|
|
url: () => "https://www.youtube.com/shorts/abcdefghijk",
|
|
};
|
|
const reportProgress = jest.fn();
|
|
const driver = new LiveYoutubeDriver(page, {}, { reportProgress });
|
|
|
|
await expect(
|
|
driver.waitForCurrentShortsNativeControls("abcdefghijk", { intervalMs: 1, timeoutMs: 0 }),
|
|
).resolves.toMatchObject({
|
|
matchingCompleteNativeActionBars: 0,
|
|
matchingVisibleNativeControls: 2,
|
|
status: "present",
|
|
});
|
|
expect(reportProgress).toHaveBeenCalledWith(
|
|
"shorts-native-controls.present",
|
|
expect.objectContaining({ matchingVisibleNativeControls: 2, status: "present" }),
|
|
);
|
|
});
|
|
|
|
test("uses real pointer movement over the current video to reveal an idle native action rail", async () => {
|
|
let nativeRailRevealed = false;
|
|
const page = {
|
|
evaluate: jest.fn(async (reader) => {
|
|
expect(reader).toBe(readCurrentShortsNativeControlState);
|
|
return nativeRailMeasurement({
|
|
currentVideoBox: box(300, 80, 400, 560),
|
|
visible: nativeRailRevealed,
|
|
});
|
|
}),
|
|
locator: jest.fn(() => {
|
|
throw new Error("The native-control wait must not scan retained video locators.");
|
|
}),
|
|
mouse: {
|
|
move: jest.fn(async () => {
|
|
nativeRailRevealed = true;
|
|
}),
|
|
},
|
|
setDefaultNavigationTimeout: jest.fn(),
|
|
setDefaultTimeout: jest.fn(),
|
|
url: () => "https://www.youtube.com/shorts/abcdefghijk",
|
|
};
|
|
const driver = new LiveYoutubeDriver(page, {});
|
|
|
|
await expect(
|
|
driver.waitForCurrentShortsNativeControls("abcdefghijk", { intervalMs: 1, timeoutMs: 50 }),
|
|
).resolves.toMatchObject({
|
|
lastNativeUiWakePoint: { x: 499, y: 360 },
|
|
matchingVisibleNativeControls: 4,
|
|
nativeUiWakeAttempts: 1,
|
|
status: "present",
|
|
});
|
|
expect(page.mouse.move).toHaveBeenCalledWith(499, 360);
|
|
expect(page.locator).not.toHaveBeenCalled();
|
|
expect(page.evaluate).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
test("does not depend on animation-frame delivery when pointer movement reveals the rail", async () => {
|
|
let nativeRailRevealed = false;
|
|
const page = {
|
|
evaluate: jest.fn(async (reader) => {
|
|
expect(reader).toBe(readCurrentShortsNativeControlState);
|
|
return nativeRailMeasurement({
|
|
currentVideoBox: box(500, 80, 400, 560),
|
|
visible: nativeRailRevealed,
|
|
});
|
|
}),
|
|
mouse: {
|
|
move: jest.fn(async () => {
|
|
nativeRailRevealed = true;
|
|
}),
|
|
},
|
|
setDefaultNavigationTimeout: jest.fn(),
|
|
setDefaultTimeout: jest.fn(),
|
|
url: () => "https://www.youtube.com/shorts/abcdefghijk",
|
|
};
|
|
const driver = new LiveYoutubeDriver(page, {});
|
|
|
|
await expect(
|
|
driver.waitForCurrentShortsNativeControls("abcdefghijk", { intervalMs: 1, timeoutMs: 50 }),
|
|
).resolves.toMatchObject({ status: "present" });
|
|
|
|
expect(page.mouse.move).toHaveBeenCalledWith(699, 360);
|
|
expect(page.evaluate).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
test("timeout zero performs exactly one measurement without a pointer pulse or delay", async () => {
|
|
const page = {
|
|
evaluate: jest.fn(async () => nativeRailMeasurement({ currentVideoBox: box(400, 80, 400, 560) })),
|
|
mouse: { move: jest.fn(async () => undefined) },
|
|
setDefaultNavigationTimeout: jest.fn(),
|
|
setDefaultTimeout: jest.fn(),
|
|
url: () => "https://www.youtube.com/shorts/abcdefghijk",
|
|
};
|
|
const driver = new LiveYoutubeDriver(page, {});
|
|
|
|
await expect(
|
|
driver.waitForCurrentShortsNativeControls("abcdefghijk", { intervalMs: 1, timeoutMs: 0 }),
|
|
).resolves.toMatchObject({ nativeUiWakeAttempts: 0, status: "blank" });
|
|
|
|
expect(page.evaluate).toHaveBeenCalledTimes(1);
|
|
expect(page.mouse.move).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("confirms a truly blank rail within the bounded observation and operation-watchdog window", async () => {
|
|
const page = {
|
|
evaluate: jest.fn(async () => nativeRailMeasurement()),
|
|
setDefaultNavigationTimeout: jest.fn(),
|
|
setDefaultTimeout: jest.fn(),
|
|
url: () => "https://www.youtube.com/shorts/abcdefghijk",
|
|
};
|
|
const driver = new LiveYoutubeDriver(page, {});
|
|
const startedAt = Date.now();
|
|
|
|
const result = await driver.waitForCurrentShortsNativeControls("abcdefghijk", {
|
|
intervalMs: 5,
|
|
operationTimeoutMs: 100,
|
|
timeoutMs: 25,
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
nativeUiWakeAttempts: 0,
|
|
reason: "no-visible-native-shorts-actions",
|
|
status: "blank",
|
|
});
|
|
expect(result.observedForMs).toBeGreaterThanOrEqual(25);
|
|
expect(Date.now() - startedAt).toBeLessThan(250);
|
|
expect(page.evaluate.mock.calls.length).toBeGreaterThanOrEqual(2);
|
|
});
|
|
|
|
test("accepts controls discovered by the final measurement at the observation boundary", async () => {
|
|
const page = {
|
|
evaluate: jest
|
|
.fn()
|
|
.mockResolvedValueOnce(nativeRailMeasurement())
|
|
.mockResolvedValueOnce(nativeRailMeasurement({ visible: true })),
|
|
setDefaultNavigationTimeout: jest.fn(),
|
|
setDefaultTimeout: jest.fn(),
|
|
url: () => "https://www.youtube.com/shorts/abcdefghijk",
|
|
};
|
|
const driver = new LiveYoutubeDriver(page, {});
|
|
|
|
await expect(
|
|
driver.waitForCurrentShortsNativeControls("abcdefghijk", {
|
|
intervalMs: 20,
|
|
operationTimeoutMs: 100,
|
|
timeoutMs: 20,
|
|
}),
|
|
).resolves.toMatchObject({ matchingVisibleNativeControls: 4, status: "present" });
|
|
expect(page.evaluate).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
test("fails instead of recording blank evidence when the synchronous browser probe stalls", async () => {
|
|
const page = {
|
|
evaluate: jest.fn(() => new Promise(() => {})),
|
|
setDefaultNavigationTimeout: jest.fn(),
|
|
setDefaultTimeout: jest.fn(),
|
|
url: () => "https://www.youtube.com/shorts/abcdefghijk",
|
|
};
|
|
const driver = new LiveYoutubeDriver(page, {});
|
|
|
|
await expect(
|
|
driver.waitForCurrentShortsNativeControls("abcdefghijk", {
|
|
intervalMs: 1,
|
|
operationTimeoutMs: 10,
|
|
timeoutMs: 25,
|
|
}),
|
|
).rejects.toThrow(
|
|
"Timed out probing Shorts native controls for abcdefghijk; the 25ms observation window is invalid.",
|
|
);
|
|
});
|
|
|
|
test("fails instead of overrunning the observation deadline when a pointer pulse stalls", async () => {
|
|
const page = {
|
|
evaluate: jest.fn(async () => nativeRailMeasurement({ currentVideoBox: box(300, 80, 400, 560) })),
|
|
mouse: { move: jest.fn(() => new Promise(() => {})) },
|
|
setDefaultNavigationTimeout: jest.fn(),
|
|
setDefaultTimeout: jest.fn(),
|
|
url: () => "https://www.youtube.com/shorts/abcdefghijk",
|
|
};
|
|
const driver = new LiveYoutubeDriver(page, {});
|
|
|
|
await expect(
|
|
driver.waitForCurrentShortsNativeControls("abcdefghijk", {
|
|
intervalMs: 1,
|
|
operationTimeoutMs: 10,
|
|
timeoutMs: 25,
|
|
}),
|
|
).rejects.toThrow(
|
|
"Timed out probing Shorts native controls for abcdefghijk; the 25ms observation window is invalid.",
|
|
);
|
|
expect(page.evaluate).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
test("returns present when a complete structural fallback rail replaces the known action-bar selector", async () => {
|
|
const page = {
|
|
evaluate: jest.fn(async () => ({
|
|
completeIndependentActionBars: 1,
|
|
completeNativeActionBars: 0,
|
|
currentVideoId: "abcdefghijk",
|
|
independentActionLabels: ["Like", "Comments", "Share", "Remix"],
|
|
labels: [],
|
|
matchingCompleteIndependentActionBars: 1,
|
|
matchingCompleteNativeActionBars: 0,
|
|
matchingRenderedReels: 1,
|
|
matchingVisibleIndependentActions: 4,
|
|
matchingVisibleNativeControls: 0,
|
|
renderedReels: 1,
|
|
visibleIndependentActions: 4,
|
|
visibleNativeControls: 0,
|
|
})),
|
|
setDefaultNavigationTimeout: jest.fn(),
|
|
setDefaultTimeout: jest.fn(),
|
|
url: () => "https://www.youtube.com/shorts/abcdefghijk",
|
|
};
|
|
const driver = new LiveYoutubeDriver(page, {});
|
|
|
|
await expect(
|
|
driver.waitForCurrentShortsNativeControls("abcdefghijk", { timeoutMs: 20_000 }),
|
|
).resolves.toMatchObject({
|
|
matchingCompleteIndependentActionBars: 1,
|
|
matchingVisibleIndependentActions: 4,
|
|
status: "present",
|
|
});
|
|
expect(page.evaluate).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
describe("blank Shorts diagnostic evidence", () => {
|
|
test("captures a viewport screenshot and a structured identity/action inventory", async () => {
|
|
const identity = {
|
|
candidateVideoIds: ["abcdefghijk"],
|
|
elementRenderedInViewport: true,
|
|
hasReel: true,
|
|
reelRenderedInViewport: true,
|
|
videoMatches: true,
|
|
};
|
|
const dom = {
|
|
actionBars: [],
|
|
renderedInViewport: true,
|
|
totalInteractiveCount: 0,
|
|
visibleActions: [],
|
|
};
|
|
const reel = {
|
|
evaluate: jest.fn(async (reader) => (reader === readShortsRendererIdentityState ? identity : dom)),
|
|
};
|
|
const page = {
|
|
evaluate: jest.fn(async () => undefined),
|
|
locator: jest.fn(() => ({ count: jest.fn(async () => 1), nth: jest.fn(() => reel) })),
|
|
screenshot: jest.fn(async () => undefined),
|
|
setDefaultNavigationTimeout: jest.fn(),
|
|
setDefaultTimeout: jest.fn(),
|
|
url: () => "https://www.youtube.com/shorts/abcdefghijk",
|
|
};
|
|
const reportProgress = jest.fn();
|
|
const driver = new LiveYoutubeDriver(page, {}, { reportProgress });
|
|
driver.readViewportSize = jest.fn(async () => ({ height: 768, width: 1024 }));
|
|
driver.waitForCurrentShortsNativeControls = jest.fn(async () => ({
|
|
currentVideoId: "abcdefghijk",
|
|
observedForMs: 0,
|
|
reason: "no-visible-native-shorts-actions",
|
|
status: "blank",
|
|
visibleNativeControls: 0,
|
|
}));
|
|
|
|
const evidence = await driver.captureBlankShortsDiagnostics("abcdefghijk", "blank.png");
|
|
|
|
expect(evidence).toMatchObject({
|
|
currentUrl: "https://www.youtube.com/shorts/abcdefghijk",
|
|
expectedVideoId: "abcdefghijk",
|
|
nativeControlsAfterEvidence: expect.objectContaining({ status: "blank" }),
|
|
reels: [{ dom, identity, index: 0 }],
|
|
screenshotPath: "blank.png",
|
|
viewport: { height: 768, width: 1024 },
|
|
});
|
|
expect(evidence.capturedAt).toEqual(expect.any(String));
|
|
expect(page.screenshot).toHaveBeenCalledWith({
|
|
animations: "disabled",
|
|
caret: "hide",
|
|
path: "blank.png",
|
|
timeout: expect.any(Number),
|
|
});
|
|
const screenshotTimeout = page.screenshot.mock.calls[0][0].timeout;
|
|
expect(screenshotTimeout).toBeGreaterThan(0);
|
|
expect(screenshotTimeout).toBeLessThanOrEqual(2_000);
|
|
expect(reportProgress).toHaveBeenCalledWith("shorts-blank-evidence.captured", {
|
|
renderedReels: 1,
|
|
screenshotPath: "blank.png",
|
|
statusAfterEvidence: "blank",
|
|
videoId: "abcdefghijk",
|
|
visibleActions: 0,
|
|
});
|
|
});
|
|
|
|
test("fails quickly when a blank-evidence screenshot stalls", async () => {
|
|
const identity = {
|
|
candidateVideoIds: ["abcdefghijk"],
|
|
elementRenderedInViewport: true,
|
|
hasReel: true,
|
|
reelRenderedInViewport: true,
|
|
videoMatches: true,
|
|
};
|
|
const dom = {
|
|
actionBars: [],
|
|
renderedInViewport: true,
|
|
totalInteractiveCount: 0,
|
|
visibleActions: [],
|
|
};
|
|
const reel = {
|
|
evaluate: jest.fn(async (reader) => (reader === readShortsRendererIdentityState ? identity : dom)),
|
|
};
|
|
const page = {
|
|
evaluate: jest.fn(async () => undefined),
|
|
locator: jest.fn(() => ({ count: jest.fn(async () => 1), nth: jest.fn(() => reel) })),
|
|
screenshot: jest.fn(() => new Promise(() => {})),
|
|
setDefaultNavigationTimeout: jest.fn(),
|
|
setDefaultTimeout: jest.fn(),
|
|
url: () => "https://www.youtube.com/shorts/abcdefghijk",
|
|
};
|
|
const driver = new LiveYoutubeDriver(page, {});
|
|
|
|
await expect(
|
|
driver.captureBlankShortsDiagnostics("abcdefghijk", "blank.png", { operationTimeoutMs: 10 }),
|
|
).rejects.toThrow("Timed out capturing screenshot evidence for blank Short abcdefghijk");
|
|
expect(page.screenshot).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
test("bounds the complete blank-evidence inventory when many renderer probes are individually slow", async () => {
|
|
const delayedResult = (value) => new Promise((resolve) => setTimeout(() => resolve(value), 20));
|
|
const identity = {
|
|
candidateVideoIds: ["abcdefghijk"],
|
|
elementRenderedInViewport: true,
|
|
hasReel: true,
|
|
reelRenderedInViewport: true,
|
|
videoMatches: true,
|
|
};
|
|
const dom = {
|
|
actionBars: [],
|
|
renderedInViewport: true,
|
|
totalInteractiveCount: 0,
|
|
visibleActions: [],
|
|
};
|
|
const reel = {
|
|
evaluate: jest.fn((reader) => delayedResult(reader === readShortsRendererIdentityState ? identity : dom)),
|
|
};
|
|
const page = {
|
|
evaluate: jest.fn(async () => undefined),
|
|
locator: jest.fn(() => ({ count: jest.fn(async () => 20), nth: jest.fn(() => reel) })),
|
|
screenshot: jest.fn(async () => undefined),
|
|
setDefaultNavigationTimeout: jest.fn(),
|
|
setDefaultTimeout: jest.fn(),
|
|
url: () => "https://www.youtube.com/shorts/abcdefghijk",
|
|
};
|
|
const driver = new LiveYoutubeDriver(page, {});
|
|
const startedAt = Date.now();
|
|
|
|
await expect(
|
|
driver.captureBlankShortsDiagnostics("abcdefghijk", "blank.png", {
|
|
evidenceTimeoutMs: 30,
|
|
operationTimeoutMs: 100,
|
|
}),
|
|
).rejects.toThrow("Timed out capturing blank Shorts diagnostics for abcdefghijk within 30ms");
|
|
expect(Date.now() - startedAt).toBeLessThan(250);
|
|
expect(reel.evaluate.mock.calls.length).toBeLessThan(40);
|
|
expect(page.screenshot).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("current live reaction target selection", () => {
|
|
function actionCandidate({ eligible = true, enabled = true, visible = true } = {}) {
|
|
return {
|
|
click: jest.fn(async () => {}),
|
|
evaluate: jest.fn(async (callback) =>
|
|
callback === readElementActionability
|
|
? {
|
|
centerHitTarget: true,
|
|
centerInViewport: true,
|
|
connected: true,
|
|
enabled,
|
|
visible,
|
|
}
|
|
: {
|
|
candidateVideoIds: eligible ? ["abcdefghijk"] : ["stalevid001"],
|
|
elementRenderedInViewport: eligible,
|
|
hasReel: true,
|
|
reelRenderedInViewport: eligible,
|
|
videoMatches: eligible,
|
|
},
|
|
),
|
|
isEnabled: jest.fn(async () => enabled),
|
|
isVisible: jest.fn(async () => visible),
|
|
};
|
|
}
|
|
|
|
test("scrolls a visible below-fold control in the exact destination Watch root before clicking it", async () => {
|
|
const hiddenDuplicate = actionCandidate({ visible: false });
|
|
const disabledDuplicate = actionCandidate({ enabled: false });
|
|
// `eligible: false` models the viewport/Shorts-identity probe returning
|
|
// false before a scroll. Watch controls must not be rejected by that probe:
|
|
// a short browser window commonly places the whole action row below fold.
|
|
const belowFoldCurrent = actionCandidate({ eligible: false });
|
|
const current = actionCandidate();
|
|
const candidates = [hiddenDuplicate, disabledDuplicate, belowFoldCurrent, current];
|
|
const candidateLocator = {
|
|
count: jest.fn(async () => candidates.length),
|
|
nth: jest.fn((index) => candidates[index]),
|
|
};
|
|
const watchRoot = { locator: jest.fn(() => candidateLocator) };
|
|
const page = {
|
|
locator: jest.fn(() => watchRoot),
|
|
setDefaultNavigationTimeout: jest.fn(),
|
|
setDefaultTimeout: jest.fn(),
|
|
url: jest.fn(() => "https://www.youtube.com/watch?v=abcdefghijk"),
|
|
};
|
|
const driver = new LiveYoutubeDriver(page, {});
|
|
|
|
await driver.clickDislike("abcdefghijk");
|
|
|
|
expect(page.locator).toHaveBeenCalledWith(
|
|
'ytd-watch-flexy[video-id="abcdefghijk"], ytd-watch-grid[video-id="abcdefghijk"]',
|
|
);
|
|
const actionSelector = watchRoot.locator.mock.calls[0][0];
|
|
expect(actionSelector.split(", ").every((selector) => selector.endsWith(":visible"))).toBe(true);
|
|
expect(hiddenDuplicate.click).not.toHaveBeenCalled();
|
|
expect(disabledDuplicate.click).not.toHaveBeenCalled();
|
|
expect(belowFoldCurrent.evaluate).toHaveBeenCalledWith(scrollElementIntoViewAndWaitForPaint, undefined, {
|
|
timeout: 20_000,
|
|
});
|
|
expect(belowFoldCurrent.evaluate).toHaveBeenCalledWith(
|
|
readElementActionability,
|
|
{ includeHref: false, scroll: false },
|
|
{ timeout: 20_000 },
|
|
);
|
|
expect(belowFoldCurrent.click).toHaveBeenCalledWith({ force: true, timeout: 5_000 });
|
|
expect(current.click).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("rejects a visible wrong-video Shorts duplicate before clicking the exact current reel", async () => {
|
|
const wrongVideoDuplicate = actionCandidate({ eligible: false });
|
|
const current = actionCandidate();
|
|
const candidates = [wrongVideoDuplicate, current];
|
|
const candidateLocator = {
|
|
count: jest.fn(async () => candidates.length),
|
|
nth: jest.fn((index) => candidates[index]),
|
|
};
|
|
const page = {
|
|
locator: jest.fn(() => candidateLocator),
|
|
setDefaultNavigationTimeout: jest.fn(),
|
|
setDefaultTimeout: jest.fn(),
|
|
url: jest.fn(() => "https://www.youtube.com/shorts/abcdefghijk"),
|
|
};
|
|
const driver = new LiveYoutubeDriver(page, {});
|
|
|
|
await driver.clickDislike("abcdefghijk");
|
|
|
|
const actionSelector = page.locator.mock.calls[0][0];
|
|
expect(actionSelector.split(", ").every((selector) => selector.endsWith(":visible"))).toBe(true);
|
|
expect(wrongVideoDuplicate.evaluate).toHaveBeenCalledWith(readShortsRendererIdentityState, {
|
|
expectedShortVideoId: "abcdefghijk",
|
|
});
|
|
expect(wrongVideoDuplicate.click).not.toHaveBeenCalled();
|
|
expect(current.click).toHaveBeenCalledWith({ force: true, timeout: 5_000 });
|
|
});
|
|
});
|
|
|
|
describe("readDislikeControlText", () => {
|
|
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("Shorts dislike-count response binding", () => {
|
|
test.each(["extension", "userscript"])("accepts every supported %s display format for the API value", (runtime) => {
|
|
const candidates = formattedDislikeCountCandidates(1_234, "en", runtime);
|
|
|
|
expect(candidates).toEqual(expect.arrayContaining(["1.2K", "1.2 thousand", "1,234"]));
|
|
candidates.forEach((renderedCount) => {
|
|
expect(assertRenderedDislikeCountMatchesApi(renderedCount, 1_234, "en", runtime)).toMatchObject({
|
|
normalizedCount: renderedCount,
|
|
});
|
|
});
|
|
});
|
|
|
|
test("normalizes localized spacing without accepting an unrelated stale number", () => {
|
|
const [compact] = formattedDislikeCountCandidates(12_345, "fr", "extension");
|
|
|
|
expect(() =>
|
|
assertRenderedDislikeCountMatchesApi(compact.replace(/ /g, "\u00a0"), 12_345, "fr", "extension"),
|
|
).not.toThrow();
|
|
expect(() => assertRenderedDislikeCountMatchesApi("999", 12_345, "fr", "extension")).toThrow(
|
|
/does not represent API count 12345/,
|
|
);
|
|
});
|
|
|
|
test.each(["extension", "userscript"])(
|
|
"requires +/-1 reaction targets whose formatted %s counts cannot remain stale",
|
|
(runtime) => {
|
|
expect(
|
|
assertDislikeCountChangesObservable(
|
|
[
|
|
{ after: 14, before: 13 },
|
|
{ after: 13, before: 14 },
|
|
],
|
|
"en",
|
|
runtime,
|
|
),
|
|
).toHaveLength(2);
|
|
expect(() => assertDislikeCountChangesObservable([{ after: 14_141, before: 14_140 }], "en", runtime)).toThrow(
|
|
/not observable.*Choose a low-count reaction target/,
|
|
);
|
|
},
|
|
);
|
|
});
|
|
|
|
describe("Shorts /votes request audit", () => {
|
|
const exactRequest = {
|
|
method: "GET",
|
|
url: "https://returnyoutubedislikeapi.com/votes?videoId=abcdefghijk",
|
|
videoId: "abcdefghijk",
|
|
};
|
|
|
|
test("accepts exactly one request for the destination Short", () => {
|
|
expect(assertExactVotesRequestAudit([exactRequest], "abcdefghijk")).toBe(exactRequest);
|
|
});
|
|
|
|
test("rejects a stale request even when an exact destination request also exists", () => {
|
|
expect(() =>
|
|
assertExactVotesRequestAudit([{ ...exactRequest, videoId: "stalevid001" }, exactRequest], "abcdefghijk"),
|
|
).toThrow(/emitted a stale \/votes request/);
|
|
});
|
|
|
|
test("rejects duplicate exact destination requests", () => {
|
|
expect(() => assertExactVotesRequestAudit([exactRequest, { ...exactRequest }], "abcdefghijk")).toThrow(
|
|
/must emit exactly one \/votes request.*observed 2/,
|
|
);
|
|
});
|
|
|
|
describe("exact navigation response oracle", () => {
|
|
const exactResponse = {
|
|
...exactRequest,
|
|
requestId: 8,
|
|
responseBody: { dislikes: 123, id: "abcdefghijk", likes: 456 },
|
|
responseStatus: 200,
|
|
source: "page",
|
|
};
|
|
|
|
test("accepts one new successful attributed request with the exact response video", () => {
|
|
expect(assertExactVotesResponseAudit([exactResponse], "abcdefghijk", 7)).toBe(exactResponse);
|
|
});
|
|
|
|
test("accepts exactly one ordered extension Like-count refinement and returns its response as authoritative", () => {
|
|
const refinedResponse = {
|
|
...exactResponse,
|
|
requestId: 9,
|
|
responseBody: { ...exactResponse.responseBody, dislikes: 125 },
|
|
url: "https://returnyoutubedislikeapi.com/votes?videoId=abcdefghijk&likeCount=48525",
|
|
};
|
|
|
|
expect(
|
|
assertExactVotesResponseAudit([exactResponse, refinedResponse], "abcdefghijk", 7, {
|
|
allowLikeCountRefinement: true,
|
|
}),
|
|
).toBe(refinedResponse);
|
|
});
|
|
|
|
test.each([
|
|
[
|
|
"two aggregate requests",
|
|
[exactResponse, { ...exactResponse, requestId: 9 }],
|
|
/second \/votes request.*single Like-count refinement/,
|
|
],
|
|
[
|
|
"two enriched requests",
|
|
[
|
|
{ ...exactResponse, url: `${exactResponse.url}&likeCount=1` },
|
|
{ ...exactResponse, requestId: 9, url: `${exactResponse.url}&likeCount=2` },
|
|
],
|
|
/first \/votes request.*aggregate request before refinement/,
|
|
],
|
|
[
|
|
"more than one refinement",
|
|
[
|
|
exactResponse,
|
|
{ ...exactResponse, requestId: 9, url: `${exactResponse.url}&likeCount=1` },
|
|
{ ...exactResponse, requestId: 10, url: `${exactResponse.url}&likeCount=2` },
|
|
],
|
|
/one base request followed by one Like-count refinement.*observed 3/,
|
|
],
|
|
[
|
|
"non-numeric refinement",
|
|
[exactResponse, { ...exactResponse, requestId: 9, url: `${exactResponse.url}&likeCount=48K` }],
|
|
/invalid Like-count refinement/,
|
|
],
|
|
[
|
|
"a stale refinement video",
|
|
[
|
|
exactResponse,
|
|
{
|
|
...exactResponse,
|
|
requestId: 9,
|
|
url: "https://returnyoutubedislikeapi.com/votes?videoId=stalevid001&likeCount=48525",
|
|
videoId: "stalevid001",
|
|
},
|
|
],
|
|
/stale \/votes request/,
|
|
],
|
|
])("rejects extension refinement traffic with %s", (_label, records, expectedError) => {
|
|
expect(() =>
|
|
assertExactVotesResponseAudit(records, "abcdefghijk", 7, { allowLikeCountRefinement: true }),
|
|
).toThrow(expectedError);
|
|
});
|
|
|
|
test.each([
|
|
["duplicate request", [exactResponse, { ...exactResponse, requestId: 9 }], /exactly one \/votes request/],
|
|
[
|
|
"stale request",
|
|
[{ ...exactResponse, requestId: 9, videoId: "stalevid001" }, exactResponse],
|
|
/stale \/votes request/,
|
|
],
|
|
["old request ID", [{ ...exactResponse, requestId: 7 }], /not created after request baseline 7/],
|
|
["wrong status", [{ ...exactResponse, responseStatus: 503 }], /returned HTTP 503/],
|
|
[
|
|
"wrong response ID",
|
|
[{ ...exactResponse, responseBody: { ...exactResponse.responseBody, id: "stalevid001" } }],
|
|
/returned video stalevid001 instead of abcdefghijk/,
|
|
],
|
|
[
|
|
"invalid response count",
|
|
[{ ...exactResponse, responseBody: { ...exactResponse.responseBody, dislikes: -1 } }],
|
|
/has no valid dislike count/,
|
|
],
|
|
])("rejects a %s", (_label, records, expectedError) => {
|
|
expect(() => assertExactVotesResponseAudit(records, "abcdefghijk", 7)).toThrow(expectedError);
|
|
});
|
|
});
|
|
|
|
test("collects the complete per-hop window and rejects stale-plus-correct traffic", async () => {
|
|
const listeners = new Map();
|
|
const context = {
|
|
off: jest.fn((event, listener) => listeners.get(event)?.delete(listener)),
|
|
on: jest.fn((event, listener) => {
|
|
if (!listeners.has(event)) listeners.set(event, new Set());
|
|
listeners.get(event).add(listener);
|
|
}),
|
|
};
|
|
const page = { setDefaultNavigationTimeout: jest.fn(), setDefaultTimeout: jest.fn() };
|
|
const driver = new LiveYoutubeDriver(page, context);
|
|
const emitRequest = (videoId) => {
|
|
const request = {
|
|
frame: () => ({ page: () => page }),
|
|
method: () => "GET",
|
|
url: () => `https://returnyoutubedislikeapi.com/votes?videoId=${videoId}`,
|
|
};
|
|
listeners.get("request").forEach((listener) => listener(request));
|
|
};
|
|
|
|
await expect(
|
|
driver.withExactVotesRequest(
|
|
async () => {
|
|
emitRequest("stalevid001");
|
|
emitRequest("abcdefghijk");
|
|
return { videoId: "abcdefghijk" };
|
|
},
|
|
{ quietMs: 0 },
|
|
),
|
|
).rejects.toThrow(/emitted a stale \/votes request/);
|
|
expect(context.off).toHaveBeenCalledWith("request", expect.any(Function));
|
|
expect(listeners.get("request").size).toBe(0);
|
|
});
|
|
|
|
test("baselines request IDs and returns the exact attributed response record", async () => {
|
|
const listeners = new Map();
|
|
const context = {
|
|
off: jest.fn((event, listener) => listeners.get(event)?.delete(listener)),
|
|
on: jest.fn((event, listener) => {
|
|
if (!listeners.has(event)) listeners.set(event, new Set());
|
|
listeners.get(event).add(listener);
|
|
}),
|
|
};
|
|
const page = { setDefaultNavigationTimeout: jest.fn(), setDefaultTimeout: jest.fn() };
|
|
const driver = new LiveYoutubeDriver(page, context);
|
|
const request = {
|
|
frame: () => ({ page: () => page }),
|
|
method: () => "GET",
|
|
url: () => "https://returnyoutubedislikeapi.com/votes?videoId=abcdefghijk",
|
|
};
|
|
const response = {
|
|
json: jest.fn(async () => ({ dislikes: 123, id: "abcdefghijk", likes: 456 })),
|
|
request: () => request,
|
|
status: () => 200,
|
|
};
|
|
|
|
await expect(
|
|
driver.withExactVotesResponse(
|
|
"abcdefghijk",
|
|
async () => {
|
|
listeners.get("request").forEach((listener) => listener(request));
|
|
listeners.get("response").forEach((listener) => listener(response));
|
|
return "navigation-result";
|
|
},
|
|
{ quietMs: 0 },
|
|
),
|
|
).resolves.toEqual({
|
|
body: { dislikes: 123, id: "abcdefghijk", likes: 456 },
|
|
request: {
|
|
method: "GET",
|
|
requestId: 1,
|
|
source: "page",
|
|
url: "https://returnyoutubedislikeapi.com/votes?videoId=abcdefghijk",
|
|
videoId: "abcdefghijk",
|
|
workerUrl: null,
|
|
},
|
|
result: "navigation-result",
|
|
status: 200,
|
|
videoId: "abcdefghijk",
|
|
});
|
|
expect(context.off).toHaveBeenCalledWith("request", expect.any(Function));
|
|
expect(context.off).toHaveBeenCalledWith("response", expect.any(Function));
|
|
expect(response.json).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
test("collects an extension base request plus one richer request and returns only the final response", async () => {
|
|
const listeners = new Map();
|
|
const context = {
|
|
off: jest.fn((event, listener) => listeners.get(event)?.delete(listener)),
|
|
on: jest.fn((event, listener) => {
|
|
if (!listeners.has(event)) listeners.set(event, new Set());
|
|
listeners.get(event).add(listener);
|
|
}),
|
|
pages: jest.fn(() => []),
|
|
};
|
|
const page = { setDefaultNavigationTimeout: jest.fn(), setDefaultTimeout: jest.fn() };
|
|
const driver = new LiveYoutubeDriver(page, context, {
|
|
expectedBuildId: EXPECTED_BUILD_ID,
|
|
selectedExtensionId: SELECTED_EXTENSION_ID,
|
|
});
|
|
driver.configureRequestAttributionRuntime("extension");
|
|
const createExchange = (url, dislikes) => {
|
|
const request = {
|
|
frame: () => ({ page: () => page }),
|
|
headers: () => ({
|
|
accept: `application/json, application/vnd.ryd-live+json; id=${SELECTED_EXTENSION_ID}; build=${EXPECTED_BUILD_ID}`,
|
|
}),
|
|
method: () => "GET",
|
|
url: () => url,
|
|
};
|
|
return {
|
|
request,
|
|
response: {
|
|
json: jest.fn(async () => ({ dislikes, id: "abcdefghijk", likes: 48_525 })),
|
|
request: () => request,
|
|
status: () => 200,
|
|
},
|
|
};
|
|
};
|
|
const base = createExchange("https://returnyoutubedislikeapi.com/votes?videoId=abcdefghijk", 123);
|
|
const refined = createExchange(
|
|
"https://returnyoutubedislikeapi.com/votes?videoId=abcdefghijk&likeCount=48525",
|
|
125,
|
|
);
|
|
|
|
await expect(
|
|
driver.withExactVotesResponse(
|
|
"abcdefghijk",
|
|
async () => {
|
|
for (const exchange of [base, refined]) {
|
|
listeners.get("request").forEach((listener) => listener(exchange.request));
|
|
listeners.get("response").forEach((listener) => listener(exchange.response));
|
|
}
|
|
},
|
|
{ quietMs: 0 },
|
|
),
|
|
).resolves.toMatchObject({
|
|
body: { dislikes: 125, id: "abcdefghijk", likes: 48_525 },
|
|
request: {
|
|
requestId: 2,
|
|
url: "https://returnyoutubedislikeapi.com/votes?videoId=abcdefghijk&likeCount=48525",
|
|
},
|
|
status: 200,
|
|
});
|
|
expect(base.response.json).toHaveBeenCalledTimes(1);
|
|
expect(refined.response.json).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
test("attributes a frame-less request only to the selected extension worker", () => {
|
|
const page = {};
|
|
const request = {
|
|
frame: () => {
|
|
throw new Error("Service Worker requests do not have a frame");
|
|
},
|
|
serviceWorker: () => ({ url: () => `chrome-extension://${SELECTED_EXTENSION_ID}/ryd.background.js` }),
|
|
};
|
|
|
|
expect(attributeRuntimeRequest(request, page, "extension", SELECTED_EXTENSION_ID)).toEqual({
|
|
source: "service-worker",
|
|
workerUrl: `chrome-extension://${SELECTED_EXTENSION_ID}/ryd.background.js`,
|
|
});
|
|
expect(attributeRuntimeRequest(request, page, "userscript")).toBeNull();
|
|
expect(
|
|
attributeRuntimeRequest(
|
|
{
|
|
...request,
|
|
serviceWorker: () => ({ url: () => `chrome-extension://${SELECTED_EXTENSION_ID}/unrelated-worker.js` }),
|
|
},
|
|
page,
|
|
"extension",
|
|
SELECTED_EXTENSION_ID,
|
|
),
|
|
).toBeNull();
|
|
});
|
|
|
|
test("does not attribute an otherwise identical /votes request from a competing extension worker", () => {
|
|
const page = {};
|
|
const request = {
|
|
frame: () => {
|
|
throw new Error("Service Worker requests do not have a frame");
|
|
},
|
|
serviceWorker: () => ({ url: () => `chrome-extension://${COMPETING_EXTENSION_ID}/ryd.background.js` }),
|
|
};
|
|
|
|
expect(attributeRuntimeRequest(request, page, "extension", SELECTED_EXTENSION_ID)).toBeNull();
|
|
});
|
|
|
|
test("parses only the exact live-build Accept fingerprint", () => {
|
|
expect(parseLiveExtensionAcceptHeader(LIVE_ACCEPT_HEADER)).toEqual({
|
|
buildId: EXPECTED_BUILD_ID,
|
|
extensionId: SELECTED_EXTENSION_ID,
|
|
});
|
|
expect(parseLiveExtensionAcceptHeader(`${LIVE_ACCEPT_HEADER}; extra=true`)).toBeNull();
|
|
expect(parseLiveExtensionAcceptHeader(LIVE_ACCEPT_HEADER.toUpperCase())).toBeNull();
|
|
expect(parseLiveExtensionAcceptHeader("application/json")).toBeNull();
|
|
});
|
|
|
|
test("attributes an extension page-framed GET only with the exact extension and build fingerprint", () => {
|
|
const page = {};
|
|
const createRequest = (accept = LIVE_ACCEPT_HEADER) => ({
|
|
frame: () => ({ page: () => page }),
|
|
headers: () => ({ Accept: accept }),
|
|
method: () => "GET",
|
|
});
|
|
|
|
expect(
|
|
attributeRuntimeRequest(createRequest(), page, "extension", SELECTED_EXTENSION_ID, EXPECTED_BUILD_ID),
|
|
).toEqual({ source: "page", workerUrl: null });
|
|
expect(
|
|
attributeRuntimeRequest(
|
|
createRequest(LIVE_ACCEPT_HEADER.replace(SELECTED_EXTENSION_ID, COMPETING_EXTENSION_ID)),
|
|
page,
|
|
"extension",
|
|
SELECTED_EXTENSION_ID,
|
|
EXPECTED_BUILD_ID,
|
|
),
|
|
).toBeNull();
|
|
expect(
|
|
attributeRuntimeRequest(
|
|
createRequest(LIVE_ACCEPT_HEADER.replace(EXPECTED_BUILD_ID, "f".repeat(32))),
|
|
page,
|
|
"extension",
|
|
SELECTED_EXTENSION_ID,
|
|
EXPECTED_BUILD_ID,
|
|
),
|
|
).toBeNull();
|
|
expect(
|
|
attributeRuntimeRequest(
|
|
createRequest("application/json"),
|
|
page,
|
|
"extension",
|
|
SELECTED_EXTENSION_ID,
|
|
EXPECTED_BUILD_ID,
|
|
),
|
|
).toBeNull();
|
|
});
|
|
|
|
test("rejects a page-framed POST even when it spoofs the complete extension fingerprint", () => {
|
|
const page = {};
|
|
const request = {
|
|
frame: () => ({ page: () => page }),
|
|
headers: () => ({ accept: LIVE_ACCEPT_HEADER }),
|
|
method: () => "POST",
|
|
};
|
|
|
|
expect(attributeRuntimeRequest(request, page, "extension", SELECTED_EXTENSION_ID, EXPECTED_BUILD_ID)).toBeNull();
|
|
expect(attributeRuntimeRequest(request, page, "userscript")).toEqual({ source: "page", workerUrl: null });
|
|
});
|
|
|
|
test("fails closed when extension request attribution has no selected extension ID", () => {
|
|
const page = { setDefaultNavigationTimeout: jest.fn(), setDefaultTimeout: jest.fn() };
|
|
const driver = new LiveYoutubeDriver(page, {});
|
|
|
|
expect(() => driver.configureRequestAttributionRuntime("extension")).toThrow(
|
|
/requires the exact selected extension ID/,
|
|
);
|
|
});
|
|
|
|
test("fails closed when extension request attribution has no exact live build ID", () => {
|
|
const page = { setDefaultNavigationTimeout: jest.fn(), setDefaultTimeout: jest.fn() };
|
|
const context = { on: jest.fn() };
|
|
const driver = new LiveYoutubeDriver(page, context, { selectedExtensionId: SELECTED_EXTENSION_ID });
|
|
|
|
expect(() => driver.configureRequestAttributionRuntime("extension")).toThrow(/requires the exact live build ID/);
|
|
});
|
|
|
|
test("does not attribute a request owned by another page", () => {
|
|
const page = {};
|
|
const otherPage = {};
|
|
const request = {
|
|
frame: () => ({ page: () => otherPage }),
|
|
serviceWorker: () => ({ url: () => `chrome-extension://${SELECTED_EXTENSION_ID}/ryd.background.js` }),
|
|
};
|
|
|
|
expect(attributeRuntimeRequest(request, page, "extension", SELECTED_EXTENSION_ID)).toBeNull();
|
|
});
|
|
|
|
test("rejects ambiguous worker traffic when another tab has the same video open", () => {
|
|
const page = { url: () => "https://www.youtube.com/shorts/abcdefghijk" };
|
|
const otherPage = { url: () => "https://www.youtube.com/watch?v=abcdefghijk" };
|
|
const requests = [{ source: "service-worker", videoId: "abcdefghijk" }];
|
|
|
|
expect(() =>
|
|
assertWorkerRequestAttributionIsUnambiguous({ pages: () => [page, otherPage] }, page, requests, "abcdefghijk"),
|
|
).toThrow(/another tab has the same video open/);
|
|
});
|
|
|
|
test("captures the selected extension worker throughout the full per-hop request window", async () => {
|
|
const listeners = new Map();
|
|
const page = {
|
|
setDefaultNavigationTimeout: jest.fn(),
|
|
setDefaultTimeout: jest.fn(),
|
|
url: () => "https://www.youtube.com/shorts/abcdefghijk",
|
|
};
|
|
const context = {
|
|
off: jest.fn((event, listener) => listeners.get(event)?.delete(listener)),
|
|
on: jest.fn((event, listener) => {
|
|
if (!listeners.has(event)) listeners.set(event, new Set());
|
|
listeners.get(event).add(listener);
|
|
}),
|
|
pages: () => [page],
|
|
};
|
|
const reportProgress = jest.fn();
|
|
const driver = new LiveYoutubeDriver(page, context, {
|
|
expectedBuildId: EXPECTED_BUILD_ID,
|
|
reportProgress,
|
|
selectedExtensionId: SELECTED_EXTENSION_ID,
|
|
});
|
|
driver.configureRequestAttributionRuntime("extension");
|
|
const request = {
|
|
frame: () => {
|
|
throw new Error("Service Worker requests do not have a frame");
|
|
},
|
|
method: () => "GET",
|
|
serviceWorker: () => ({ url: () => `chrome-extension://${SELECTED_EXTENSION_ID}/ryd.background.js` }),
|
|
url: () => "https://returnyoutubedislikeapi.com/votes?videoId=abcdefghijk",
|
|
};
|
|
|
|
await expect(
|
|
driver.withExactVotesRequest(
|
|
async () => {
|
|
listeners.get("request").forEach((listener) => listener(request));
|
|
return { videoId: "abcdefghijk" };
|
|
},
|
|
{ quietMs: 0 },
|
|
),
|
|
).resolves.toEqual({ videoId: "abcdefghijk" });
|
|
expect(reportProgress).toHaveBeenCalledWith(
|
|
"shorts-votes-request-audit.confirmed",
|
|
expect.objectContaining({ source: "service-worker", videoId: "abcdefghijk" }),
|
|
);
|
|
});
|
|
|
|
test("cannot satisfy the exact /votes oracle with a competing extension worker", async () => {
|
|
const listeners = new Map();
|
|
const page = {
|
|
setDefaultNavigationTimeout: jest.fn(),
|
|
setDefaultTimeout: jest.fn(),
|
|
url: () => "https://www.youtube.com/shorts/abcdefghijk",
|
|
};
|
|
const context = {
|
|
off: jest.fn((event, listener) => listeners.get(event)?.delete(listener)),
|
|
on: jest.fn((event, listener) => {
|
|
if (!listeners.has(event)) listeners.set(event, new Set());
|
|
listeners.get(event).add(listener);
|
|
}),
|
|
pages: () => [page],
|
|
};
|
|
const driver = new LiveYoutubeDriver(page, context, {
|
|
expectedBuildId: EXPECTED_BUILD_ID,
|
|
selectedExtensionId: SELECTED_EXTENSION_ID,
|
|
});
|
|
driver.configureRequestAttributionRuntime("extension");
|
|
const competingRequest = {
|
|
frame: () => {
|
|
throw new Error("Service Worker requests do not have a frame");
|
|
},
|
|
method: () => "GET",
|
|
serviceWorker: () => ({ url: () => `chrome-extension://${COMPETING_EXTENSION_ID}/ryd.background.js` }),
|
|
url: () => "https://returnyoutubedislikeapi.com/votes?videoId=abcdefghijk",
|
|
};
|
|
|
|
await expect(
|
|
driver.withExactVotesRequest(
|
|
async () => {
|
|
listeners.get("request").forEach((listener) => listener(competingRequest));
|
|
return { videoId: "abcdefghijk" };
|
|
},
|
|
{ quietMs: 0 },
|
|
),
|
|
).rejects.toThrow(/must emit exactly one \/votes request.*observed 0/);
|
|
});
|
|
|
|
test("cannot satisfy the extension /votes oracle with an ordinary same-page GET", async () => {
|
|
const listeners = new Map();
|
|
const page = {
|
|
setDefaultNavigationTimeout: jest.fn(),
|
|
setDefaultTimeout: jest.fn(),
|
|
url: () => "https://www.youtube.com/shorts/abcdefghijk",
|
|
};
|
|
const context = {
|
|
off: jest.fn((event, listener) => listeners.get(event)?.delete(listener)),
|
|
on: jest.fn((event, listener) => {
|
|
if (!listeners.has(event)) listeners.set(event, new Set());
|
|
listeners.get(event).add(listener);
|
|
}),
|
|
pages: () => [page],
|
|
};
|
|
const driver = new LiveYoutubeDriver(page, context, {
|
|
expectedBuildId: EXPECTED_BUILD_ID,
|
|
selectedExtensionId: SELECTED_EXTENSION_ID,
|
|
});
|
|
driver.configureRequestAttributionRuntime("extension");
|
|
const pageSpoof = {
|
|
frame: () => ({ page: () => page }),
|
|
headers: () => ({ accept: "application/json" }),
|
|
method: () => "GET",
|
|
url: () => "https://returnyoutubedislikeapi.com/votes?videoId=abcdefghijk",
|
|
};
|
|
|
|
await expect(
|
|
driver.withExactVotesRequest(
|
|
async () => {
|
|
listeners.get("request").forEach((listener) => listener(pageSpoof));
|
|
return { videoId: "abcdefghijk" };
|
|
},
|
|
{ quietMs: 0 },
|
|
),
|
|
).rejects.toThrow(/must emit exactly one \/votes request.*observed 0/);
|
|
driver.stop();
|
|
});
|
|
|
|
test("allows no /votes request only when the current Short stayed fully blank for the native-control window", async () => {
|
|
const listeners = new Map();
|
|
const page = {
|
|
setDefaultNavigationTimeout: jest.fn(),
|
|
setDefaultTimeout: jest.fn(),
|
|
url: () => "https://www.youtube.com/shorts/abcdefghijk",
|
|
};
|
|
const context = {
|
|
off: jest.fn((event, listener) => listeners.get(event)?.delete(listener)),
|
|
on: jest.fn((event, listener) => {
|
|
if (!listeners.has(event)) listeners.set(event, new Set());
|
|
listeners.get(event).add(listener);
|
|
}),
|
|
};
|
|
const driver = new LiveYoutubeDriver(page, context);
|
|
driver.configureRequestAttributionRuntime("userscript");
|
|
driver.waitForCurrentShortsNativeControls = jest.fn(async () => ({
|
|
currentVideoId: "abcdefghijk",
|
|
observedForMs: 20_000,
|
|
reason: "no-visible-native-shorts-actions",
|
|
status: "blank",
|
|
visibleNativeControls: 0,
|
|
}));
|
|
|
|
await expect(
|
|
driver.withShortsSampleVotesResponse(async () => ({ videoId: "abcdefghijk" }), { quietMs: 0 }),
|
|
).resolves.toMatchObject({
|
|
body: null,
|
|
nativeControls: {
|
|
observedForMs: 20_000,
|
|
reason: "no-visible-native-shorts-actions",
|
|
status: "blank",
|
|
},
|
|
request: null,
|
|
status: null,
|
|
videoId: "abcdefghijk",
|
|
});
|
|
expect(context.off).toHaveBeenCalledWith("request", expect.any(Function));
|
|
expect(context.off).toHaveBeenCalledWith("response", expect.any(Function));
|
|
});
|
|
|
|
test("still rejects stale /votes traffic when YouTube rendered no native Shorts controls", async () => {
|
|
const listeners = new Map();
|
|
const page = {
|
|
setDefaultNavigationTimeout: jest.fn(),
|
|
setDefaultTimeout: jest.fn(),
|
|
url: () => "https://www.youtube.com/shorts/abcdefghijk",
|
|
};
|
|
const context = {
|
|
off: jest.fn((event, listener) => listeners.get(event)?.delete(listener)),
|
|
on: jest.fn((event, listener) => {
|
|
if (!listeners.has(event)) listeners.set(event, new Set());
|
|
listeners.get(event).add(listener);
|
|
}),
|
|
};
|
|
const request = {
|
|
frame: () => ({ page: () => page }),
|
|
method: () => "GET",
|
|
url: () => "https://returnyoutubedislikeapi.com/votes?videoId=stalevid001",
|
|
};
|
|
const response = {
|
|
json: jest.fn(async () => ({ dislikes: 123, id: "stalevid001", likes: 456 })),
|
|
request: () => request,
|
|
status: () => 200,
|
|
};
|
|
const driver = new LiveYoutubeDriver(page, context);
|
|
driver.configureRequestAttributionRuntime("userscript");
|
|
driver.waitForCurrentShortsNativeControls = jest.fn(async () => ({
|
|
currentVideoId: "abcdefghijk",
|
|
observedForMs: 20_000,
|
|
reason: "no-visible-native-shorts-actions",
|
|
status: "blank",
|
|
visibleNativeControls: 0,
|
|
}));
|
|
|
|
await expect(
|
|
driver.withShortsSampleVotesResponse(
|
|
async () => {
|
|
listeners.get("request").forEach((listener) => listener(request));
|
|
listeners.get("response").forEach((listener) => listener(response));
|
|
return { videoId: "abcdefghijk" };
|
|
},
|
|
{ quietMs: 0 },
|
|
),
|
|
).rejects.toThrow("emitted a stale /votes request while targeting abcdefghijk");
|
|
});
|
|
|
|
test("does not forgive a missing /votes request once any native Shorts action is visible", async () => {
|
|
const listeners = new Map();
|
|
const page = {
|
|
setDefaultNavigationTimeout: jest.fn(),
|
|
setDefaultTimeout: jest.fn(),
|
|
url: () => "https://www.youtube.com/shorts/abcdefghijk",
|
|
};
|
|
const context = {
|
|
off: jest.fn((event, listener) => listeners.get(event)?.delete(listener)),
|
|
on: jest.fn((event, listener) => {
|
|
if (!listeners.has(event)) listeners.set(event, new Set());
|
|
listeners.get(event).add(listener);
|
|
}),
|
|
};
|
|
const driver = new LiveYoutubeDriver(page, context);
|
|
driver.configureRequestAttributionRuntime("userscript");
|
|
driver.waitForCurrentShortsNativeControls = jest.fn(async () => ({
|
|
currentVideoId: "abcdefghijk",
|
|
observedForMs: 1,
|
|
status: "present",
|
|
visibleNativeControls: 1,
|
|
}));
|
|
|
|
await expect(
|
|
driver.withShortsSampleVotesResponse(async () => ({ videoId: "abcdefghijk" }), {
|
|
quietMs: 0,
|
|
trafficTimeoutMs: 0,
|
|
}),
|
|
).rejects.toThrow("Timed out waiting for new attributed production /votes traffic for Short abcdefghijk");
|
|
});
|
|
|
|
test("navigateToNextShort consumes a frame-less response from the selected extension worker", async () => {
|
|
const previousVideoId = "oldvideo001";
|
|
const nextVideoId = "nextvideo01";
|
|
let currentUrl = `https://www.youtube.com/shorts/${previousVideoId}`;
|
|
let finishNavigation;
|
|
const listeners = new Map();
|
|
const request = {
|
|
frame: () => {
|
|
throw new Error("Service Worker requests do not have a frame");
|
|
},
|
|
method: () => "GET",
|
|
serviceWorker: () => ({ url: () => `chrome-extension://${SELECTED_EXTENSION_ID}/ryd.background.js` }),
|
|
url: () => `https://returnyoutubedislikeapi.com/votes?videoId=${nextVideoId}`,
|
|
};
|
|
const response = {
|
|
json: jest.fn(async () => ({ dislikes: 321, id: nextVideoId, likes: 456 })),
|
|
request: () => request,
|
|
status: () => 200,
|
|
url: () => `https://returnyoutubedislikeapi.com/votes?videoId=${nextVideoId}`,
|
|
};
|
|
const nextButton = {
|
|
click: jest.fn(async () => {
|
|
currentUrl = `https://www.youtube.com/shorts/${nextVideoId}`;
|
|
listeners.get("request").forEach((listener) => listener(request));
|
|
listeners.get("response").forEach((listener) => listener(response));
|
|
finishNavigation?.();
|
|
}),
|
|
evaluate: jest.fn(async (callback) =>
|
|
callback === readElementActionability
|
|
? {
|
|
centerHitTarget: true,
|
|
centerInViewport: true,
|
|
connected: true,
|
|
enabled: true,
|
|
visible: true,
|
|
}
|
|
: {
|
|
elementRenderedInViewport: true,
|
|
hasReel: false,
|
|
reelRenderedInViewport: false,
|
|
videoMatches: false,
|
|
},
|
|
),
|
|
isVisible: jest.fn(async () => true),
|
|
};
|
|
const page = {
|
|
evaluate: jest.fn(async () => "document-marker"),
|
|
locator: jest.fn(() => ({
|
|
count: jest.fn(async () => 1),
|
|
nth: jest.fn(() => nextButton),
|
|
})),
|
|
setDefaultNavigationTimeout: jest.fn(),
|
|
setDefaultTimeout: jest.fn(),
|
|
url: jest.fn(() => currentUrl),
|
|
waitForURL: jest.fn(
|
|
(predicate) =>
|
|
new Promise((resolve) => {
|
|
finishNavigation = () => {
|
|
if (predicate(new URL(currentUrl))) resolve();
|
|
};
|
|
}),
|
|
),
|
|
};
|
|
const context = {
|
|
off: jest.fn((event, listener) => listeners.get(event)?.delete(listener)),
|
|
on: jest.fn((event, listener) => {
|
|
if (!listeners.has(event)) listeners.set(event, new Set());
|
|
listeners.get(event).add(listener);
|
|
}),
|
|
pages: () => [page],
|
|
};
|
|
const driver = new LiveYoutubeDriver(page, context, {
|
|
expectedBuildId: EXPECTED_BUILD_ID,
|
|
selectedExtensionId: SELECTED_EXTENSION_ID,
|
|
});
|
|
driver.configureRequestAttributionRuntime("extension");
|
|
driver.waitForCurrentShortsNativeControls = jest.fn(async () => ({
|
|
currentVideoId: nextVideoId,
|
|
observedForMs: 10,
|
|
status: "present",
|
|
visibleNativeControls: 5,
|
|
}));
|
|
|
|
await expect(driver.navigateToNextShort(previousVideoId, { quietMs: 0 })).resolves.toMatchObject({
|
|
body: { dislikes: 321, id: nextVideoId, likes: 456 },
|
|
nativeControls: { status: "present", visibleNativeControls: 5 },
|
|
request: {
|
|
method: "GET",
|
|
source: "service-worker",
|
|
videoId: nextVideoId,
|
|
},
|
|
status: 200,
|
|
videoId: nextVideoId,
|
|
});
|
|
expect(response.json).toHaveBeenCalledTimes(1);
|
|
expect(nextButton.click).toHaveBeenCalledWith({ force: true, timeout: 5_000 });
|
|
expect(context.off).toHaveBeenCalledWith("request", expect.any(Function));
|
|
expect(context.off).toHaveBeenCalledWith("response", expect.any(Function));
|
|
});
|
|
});
|
|
|
|
describe("production interaction runtime attribution", () => {
|
|
function createRecorderHarness(runtime, selectedExtensionId = null) {
|
|
const listeners = new Map();
|
|
const page = { url: () => "https://www.youtube.com/watch?v=abcdefghijk" };
|
|
const context = {
|
|
off: jest.fn((event, listener) => listeners.get(event)?.delete(listener)),
|
|
on: jest.fn((event, listener) => {
|
|
if (!listeners.has(event)) listeners.set(event, new Set());
|
|
listeners.get(event).add(listener);
|
|
}),
|
|
pages: () => [page],
|
|
};
|
|
const recorder = new VoteTrafficRecorder(context, "abcdefghijk", {
|
|
handshakeTimeout: 25,
|
|
page,
|
|
runtime,
|
|
selectedExtensionId,
|
|
});
|
|
return { context, listeners, page, recorder };
|
|
}
|
|
|
|
function interactionRequest(pathname, body, { page = null, workerId = null } = {}) {
|
|
return {
|
|
frame: () => {
|
|
if (page) return { page: () => page };
|
|
throw new Error("Service Worker requests do not have a frame");
|
|
},
|
|
method: () => "POST",
|
|
postDataJSON: () => body,
|
|
serviceWorker: () => (workerId ? { url: () => `chrome-extension://${workerId}/ryd.background.js` } : null),
|
|
url: () => `https://returnyoutubedislikeapi.com${pathname}`,
|
|
};
|
|
}
|
|
|
|
function emitRequest(listeners, request) {
|
|
listeners.get("request").forEach((listener) => listener(request));
|
|
}
|
|
|
|
test("requires an exact selected extension ID before recording a background vote", () => {
|
|
const context = { on: jest.fn() };
|
|
|
|
expect(
|
|
() =>
|
|
new VoteTrafficRecorder(context, "abcdefghijk", {
|
|
page: {},
|
|
runtime: "extension",
|
|
}),
|
|
).toThrow(/requires the exact selected extension ID/);
|
|
});
|
|
|
|
test("ignores a complete matching handshake from a competing extension worker", async () => {
|
|
const { listeners, recorder } = createRecorderHarness("extension", SELECTED_EXTENSION_ID);
|
|
const body = { userId: "user-1", value: -1, videoId: "abcdefghijk" };
|
|
emitRequest(listeners, interactionRequest("/interact/vote", body, { workerId: COMPETING_EXTENSION_ID }));
|
|
emitRequest(listeners, interactionRequest("/interact/confirmVote", body, { workerId: COMPETING_EXTENSION_ID }));
|
|
|
|
expect(recorder.mark()).toBe(0);
|
|
expect(recorder.hasVote(-1, 0)).toBe(false);
|
|
await expect(recorder.waitForHandshake(-1, 0)).rejects.toThrow(
|
|
/Timed out waiting for the production vote handshake/,
|
|
);
|
|
recorder.stop();
|
|
});
|
|
|
|
test("ignores a complete page-framed handshake that spoofs the selected extension fingerprint", async () => {
|
|
const { listeners, page, recorder } = createRecorderHarness("extension", SELECTED_EXTENSION_ID);
|
|
const userId = "A".repeat(36);
|
|
const vote = interactionRequest("/interact/vote", { userId, value: -1, videoId: "abcdefghijk" }, { page });
|
|
const confirmation = interactionRequest(
|
|
"/interact/confirmVote",
|
|
{ solution: Buffer.alloc(4).toString("base64"), userId, videoId: "abcdefghijk" },
|
|
{ page },
|
|
);
|
|
vote.headers = confirmation.headers = () => ({ accept: LIVE_ACCEPT_HEADER });
|
|
|
|
emitRequest(listeners, vote);
|
|
emitRequest(listeners, confirmation);
|
|
|
|
expect(recorder.mark()).toBe(0);
|
|
expect(recorder.hasVote(-1, 0)).toBe(false);
|
|
await expect(recorder.waitForHandshake(-1, 0)).rejects.toThrow(
|
|
/Timed out waiting for the production vote handshake/,
|
|
);
|
|
recorder.stop();
|
|
});
|
|
|
|
test("records only the selected extension worker when a competitor emits the same vote", () => {
|
|
const { listeners, recorder } = createRecorderHarness("extension", SELECTED_EXTENSION_ID);
|
|
const body = { userId: "user-1", value: -1, videoId: "abcdefghijk" };
|
|
const competing = interactionRequest("/interact/vote", body, { workerId: COMPETING_EXTENSION_ID });
|
|
const selected = interactionRequest("/interact/vote", body, { workerId: SELECTED_EXTENSION_ID });
|
|
|
|
emitRequest(listeners, competing);
|
|
emitRequest(listeners, selected);
|
|
|
|
expect(recorder.mark()).toBe(1);
|
|
expect(recorder.records).toEqual([
|
|
expect.objectContaining({
|
|
body,
|
|
source: "service-worker",
|
|
workerUrl: `chrome-extension://${SELECTED_EXTENSION_ID}/ryd.background.js`,
|
|
}),
|
|
]);
|
|
recorder.stop();
|
|
});
|
|
|
|
test("binds userscript interaction traffic to the selected page", () => {
|
|
const { listeners, page, recorder } = createRecorderHarness("userscript");
|
|
const body = { userId: "user-1", value: -1, videoId: "abcdefghijk" };
|
|
emitRequest(listeners, interactionRequest("/interact/vote", body, { page: {} }));
|
|
emitRequest(listeners, interactionRequest("/interact/vote", body, { page }));
|
|
|
|
expect(recorder.mark()).toBe(1);
|
|
expect(recorder.records[0]).toEqual(expect.objectContaining({ body, source: "page", workerUrl: null }));
|
|
recorder.stop();
|
|
});
|
|
});
|
|
|
|
describe("current Shorts control ownership audit", () => {
|
|
function createDriver({
|
|
blockedAction = null,
|
|
extraAction = false,
|
|
hiddenDuplicateInCurrentReel = false,
|
|
visibleStaleControl = false,
|
|
} = {}) {
|
|
document.body.innerHTML = `
|
|
<ytd-reel-video-renderer video-id="abcdefghijk">
|
|
<reel-action-bar-view-model>
|
|
<like-button-view-model><button type="button" aria-label="Like">1K</button></like-button-view-model>
|
|
<div data-ryd-synthetic-shorts-dislike data-ryd-video-id="abcdefghijk">
|
|
<button type="button" aria-label="Dislike this video" aria-pressed="false"></button>
|
|
<span id="text">123</span>
|
|
</div>
|
|
<div><button type="button" aria-label="Comments"></button></div>
|
|
<div><button type="button" aria-label="Share"></button></div>
|
|
<div><button type="button" aria-label="Remix"></button></div>
|
|
<div><button type="button" aria-label="More"></button></div>
|
|
${extraAction ? '<div><button type="button" aria-label="Extra action"></button></div>' : ""}
|
|
${
|
|
hiddenDuplicateInCurrentReel
|
|
? '<div data-ryd-synthetic-shorts-dislike data-ryd-video-id="abcdefghijk" hidden><button></button></div>'
|
|
: ""
|
|
}
|
|
</reel-action-bar-view-model>
|
|
</ytd-reel-video-renderer>
|
|
${
|
|
visibleStaleControl
|
|
? `<ytd-reel-video-renderer video-id="stalevid001">
|
|
<div data-ryd-synthetic-shorts-dislike data-ryd-video-id="stalevid001"><button></button></div>
|
|
</ytd-reel-video-renderer>`
|
|
: ""
|
|
}
|
|
`;
|
|
const buttons = [...document.querySelectorAll("button")];
|
|
buttons.forEach((element, index) => {
|
|
const top = 40 + index * 52;
|
|
element.getBoundingClientRect = jest.fn(() => ({
|
|
bottom: top + 48,
|
|
height: 48,
|
|
left: 900,
|
|
right: 948,
|
|
top,
|
|
width: 48,
|
|
}));
|
|
});
|
|
document.querySelectorAll("[data-ryd-synthetic-shorts-dislike]").forEach((element) => {
|
|
element.getBoundingClientRect = jest.fn(() => ({
|
|
bottom: 140,
|
|
height: 100,
|
|
left: 900,
|
|
right: 948,
|
|
top: 40,
|
|
width: 48,
|
|
}));
|
|
});
|
|
document.elementFromPoint = jest.fn((x, y) => {
|
|
const target = buttons.find((element) => {
|
|
const rect = element.getBoundingClientRect();
|
|
return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom;
|
|
});
|
|
return target?.getAttribute("aria-label") === blockedAction ? document.body : target ?? null;
|
|
});
|
|
|
|
const button = document.querySelector(
|
|
'ytd-reel-video-renderer[video-id="abcdefghijk"] [data-ryd-synthetic-shorts-dislike] button',
|
|
);
|
|
const page = {
|
|
evaluate: jest.fn(async (callback) => callback()),
|
|
setDefaultNavigationTimeout: jest.fn(),
|
|
setDefaultTimeout: jest.fn(),
|
|
url: jest.fn(() => "https://www.youtube.com/shorts/abcdefghijk"),
|
|
};
|
|
const driver = new LiveYoutubeDriver(page, {});
|
|
driver.visibleDislikeButton = jest.fn(async () => ({
|
|
evaluate: async (callback, argument) => callback(button, argument),
|
|
}));
|
|
return driver;
|
|
}
|
|
|
|
test("accepts exactly one current synthetic control in the active reel and viewport", async () => {
|
|
const driver = createDriver();
|
|
|
|
await expect(driver.assertCurrentShortsControl("abcdefghijk", "extension")).resolves.toMatchObject({
|
|
count: "123",
|
|
synthetic: true,
|
|
videoId: "abcdefghijk",
|
|
hitTestedActionButtons: 6,
|
|
visibleActionButtons: 6,
|
|
});
|
|
});
|
|
|
|
test("accepts a seventh optional Shorts action when every action remains visible and hit-testable", async () => {
|
|
const driver = createDriver({ extraAction: true });
|
|
|
|
await expect(driver.assertCurrentShortsControl("abcdefghijk", "extension")).resolves.toMatchObject({
|
|
hitTestedActionButtons: 7,
|
|
visibleActionButtons: 7,
|
|
});
|
|
});
|
|
|
|
test("rejects a visible Shorts action covered by another layer", async () => {
|
|
const driver = createDriver({ blockedAction: "Share" });
|
|
|
|
await expect(driver.assertCurrentShortsControl("abcdefghijk", "userscript")).rejects.toThrow(
|
|
"Every visible Shorts action must be hit-testable at its center; blocked controls: Share",
|
|
);
|
|
});
|
|
|
|
test("rejects a hidden duplicate retained inside the active reel", async () => {
|
|
const driver = createDriver({ hiddenDuplicateInCurrentReel: true });
|
|
|
|
await expect(driver.assertCurrentShortsControl("abcdefghijk", "userscript")).rejects.toThrow(
|
|
"current Shorts action stack must contain exactly one userscript synthetic dislike control",
|
|
);
|
|
});
|
|
|
|
test("rejects a visible synthetic control owned by a stale pre-rendered reel", async () => {
|
|
const driver = createDriver({ visibleStaleControl: true });
|
|
|
|
await expect(driver.assertCurrentShortsControl("abcdefghijk", "extension")).rejects.toThrow(
|
|
"viewport contains a visible extension synthetic dislike control for a stale video ID",
|
|
);
|
|
});
|
|
|
|
test("rejects a stale numeric control count that does not represent the current API response", async () => {
|
|
const driver = createDriver();
|
|
|
|
await expect(
|
|
driver.assertCurrentShortsControl("abcdefghijk", "extension", { expectedDislikes: 456 }),
|
|
).rejects.toThrow(/Rendered dislike count "123" does not represent API count 456/);
|
|
});
|
|
});
|
|
|
|
describe("relatedWatchVideoId", () => {
|
|
const settings = {
|
|
currentVideoId: "abcdefghijk",
|
|
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("cold channel tab targeting", () => {
|
|
test.each([
|
|
["https://www.youtube.com/@channel", "short", "https://www.youtube.com/@channel/shorts"],
|
|
["https://www.youtube.com/@channel/featured", "watch", "https://www.youtube.com/@channel/videos"],
|
|
["https://www.youtube.com/@channel/videos", "short", "https://www.youtube.com/@channel/shorts"],
|
|
["https://www.youtube.com/@channel/shorts", "watch", "https://www.youtube.com/@channel/videos"],
|
|
])("derives the relevant tab from %s for a %s target", (channelUrl, kind, expected) => {
|
|
expect(channelTabUrl(channelUrl, kind).toString()).toBe(expected);
|
|
});
|
|
|
|
test.each([
|
|
["https://www.youtube.com/shorts/targetvid01", "short", true],
|
|
["https://www.youtube.com/watch?v=targetvid01", "watch", true],
|
|
["https://www.youtube.com/watch?v=targetvid01", "short", false],
|
|
["https://www.youtube.com/shorts/targetvid01", "watch", false],
|
|
["https://example.com/watch?v=targetvid01", "watch", false],
|
|
])("matches only the exact %s destination for %s", (url, kind, expected) => {
|
|
expect(
|
|
isExactVideoUrl(url, {
|
|
kind,
|
|
origin: "https://www.youtube.com",
|
|
videoId: "targetvid01",
|
|
}),
|
|
).toBe(expected);
|
|
});
|
|
});
|
|
|
|
describe("hit-tested navigation link activation", () => {
|
|
let originalElementFromPoint;
|
|
|
|
function makeLink() {
|
|
const link = document.createElement("a");
|
|
const title = document.createElement("span");
|
|
link.href = "/watch?v=targetvid01";
|
|
title.textContent = "Related video";
|
|
link.append(title);
|
|
link.scrollIntoView = jest.fn();
|
|
link.getBoundingClientRect = jest.fn(() => ({
|
|
bottom: 140,
|
|
height: 40,
|
|
left: 100,
|
|
right: 300,
|
|
top: 100,
|
|
width: 200,
|
|
x: 100,
|
|
y: 100,
|
|
}));
|
|
document.body.append(link);
|
|
return { link, title };
|
|
}
|
|
|
|
beforeEach(() => {
|
|
originalElementFromPoint = document.elementFromPoint;
|
|
});
|
|
|
|
afterEach(() => {
|
|
document.body.replaceChildren();
|
|
Object.defineProperty(document, "elementFromPoint", {
|
|
configurable: true,
|
|
value: originalElementFromPoint,
|
|
writable: true,
|
|
});
|
|
});
|
|
|
|
test("accepts a visible enabled in-viewport link whose center hits a descendant", () => {
|
|
const { link, title } = makeLink();
|
|
Object.defineProperty(document, "elementFromPoint", {
|
|
configurable: true,
|
|
value: jest.fn(() => title),
|
|
writable: true,
|
|
});
|
|
|
|
const state = readNavigationLinkActionability(link);
|
|
|
|
expect(assertNavigationLinkActionable(state, "related link")).toBe(state);
|
|
expect(state).toMatchObject({
|
|
center: { x: 200, y: 120 },
|
|
centerHitTarget: true,
|
|
centerInViewport: true,
|
|
connected: true,
|
|
enabled: true,
|
|
href: "/watch?v=targetvid01",
|
|
visible: true,
|
|
});
|
|
expect(link.scrollIntoView).toHaveBeenCalledWith({ block: "center", inline: "nearest" });
|
|
});
|
|
|
|
test("rejects an independently visible link when another element covers its center", () => {
|
|
const { link } = makeLink();
|
|
const obstruction = document.createElement("div");
|
|
document.body.append(obstruction);
|
|
Object.defineProperty(document, "elementFromPoint", {
|
|
configurable: true,
|
|
value: jest.fn(() => obstruction),
|
|
writable: true,
|
|
});
|
|
|
|
expect(() => assertNavigationLinkActionable(readNavigationLinkActionability(link), "related link")).toThrow(
|
|
/center is covered by another element/,
|
|
);
|
|
});
|
|
|
|
test("uses a force click after independent checks instead of entering the perpetual stability waiter", async () => {
|
|
const { link, title } = makeLink();
|
|
Object.defineProperty(document, "elementFromPoint", {
|
|
configurable: true,
|
|
value: jest.fn(() => title),
|
|
writable: true,
|
|
});
|
|
const locator = {
|
|
click: jest.fn(async (options) => {
|
|
if (!options?.force) throw new Error("simulated Playwright stability timeout");
|
|
}),
|
|
evaluate: jest.fn(async (callback, argument) => callback(link, argument)),
|
|
};
|
|
|
|
await expect(
|
|
clickHitTestedNavigationLink(locator, {
|
|
expectedKind: "watch",
|
|
expectedVideoId: "targetvid01",
|
|
label: "related link",
|
|
timeout: 250,
|
|
}),
|
|
).resolves.toMatchObject({ centerHitTarget: true, enabled: true, visible: true });
|
|
|
|
expect(locator.evaluate).toHaveBeenNthCalledWith(1, scrollElementIntoViewAndWaitForPaint, undefined, {
|
|
timeout: 250,
|
|
});
|
|
expect(locator.evaluate).toHaveBeenNthCalledWith(
|
|
2,
|
|
readElementActionability,
|
|
{ includeHref: true, scroll: false },
|
|
{ timeout: 250 },
|
|
);
|
|
expect(locator.click).toHaveBeenCalledTimes(1);
|
|
expect(locator.click).toHaveBeenCalledWith({ force: true, timeout: 250 });
|
|
});
|
|
|
|
test("rejects a link whose target changed after selection without clicking it", async () => {
|
|
const { link, title } = makeLink();
|
|
link.href = "/watch?v=stalevideo1";
|
|
Object.defineProperty(document, "elementFromPoint", {
|
|
configurable: true,
|
|
value: jest.fn(() => title),
|
|
writable: true,
|
|
});
|
|
const locator = {
|
|
click: jest.fn(),
|
|
evaluate: jest.fn(async (callback, argument) => callback(link, argument)),
|
|
};
|
|
|
|
await expect(
|
|
clickHitTestedNavigationLink(locator, {
|
|
expectedKind: "watch",
|
|
expectedVideoId: "targetvid01",
|
|
label: "related link",
|
|
}),
|
|
).rejects.toThrow(/changed to another Watch video/);
|
|
expect(locator.click).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("re-resolves an exact channel target when the first visible duplicate is aria-hidden", async () => {
|
|
const hidden = makeLink();
|
|
const actionable = makeLink();
|
|
const hiddenContainer = document.createElement("div");
|
|
hiddenContainer.setAttribute("aria-hidden", "true");
|
|
hiddenContainer.append(hidden.link);
|
|
document.body.prepend(hiddenContainer);
|
|
actionable.link.getBoundingClientRect.mockReturnValue({
|
|
bottom: 140,
|
|
height: 40,
|
|
left: 400,
|
|
right: 600,
|
|
top: 100,
|
|
width: 200,
|
|
x: 400,
|
|
y: 100,
|
|
});
|
|
Object.defineProperty(document, "elementFromPoint", {
|
|
configurable: true,
|
|
value: jest.fn((x) => (x < 400 ? hidden.title : actionable.title)),
|
|
writable: true,
|
|
});
|
|
const candidates = [hidden, actionable].map(({ link }) => ({
|
|
click: jest.fn(),
|
|
evaluate: jest.fn(async (callback, argument) => callback(link, argument)),
|
|
isVisible: jest.fn(async () => true),
|
|
}));
|
|
candidates[1].click.mockResolvedValue(undefined);
|
|
const collection = {
|
|
count: jest.fn(async () => candidates.length),
|
|
nth: jest.fn((index) => candidates[index]),
|
|
};
|
|
const page = {
|
|
locator: jest.fn(() => collection),
|
|
url: jest.fn(() => "https://www.youtube.com/@channel"),
|
|
};
|
|
|
|
await expect(
|
|
clickStabilizedExactVideoLink(page, "targetvid01", "watch", { pollInterval: 0, timeout: 100 }),
|
|
).resolves.toMatchObject({ centerHitTarget: true, visible: true });
|
|
|
|
expect(candidates[0].click).not.toHaveBeenCalled();
|
|
expect(candidates[1].click).toHaveBeenCalledTimes(1);
|
|
expect(candidates[1].click).toHaveBeenCalledWith({ force: true, timeout: expect.any(Number) });
|
|
});
|
|
|
|
test("retries the freshly resolved exact channel target while hydration opacity settles", async () => {
|
|
const { link, title } = makeLink();
|
|
const container = document.createElement("div");
|
|
container.style.opacity = "0";
|
|
container.append(link);
|
|
document.body.append(container);
|
|
Object.defineProperty(document, "elementFromPoint", {
|
|
configurable: true,
|
|
value: jest.fn(() => title),
|
|
writable: true,
|
|
});
|
|
let actionabilityReads = 0;
|
|
const candidate = {
|
|
click: jest.fn(async () => undefined),
|
|
evaluate: jest.fn(async (callback, argument) => {
|
|
const result = callback(link, argument);
|
|
if (callback === readElementActionability) {
|
|
actionabilityReads += 1;
|
|
if (actionabilityReads === 1) container.style.opacity = "1";
|
|
}
|
|
return result;
|
|
}),
|
|
isVisible: jest.fn(async () => true),
|
|
};
|
|
const collection = {
|
|
count: jest.fn(async () => 1),
|
|
nth: jest.fn(() => candidate),
|
|
};
|
|
const page = {
|
|
locator: jest.fn(() => collection),
|
|
url: jest.fn(() => "https://www.youtube.com/@channel"),
|
|
};
|
|
|
|
await expect(
|
|
clickStabilizedExactVideoLink(page, "targetvid01", "watch", { pollInterval: 0, timeout: 100 }),
|
|
).resolves.toMatchObject({ centerHitTarget: true, visible: true });
|
|
|
|
expect(actionabilityReads).toBe(2);
|
|
expect(collection.count).toHaveBeenCalledTimes(1);
|
|
expect(candidate.click).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
test("waits for a transient channel-header avatar overlay to clear without scrolling the exact Short again", async () => {
|
|
const { link, title } = makeLink();
|
|
link.href = "/shorts/targetvid01";
|
|
const headerAvatarOverlay = document.createElement("div");
|
|
headerAvatarOverlay.className = "ytSpecAvatarShapeImageOverlays ytSpecAvatarShapeImage";
|
|
document.body.append(headerAvatarOverlay);
|
|
let hitReadsSinceScroll = 0;
|
|
link.scrollIntoView.mockImplementation(() => {
|
|
hitReadsSinceScroll = 0;
|
|
});
|
|
Object.defineProperty(document, "elementFromPoint", {
|
|
configurable: true,
|
|
value: jest.fn(() => (++hitReadsSinceScroll === 1 ? headerAvatarOverlay : title)),
|
|
writable: true,
|
|
});
|
|
const candidate = {
|
|
click: jest.fn(async () => undefined),
|
|
evaluate: jest.fn(async (callback, argument) => callback(link, argument)),
|
|
isVisible: jest.fn(async () => true),
|
|
};
|
|
const collection = {
|
|
count: jest.fn(async () => 1),
|
|
nth: jest.fn(() => candidate),
|
|
};
|
|
const page = {
|
|
locator: jest.fn(() => collection),
|
|
url: jest.fn(() => "https://www.youtube.com/@channel"),
|
|
};
|
|
|
|
await expect(
|
|
clickStabilizedExactVideoLink(page, "targetvid01", "short", { pollInterval: 0, timeout: 250 }),
|
|
).resolves.toMatchObject({ centerHitTarget: true, href: "/shorts/targetvid01" });
|
|
|
|
expect(link.scrollIntoView).toHaveBeenCalledTimes(1);
|
|
expect(document.elementFromPoint).toHaveBeenCalledTimes(2);
|
|
expect(candidate.click).toHaveBeenCalledTimes(1);
|
|
expect(candidate.click).toHaveBeenCalledWith({ force: true, timeout: expect.any(Number) });
|
|
});
|
|
|
|
test("cold channel-to-Watch navigation does not retain the preliminary hidden duplicate", async () => {
|
|
const hidden = makeLink();
|
|
const actionable = makeLink();
|
|
const hiddenContainer = document.createElement("div");
|
|
hiddenContainer.setAttribute("aria-hidden", "true");
|
|
hiddenContainer.append(hidden.link);
|
|
document.body.prepend(hiddenContainer);
|
|
actionable.link.getBoundingClientRect.mockReturnValue({
|
|
bottom: 140,
|
|
height: 40,
|
|
left: 400,
|
|
right: 600,
|
|
top: 100,
|
|
width: 200,
|
|
x: 400,
|
|
y: 100,
|
|
});
|
|
Object.defineProperty(document, "elementFromPoint", {
|
|
configurable: true,
|
|
value: jest.fn((x) => (x < 400 ? hidden.title : actionable.title)),
|
|
writable: true,
|
|
});
|
|
let currentUrl = "https://www.youtube.com/@channel";
|
|
const candidates = [hidden, actionable].map(({ link }, index) => ({
|
|
click: jest.fn(async () => {
|
|
if (index === 1) currentUrl = "https://www.youtube.com/watch?v=targetvid01";
|
|
}),
|
|
evaluate: jest.fn(async (callback, argument) => callback(link, argument)),
|
|
isVisible: jest.fn(async () => true),
|
|
}));
|
|
const collection = {
|
|
count: jest.fn(async () => candidates.length),
|
|
nth: jest.fn((index) => candidates[index]),
|
|
};
|
|
const page = {
|
|
evaluate: jest.fn(async (callback) => callback()),
|
|
goto: jest.fn(async (url) => {
|
|
currentUrl = url;
|
|
}),
|
|
locator: jest.fn(() => collection),
|
|
reload: jest.fn(async () => undefined),
|
|
setDefaultNavigationTimeout: jest.fn(),
|
|
setDefaultTimeout: jest.fn(),
|
|
url: jest.fn(() => currentUrl),
|
|
waitForURL: jest.fn(async (predicate, options) => {
|
|
expect(options).toEqual({ timeout: 5_000, waitUntil: "commit" });
|
|
for (let attempt = 0; attempt < 100; attempt += 1) {
|
|
if (predicate(new URL(currentUrl))) return;
|
|
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
}
|
|
throw new Error("navigation did not complete");
|
|
}),
|
|
};
|
|
const driver = new LiveYoutubeDriver(page, {});
|
|
driver.withExactVotesResponse = jest.fn(async (videoId, action) => ({
|
|
body: { dislikes: 2, id: videoId, likes: 3 },
|
|
result: await action(),
|
|
status: 200,
|
|
videoId,
|
|
}));
|
|
driver.waitForVideo = jest.fn(async () => undefined);
|
|
|
|
await expect(
|
|
driver.navigateFromColdChannelToWatch("https://www.youtube.com/@channel", "targetvid01"),
|
|
).resolves.toMatchObject({ status: 200, videoId: "targetvid01" });
|
|
|
|
expect(candidates[0].click).not.toHaveBeenCalled();
|
|
expect(candidates[1].click).toHaveBeenCalledTimes(1);
|
|
expect(page.goto).toHaveBeenCalledWith("https://www.youtube.com/@channel/videos", {
|
|
waitUntil: "domcontentloaded",
|
|
});
|
|
expect(driver.waitForVideo).toHaveBeenCalledWith("targetvid01");
|
|
});
|
|
|
|
test("retries an ignored exact Watch pointer activation once with a freshly validated keyboard activation", async () => {
|
|
const { link, title } = makeLink();
|
|
Object.defineProperty(document, "elementFromPoint", {
|
|
configurable: true,
|
|
value: jest.fn(() => title),
|
|
writable: true,
|
|
});
|
|
let currentUrl = "https://www.youtube.com/@channel";
|
|
const candidate = {
|
|
click: jest.fn(async () => undefined),
|
|
evaluate: jest.fn(async (callback, argument) => callback(link, argument)),
|
|
focus: jest.fn(async () => undefined),
|
|
isVisible: jest.fn(async () => true),
|
|
press: jest.fn(async (key) => {
|
|
expect(key).toBe("Enter");
|
|
currentUrl = "https://www.youtube.com/watch?v=targetvid01";
|
|
}),
|
|
};
|
|
const collection = {
|
|
count: jest.fn(async () => 1),
|
|
nth: jest.fn(() => candidate),
|
|
};
|
|
let navigationAttempt = 0;
|
|
const page = {
|
|
evaluate: jest.fn(async (callback) => callback()),
|
|
goto: jest.fn(async (url) => {
|
|
currentUrl = url;
|
|
}),
|
|
locator: jest.fn(() => collection),
|
|
reload: jest.fn(async () => undefined),
|
|
setDefaultNavigationTimeout: jest.fn(),
|
|
setDefaultTimeout: jest.fn(),
|
|
url: jest.fn(() => currentUrl),
|
|
waitForURL: jest.fn(async (predicate, options) => {
|
|
navigationAttempt += 1;
|
|
expect(options).toEqual({
|
|
timeout: navigationAttempt === 1 ? 5_000 : 25_000,
|
|
waitUntil: "commit",
|
|
});
|
|
if (navigationAttempt === 1) throw new Error("pointer activation was ignored");
|
|
for (let attempt = 0; attempt < 100; attempt += 1) {
|
|
if (predicate(new URL(currentUrl))) return;
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
}
|
|
throw new Error("keyboard navigation did not complete");
|
|
}),
|
|
};
|
|
const reportProgress = jest.fn();
|
|
const driver = new LiveYoutubeDriver(page, {}, { reportProgress });
|
|
driver.withExactVotesResponse = jest.fn(async (videoId, action) => ({
|
|
body: { dislikes: 2, id: videoId, likes: 3 },
|
|
result: await action(),
|
|
status: 200,
|
|
videoId,
|
|
}));
|
|
driver.waitForVideo = jest.fn(async () => undefined);
|
|
|
|
await expect(
|
|
driver.navigateFromColdChannelToWatch("https://www.youtube.com/@channel/shorts", "targetvid01"),
|
|
).resolves.toMatchObject({ status: 200, videoId: "targetvid01" });
|
|
|
|
expect(page.goto).toHaveBeenCalledWith("https://www.youtube.com/@channel/videos", {
|
|
waitUntil: "domcontentloaded",
|
|
});
|
|
expect(candidate.click).toHaveBeenCalledTimes(1);
|
|
expect(candidate.click).toHaveBeenCalledWith({ timeout: expect.any(Number) });
|
|
expect(candidate.focus).toHaveBeenCalledTimes(1);
|
|
expect(candidate.press).toHaveBeenCalledTimes(1);
|
|
expect(candidate.press).toHaveBeenCalledWith("Enter", { timeout: expect.any(Number) });
|
|
expect(reportProgress).toHaveBeenCalledWith("cold-channel.target-link.retrying", {
|
|
firstFailure: "pointer activation was ignored",
|
|
firstTimeoutMs: 5_000,
|
|
kind: "watch",
|
|
retryTimeoutMs: 25_000,
|
|
videoId: "targetvid01",
|
|
});
|
|
expect(driver.waitForVideo).toHaveBeenCalledWith("targetvid01");
|
|
});
|
|
|
|
test("keeps the strict center hit test when an avatar overlay persistently covers an exact Short target", async () => {
|
|
const { link } = makeLink();
|
|
link.href = "/shorts/targetvid01";
|
|
const obstruction = document.createElement("div");
|
|
obstruction.className = "ytSpecAvatarShapeImageOverlays ytSpecAvatarShapeImage";
|
|
document.body.append(obstruction);
|
|
Object.defineProperty(document, "elementFromPoint", {
|
|
configurable: true,
|
|
value: jest.fn(() => obstruction),
|
|
writable: true,
|
|
});
|
|
const candidate = {
|
|
click: jest.fn(),
|
|
evaluate: jest.fn(async (callback, argument) => callback(link, argument)),
|
|
isVisible: jest.fn(async () => true),
|
|
};
|
|
const collection = {
|
|
count: jest.fn(async () => 1),
|
|
nth: jest.fn(() => candidate),
|
|
};
|
|
const page = {
|
|
locator: jest.fn(() => collection),
|
|
url: jest.fn(() => "https://www.youtube.com/@channel"),
|
|
};
|
|
|
|
await expect(
|
|
clickStabilizedExactVideoLink(page, "targetvid01", "short", { pollInterval: 1, timeout: 20 }),
|
|
).rejects.toThrow(/Timed out waiting for an actionable exact short link.*center is covered/);
|
|
expect(link.scrollIntoView).toHaveBeenCalledTimes(1);
|
|
expect(candidate.click).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("related-watch navigation re-resolves a hidden duplicate and keeps the SPA oracle", async () => {
|
|
const currentVideoId = "abcdefghijk";
|
|
const destinationVideoId = "targetvid01";
|
|
let currentUrl = `https://www.youtube.com/watch?v=${currentVideoId}`;
|
|
const hidden = makeLink();
|
|
const { link, title } = makeLink();
|
|
const hiddenContainer = document.createElement("div");
|
|
hiddenContainer.setAttribute("aria-hidden", "true");
|
|
hiddenContainer.append(hidden.link);
|
|
document.body.prepend(hiddenContainer);
|
|
link.getBoundingClientRect.mockReturnValue({
|
|
bottom: 140,
|
|
height: 40,
|
|
left: 400,
|
|
right: 600,
|
|
top: 100,
|
|
width: 200,
|
|
x: 400,
|
|
y: 100,
|
|
});
|
|
Object.defineProperty(document, "elementFromPoint", {
|
|
configurable: true,
|
|
value: jest.fn((x) => (x < 400 ? hidden.title : title)),
|
|
writable: true,
|
|
});
|
|
const hiddenLocator = {
|
|
click: jest.fn(),
|
|
evaluate: jest.fn(async (callback, argument) => callback(hidden.link, argument)),
|
|
isVisible: jest.fn(async () => true),
|
|
};
|
|
const linkLocator = {
|
|
click: jest.fn(async (options) => {
|
|
if (!options?.force) throw new Error("simulated Playwright stability timeout");
|
|
currentUrl = `https://www.youtube.com/watch?v=${destinationVideoId}`;
|
|
}),
|
|
evaluate: jest.fn(async (callback, argument) => callback(link, argument)),
|
|
isVisible: jest.fn(async () => true),
|
|
};
|
|
const candidates = [hiddenLocator, linkLocator];
|
|
const relatedLocator = {
|
|
count: jest.fn(async () => candidates.length),
|
|
nth: jest.fn((index) => candidates[index]),
|
|
};
|
|
const page = {
|
|
evaluate: jest.fn(async (callback) => callback()),
|
|
locator: jest.fn(() => relatedLocator),
|
|
setDefaultNavigationTimeout: jest.fn(),
|
|
setDefaultTimeout: jest.fn(),
|
|
url: jest.fn(() => currentUrl),
|
|
waitForURL: jest.fn(async (predicate, options) => {
|
|
expect(options).toEqual({ waitUntil: "commit" });
|
|
for (let attempt = 0; attempt < 100; attempt += 1) {
|
|
if (predicate(new URL(currentUrl))) return;
|
|
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
}
|
|
throw new Error("navigation did not complete");
|
|
}),
|
|
};
|
|
const driver = new LiveYoutubeDriver(page, {});
|
|
driver.withExactVotesResponse = jest.fn(async (videoId, action) => ({
|
|
body: { id: videoId },
|
|
result: await action(),
|
|
status: 200,
|
|
videoId,
|
|
}));
|
|
driver.waitForVideo = jest.fn(async () => undefined);
|
|
|
|
await expect(driver.navigateToRelatedWatch()).resolves.toMatchObject({
|
|
status: 200,
|
|
videoId: destinationVideoId,
|
|
});
|
|
|
|
expect(hiddenLocator.click).not.toHaveBeenCalled();
|
|
expect(linkLocator.click).toHaveBeenCalledWith({ force: true, timeout: 5_000 });
|
|
expect(driver.withExactVotesResponse).toHaveBeenCalledWith(destinationVideoId, expect.any(Function));
|
|
expect(driver.waitForVideo).toHaveBeenCalledWith(destinationVideoId);
|
|
expect(page.url()).toBe(`https://www.youtube.com/watch?v=${destinationVideoId}`);
|
|
});
|
|
|
|
test("playlist SPA navigation settles a transient overlay before selecting and clicking its unvisited target", async () => {
|
|
const currentVideoId = "abcdefghijk";
|
|
const destinationVideoId = "targetvid01";
|
|
let currentUrl = `https://www.youtube.com/watch?v=${currentVideoId}&list=RDabcdefghijk`;
|
|
const current = makeLink();
|
|
current.link.href = `/watch?v=${currentVideoId}&list=RDabcdefghijk`;
|
|
const destination = makeLink();
|
|
destination.link.href = `/watch?v=${destinationVideoId}&list=RDabcdefghijk`;
|
|
destination.link.getBoundingClientRect.mockReturnValue({
|
|
bottom: 140,
|
|
height: 40,
|
|
left: 400,
|
|
right: 600,
|
|
top: 100,
|
|
width: 200,
|
|
x: 400,
|
|
y: 100,
|
|
});
|
|
const headerAvatarOverlay = document.createElement("div");
|
|
headerAvatarOverlay.className = "ytSpecAvatarShapeImageOverlays ytSpecAvatarShapeImage";
|
|
document.body.append(headerAvatarOverlay);
|
|
let destinationHitReadsSinceScroll = 0;
|
|
destination.link.scrollIntoView.mockImplementation(() => {
|
|
destinationHitReadsSinceScroll = 0;
|
|
});
|
|
Object.defineProperty(document, "elementFromPoint", {
|
|
configurable: true,
|
|
value: jest.fn((x) => {
|
|
if (x < 400) return current.title;
|
|
destinationHitReadsSinceScroll += 1;
|
|
return destinationHitReadsSinceScroll === 1 ? headerAvatarOverlay : destination.title;
|
|
}),
|
|
writable: true,
|
|
});
|
|
const candidates = [current, destination].map(({ link }, index) => ({
|
|
click: jest.fn(async (options) => {
|
|
expect(options.force).toBe(true);
|
|
if (index === 1) {
|
|
currentUrl = `https://www.youtube.com/watch?v=${destinationVideoId}&list=RDabcdefghijk`;
|
|
}
|
|
}),
|
|
evaluate: jest.fn(async (callback, argument) => callback(link, argument)),
|
|
isVisible: jest.fn(async () => true),
|
|
}));
|
|
const collection = {
|
|
count: jest.fn(async () => candidates.length),
|
|
nth: jest.fn((index) => candidates[index]),
|
|
};
|
|
const page = {
|
|
evaluate: jest.fn(async (callback) => callback()),
|
|
locator: jest.fn(() => collection),
|
|
setDefaultNavigationTimeout: jest.fn(),
|
|
setDefaultTimeout: jest.fn(),
|
|
url: jest.fn(() => currentUrl),
|
|
waitForURL: jest.fn(async (predicate, options) => {
|
|
expect(options).toEqual({ waitUntil: "commit" });
|
|
for (let attempt = 0; attempt < 100; attempt += 1) {
|
|
if (predicate(new URL(currentUrl))) return;
|
|
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
}
|
|
throw new Error("navigation did not complete");
|
|
}),
|
|
};
|
|
const driver = new LiveYoutubeDriver(page, {});
|
|
driver.withExactVotesResponse = jest.fn(async (videoId, action) => ({
|
|
body: { dislikes: 2, id: videoId, likes: 3 },
|
|
result: await action(),
|
|
status: 200,
|
|
videoId,
|
|
}));
|
|
driver.waitForVideo = jest.fn(async () => undefined);
|
|
|
|
await expect(driver.navigateWithinPlaylist({ excludedVideoIds: ["excluded001"] })).resolves.toMatchObject({
|
|
body: { id: destinationVideoId },
|
|
status: 200,
|
|
videoId: destinationVideoId,
|
|
});
|
|
|
|
expect(candidates[0].click).not.toHaveBeenCalled();
|
|
expect(candidates[1].click).toHaveBeenCalledTimes(1);
|
|
expect(destination.link.scrollIntoView).toHaveBeenCalledTimes(2);
|
|
expect(document.elementFromPoint).toHaveBeenCalledTimes(4);
|
|
expect(driver.withExactVotesResponse).toHaveBeenCalledWith(destinationVideoId, expect.any(Function));
|
|
expect(driver.waitForVideo).toHaveBeenCalledWith(destinationVideoId);
|
|
});
|
|
|
|
test.each([
|
|
["disconnected", (link) => link.remove(), /became disconnected/],
|
|
["hidden", (link) => (link.hidden = true), /is not visibly rendered/],
|
|
["disabled", (link) => link.setAttribute("aria-disabled", "true"), /is disabled/],
|
|
[
|
|
"outside the viewport",
|
|
(link) =>
|
|
link.getBoundingClientRect.mockReturnValue({
|
|
bottom: 940,
|
|
height: 40,
|
|
left: 100,
|
|
right: 300,
|
|
top: 900,
|
|
width: 200,
|
|
x: 100,
|
|
y: 900,
|
|
}),
|
|
/center is outside the viewport/,
|
|
],
|
|
])("rejects a %s link before forcing a click", async (_description, mutate, expectedError) => {
|
|
const { link, title } = makeLink();
|
|
Object.defineProperty(document, "elementFromPoint", {
|
|
configurable: true,
|
|
value: jest.fn(() => title),
|
|
writable: true,
|
|
});
|
|
mutate(link);
|
|
const locator = {
|
|
click: jest.fn(),
|
|
evaluate: jest.fn(async (callback, argument) => callback(link, argument)),
|
|
};
|
|
|
|
await expect(clickHitTestedNavigationLink(locator, { label: "related link", timeout: 20 })).rejects.toThrow(
|
|
expectedError,
|
|
);
|
|
expect(locator.click).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("hit-tested live control activation", () => {
|
|
function makeButton() {
|
|
const button = document.createElement("button");
|
|
button.textContent = "Action";
|
|
document.body.append(button);
|
|
button.scrollIntoView = jest.fn();
|
|
button.getBoundingClientRect = jest.fn(() => ({
|
|
bottom: 140,
|
|
height: 40,
|
|
left: 100,
|
|
right: 180,
|
|
top: 100,
|
|
width: 80,
|
|
x: 100,
|
|
y: 100,
|
|
}));
|
|
Object.defineProperty(window, "innerHeight", { configurable: true, value: 720 });
|
|
Object.defineProperty(window, "innerWidth", { configurable: true, value: 1280 });
|
|
return button;
|
|
}
|
|
|
|
test("forces a trusted click only after explicit visibility, enabled, viewport, and hit-target checks", async () => {
|
|
const button = makeButton();
|
|
Object.defineProperty(document, "elementFromPoint", {
|
|
configurable: true,
|
|
value: jest.fn(() => button),
|
|
writable: true,
|
|
});
|
|
const beforeClick = jest.fn();
|
|
const locator = {
|
|
click: jest.fn(async (options) => {
|
|
if (!options.force) throw new Error("simulated Playwright stability timeout");
|
|
}),
|
|
evaluate: jest.fn(async (callback, argument) => callback(button, argument)),
|
|
};
|
|
|
|
await expect(
|
|
clickHitTestedElement(locator, { beforeClick, label: "reaction control", timeout: 321 }),
|
|
).resolves.toMatchObject({ centerHitTarget: true, centerInViewport: true, enabled: true, visible: true });
|
|
|
|
expect(button.scrollIntoView).toHaveBeenCalledWith({ block: "center", inline: "nearest" });
|
|
expect(beforeClick).toHaveBeenCalledTimes(1);
|
|
expect(locator.click).toHaveBeenCalledWith({ force: true, timeout: 321 });
|
|
});
|
|
|
|
test("does not force a click when the independently hit-tested center is obstructed", async () => {
|
|
const button = makeButton();
|
|
const obstruction = document.createElement("div");
|
|
document.body.append(obstruction);
|
|
Object.defineProperty(document, "elementFromPoint", {
|
|
configurable: true,
|
|
value: jest.fn(() => obstruction),
|
|
writable: true,
|
|
});
|
|
const locator = {
|
|
click: jest.fn(),
|
|
evaluate: jest.fn(async (callback, argument) => callback(button, argument)),
|
|
};
|
|
|
|
await expect(clickHitTestedElement(locator, { label: "More-actions control" })).rejects.toThrow(
|
|
/center is covered by another element/,
|
|
);
|
|
expect(locator.click).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("uses DOM scrolling and explicit viewport state for non-interactive visual measurements", async () => {
|
|
const button = makeButton();
|
|
Object.defineProperty(document, "elementFromPoint", {
|
|
configurable: true,
|
|
value: jest.fn(() => null),
|
|
writable: true,
|
|
});
|
|
const locator = {
|
|
evaluate: jest.fn(async (callback, argument) => callback(button, argument)),
|
|
scrollIntoViewIfNeeded: jest.fn(() => {
|
|
throw new Error("must not enter Playwright's stability waiter");
|
|
}),
|
|
};
|
|
|
|
await expect(prepareElementForViewportMeasurement(locator, "ratio bar")).resolves.toMatchObject({
|
|
centerInViewport: true,
|
|
connected: true,
|
|
visible: true,
|
|
});
|
|
expect(locator.scrollIntoViewIfNeeded).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test.each([
|
|
["disconnected", { connected: false }, /became disconnected/],
|
|
["hidden", { visible: false }, /not visibly rendered/],
|
|
["disabled", { enabled: false }, /is disabled/],
|
|
["offscreen", { centerInViewport: false }, /center is outside the viewport/],
|
|
["covered", { centerHitTarget: false }, /center is covered/],
|
|
])("rejects a %s live control before activation", (_label, override, expectedError) => {
|
|
expect(() =>
|
|
assertElementActionable(
|
|
{
|
|
centerHitTarget: true,
|
|
centerInViewport: true,
|
|
connected: true,
|
|
enabled: true,
|
|
visible: true,
|
|
...override,
|
|
},
|
|
"live control",
|
|
),
|
|
).toThrow(expectedError);
|
|
});
|
|
|
|
test("rejects a non-interactive measurement target outside the viewport", () => {
|
|
expect(() =>
|
|
assertElementReadyForViewportMeasurement({ centerInViewport: false, connected: true, visible: true }, "top row"),
|
|
).toThrow(/center is outside the viewport/);
|
|
});
|
|
});
|
|
|
|
describe("bound authenticated session proof", () => {
|
|
function createSignedInDriver(documentState, authenticatedHandle = "@expected.handle") {
|
|
const page = {
|
|
evaluate: jest.fn(async () => documentState),
|
|
locator: jest.fn(() => {
|
|
throw new Error("assertSignedIn must not reopen the account menu");
|
|
}),
|
|
setDefaultNavigationTimeout: jest.fn(),
|
|
setDefaultTimeout: jest.fn(),
|
|
};
|
|
return { driver: new LiveYoutubeDriver(page, {}, { authenticatedHandle }), page };
|
|
}
|
|
|
|
test("uses the context-selected handle and current ytcfg login flag without reopening the account menu", async () => {
|
|
const { driver, page } = createSignedInDriver({ committed: true, configuredLoggedIn: true });
|
|
|
|
await expect(driver.assertSignedIn("@EXPECTED.handle")).resolves.toBeUndefined();
|
|
|
|
expect(page.evaluate).toHaveBeenCalledTimes(1);
|
|
expect(page.locator).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("rejects a current page whose ytcfg state is signed out", async () => {
|
|
const { driver } = createSignedInDriver({ committed: true, configuredLoggedIn: false });
|
|
|
|
await expect(driver.assertSignedIn("@expected.handle")).rejects.toThrow(/LOGGED_IN is not true/);
|
|
});
|
|
|
|
test("rejects a requested handle different from the context-selected identity", async () => {
|
|
const { driver, page } = createSignedInDriver({ committed: true, configuredLoggedIn: true });
|
|
|
|
await expect(driver.assertSignedIn("@different.handle")).rejects.toThrow(
|
|
/not bound to the authenticated browser context/,
|
|
);
|
|
expect(page.evaluate).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("live playback diagnostics", () => {
|
|
test("reports that playback was intentionally paused", async () => {
|
|
const videos = [{ pause: jest.fn() }, { pause: jest.fn() }];
|
|
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("Shorts control stability soak", () => {
|
|
function createDriver() {
|
|
const page = {
|
|
setDefaultNavigationTimeout: jest.fn(),
|
|
setDefaultTimeout: jest.fn(),
|
|
};
|
|
const driver = new LiveYoutubeDriver(page, {});
|
|
driver.assertCurrentVideo = jest.fn();
|
|
return driver;
|
|
}
|
|
|
|
test("requires a later ownership/count sample instead of trusting the first rendered control", async () => {
|
|
const driver = createDriver();
|
|
driver.assertCurrentShortsControl = jest
|
|
.fn()
|
|
.mockResolvedValueOnce({ count: "123", videoId: "abcdefghijk" })
|
|
.mockRejectedValueOnce(new Error("control disappeared after navigation"));
|
|
|
|
await expect(
|
|
driver.soakCurrentShortsControl("abcdefghijk", "extension", 123, {
|
|
durationMs: 0,
|
|
intervalMs: 1,
|
|
minimumSamples: 2,
|
|
presenceTimeoutMs: 10,
|
|
}),
|
|
).rejects.toThrow("control disappeared after navigation");
|
|
expect(driver.assertCurrentShortsControl).toHaveBeenCalledTimes(2);
|
|
expect(driver.assertCurrentShortsControl).toHaveBeenNthCalledWith(2, "abcdefghijk", "extension", {
|
|
expectedDislikes: 123,
|
|
presenceTimeoutMs: 10,
|
|
});
|
|
});
|
|
|
|
test("returns evidence from at least two stable samples", async () => {
|
|
const driver = createDriver();
|
|
driver.assertCurrentShortsControl = jest.fn(async () => ({ count: "123", videoId: "abcdefghijk" }));
|
|
|
|
await expect(
|
|
driver.soakCurrentShortsControl("abcdefghijk", "userscript", 123, {
|
|
durationMs: 0,
|
|
intervalMs: 1,
|
|
minimumSamples: 2,
|
|
}),
|
|
).resolves.toMatchObject({ expectedDislikes: 123, sampleCount: 2, videoId: "abcdefghijk" });
|
|
});
|
|
});
|
|
|
|
describe("watch ratio-bar soak", () => {
|
|
test("rejects a current Watch result whose rendered count belongs to another response", async () => {
|
|
const page = {
|
|
evaluate: jest.fn(async () => "en"),
|
|
setDefaultNavigationTimeout: jest.fn(),
|
|
setDefaultTimeout: jest.fn(),
|
|
};
|
|
const driver = new LiveYoutubeDriver(page, {});
|
|
driver.assertCurrentVideo = jest.fn();
|
|
driver.inspectWatchRatioVisual = jest.fn(async () => ({ count: "999" }));
|
|
driver.soakWatchRatioVisual = jest.fn();
|
|
|
|
await expect(
|
|
driver.assertCurrentWatchResult("abcdefghijk", "extension", { dislikes: 123, likes: 456 }),
|
|
).rejects.toThrow(/Rendered dislike count "999" does not represent API count 123/);
|
|
expect(driver.soakWatchRatioVisual).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("rejects a visible ratio result that is not owned by the current Watch root", async () => {
|
|
const emptyLocator = {
|
|
count: jest.fn(async () => 0),
|
|
nth: jest.fn(),
|
|
};
|
|
const currentRoot = { locator: jest.fn(() => emptyLocator) };
|
|
const page = {
|
|
locator: jest.fn(() => currentRoot),
|
|
setDefaultNavigationTimeout: jest.fn(),
|
|
setDefaultTimeout: jest.fn(),
|
|
};
|
|
const driver = new LiveYoutubeDriver(page, {});
|
|
driver.assertCurrentVideo = jest.fn();
|
|
driver.assertRenderedDislikeCount = jest.fn(async () => ({}));
|
|
driver.inspectWatchRatioVisual = jest.fn(async () => ({ count: "123" }));
|
|
driver.soakWatchRatioVisual = jest.fn();
|
|
|
|
await expect(
|
|
driver.assertCurrentWatchResult("abcdefghijk", "userscript", { dislikes: 123, likes: 456 }),
|
|
).rejects.toThrow(/exactly one visible userscript ratio bar owned by current Watch video abcdefghijk; found 0/);
|
|
expect(driver.soakWatchRatioVisual).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("rechecks the current video, bar geometry, and stable count for the requested interval", async () => {
|
|
const page = {
|
|
setDefaultNavigationTimeout: jest.fn(),
|
|
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",
|
|
expectedCounts: { dislikes: 123, likes: 456 },
|
|
intervalMs: 1,
|
|
videoId: "targetvid01",
|
|
});
|
|
|
|
expect(driver.assertCurrentVideo).toHaveBeenCalledWith("targetvid01");
|
|
expect(driver.assertWatchRatioVisual).toHaveBeenCalledWith("userscript", {
|
|
expectedCount: "123",
|
|
expectedCounts: { dislikes: 123, likes: 456 },
|
|
expectedVideoId: "targetvid01",
|
|
});
|
|
expect(result).toEqual({
|
|
count: "123",
|
|
durationMs: 1,
|
|
ratioAudit: undefined,
|
|
sampleCount: driver.assertWatchRatioVisual.mock.calls.length,
|
|
videoId: "targetvid01",
|
|
});
|
|
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();
|
|
});
|
|
|
|
test("waits for both synthetic Shorts icons to paint before measuring their geometry", async () => {
|
|
const readyIdentity = {
|
|
elementRenderedInViewport: true,
|
|
hasReel: true,
|
|
reelRenderedInViewport: true,
|
|
videoMatches: true,
|
|
};
|
|
const count = { evaluate: jest.fn(async () => "123"), first: jest.fn() };
|
|
count.first.mockReturnValue(count);
|
|
const button = {
|
|
evaluate: jest.fn(async () => readyIdentity),
|
|
isVisible: jest.fn(async () => true),
|
|
};
|
|
const buttonList = {
|
|
count: jest.fn(async () => 1),
|
|
nth: jest.fn(() => button),
|
|
};
|
|
const control = {
|
|
evaluate: jest.fn(async () => readyIdentity),
|
|
isVisible: jest.fn(async () => true),
|
|
locator: jest.fn((selector) => (selector === "button" ? buttonList : count)),
|
|
};
|
|
const controls = {
|
|
count: jest.fn(async () => 1),
|
|
nth: jest.fn(() => control),
|
|
};
|
|
const page = {
|
|
locator: jest.fn(() => controls),
|
|
setDefaultNavigationTimeout: jest.fn(),
|
|
setDefaultTimeout: jest.fn(),
|
|
};
|
|
const driver = new LiveYoutubeDriver(page, {});
|
|
const likeButton = { evaluate: jest.fn() };
|
|
const paintFailure = new Error("paint did not complete");
|
|
driver.assertCurrentVideo = jest.fn();
|
|
driver.visibleLikeButton = jest.fn(async () => likeButton);
|
|
driver.waitForShortsVisualPaint = jest.fn(async () => {
|
|
throw paintFailure;
|
|
});
|
|
|
|
await expect(driver.captureSyntheticShortsVisual("abcdefghijk", "synthetic.png")).rejects.toBe(paintFailure);
|
|
|
|
expect(driver.waitForShortsVisualPaint).toHaveBeenCalledWith([likeButton, button]);
|
|
expect(control.evaluate).toHaveBeenCalledTimes(1);
|
|
expect(control.evaluate.mock.calls[0][1]).toEqual({ expectedShortVideoId: "abcdefghijk" });
|
|
expect(likeButton.evaluate).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("waits for both native Shorts icons to paint before measuring their geometry", async () => {
|
|
const page = {
|
|
setDefaultNavigationTimeout: jest.fn(),
|
|
setDefaultTimeout: jest.fn(),
|
|
};
|
|
const driver = new LiveYoutubeDriver(page, {});
|
|
const likeButton = { evaluate: jest.fn() };
|
|
const dislikeButton = { evaluate: jest.fn() };
|
|
const paintFailure = new Error("paint did not complete");
|
|
driver.assertCurrentVideo = jest.fn();
|
|
driver.waitForDislikeText = jest.fn(async () => "123");
|
|
driver.visibleLikeButton = jest.fn(async () => likeButton);
|
|
driver.visibleDislikeButton = jest.fn(async () => dislikeButton);
|
|
driver.waitForShortsVisualPaint = jest.fn(async () => {
|
|
throw paintFailure;
|
|
});
|
|
|
|
await expect(driver.captureNativeShortsVisual("abcdefghijk", "native.png")).rejects.toBe(paintFailure);
|
|
|
|
expect(driver.waitForShortsVisualPaint).toHaveBeenCalledWith([likeButton, dislikeButton]);
|
|
expect(likeButton.evaluate).not.toHaveBeenCalled();
|
|
expect(dislikeButton.evaluate).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test.each([
|
|
["strict-synthetic", "captureSyntheticShortsVisual", "captureNativeShortsVisual"],
|
|
["native-pair", "captureNativeShortsVisual", "captureSyntheticShortsVisual"],
|
|
])(
|
|
"selects the %s Shorts capture by capability, not runtime label",
|
|
async (shortsVisualModel, expected, rejected) => {
|
|
const page = {
|
|
setDefaultNavigationTimeout: jest.fn(),
|
|
setDefaultTimeout: jest.fn(),
|
|
};
|
|
const driver = new LiveYoutubeDriver(page, {});
|
|
driver.assertCurrentVideo = jest.fn();
|
|
driver.assertRenderedDislikeCount = jest.fn(async () => ({}));
|
|
driver.readReactionPressedStates = jest.fn(async () => ({ dislikeState: "false", likeState: "false" }));
|
|
driver.waitForDislikeText = jest.fn(async () => "123");
|
|
driver.captureSyntheticShortsVisual = jest.fn(async () => ({ capture: "synthetic" }));
|
|
driver.captureNativeShortsVisual = jest.fn(async () => ({ capture: "native" }));
|
|
|
|
await driver.captureReactionStateVisual({
|
|
expectedCounts: { dislikes: 123, likes: 456 },
|
|
expectedState: "neutral",
|
|
isShort: true,
|
|
runtime: shortsVisualModel === "strict-synthetic" ? "extension" : "userscript",
|
|
screenshotPath: "reaction.png",
|
|
shortsVisualModel,
|
|
videoId: "abcdefghijk",
|
|
});
|
|
|
|
expect(driver[expected]).toHaveBeenCalledWith("abcdefghijk", "reaction.png");
|
|
expect(driver[rejected]).not.toHaveBeenCalled();
|
|
},
|
|
);
|
|
|
|
test("rejects Shorts reaction evidence without an adapter visual capability", async () => {
|
|
const page = {
|
|
setDefaultNavigationTimeout: jest.fn(),
|
|
setDefaultTimeout: jest.fn(),
|
|
};
|
|
const driver = new LiveYoutubeDriver(page, {});
|
|
driver.assertCurrentVideo = jest.fn();
|
|
driver.assertRenderedDislikeCount = jest.fn(async () => ({}));
|
|
driver.readReactionPressedStates = jest.fn(async () => ({ dislikeState: "false", likeState: "false" }));
|
|
driver.waitForDislikeText = jest.fn(async () => "123");
|
|
|
|
await expect(
|
|
driver.captureReactionStateVisual({
|
|
expectedCounts: { dislikes: 123, likes: 456 },
|
|
expectedState: "neutral",
|
|
isShort: true,
|
|
runtime: "extension",
|
|
screenshotPath: "reaction.png",
|
|
videoId: "abcdefghijk",
|
|
}),
|
|
).rejects.toThrow("Unsupported reaction Shorts visual model: missing");
|
|
});
|
|
});
|
|
|
|
describe("watch reaction screenshot bounds", () => {
|
|
function visibleBoxLocator(measurement, evaluatedValue = null) {
|
|
const locator = {
|
|
boundingBox: jest.fn(async () => measurement),
|
|
count: jest.fn(async () => 1),
|
|
evaluate: jest.fn(async () => evaluatedValue),
|
|
isVisible: jest.fn(async () => true),
|
|
nth: jest.fn(() => locator),
|
|
scrollIntoViewIfNeeded: jest.fn(async () => undefined),
|
|
};
|
|
return locator;
|
|
}
|
|
|
|
function createRatioCaptureDriver({ appearance: appearanceOverrides = {}, fillBox = box(100, 144, 120, 2) } = {}) {
|
|
const viewport = { height: 300, width: 500 };
|
|
const likeBox = box(100, 100, 80, 40);
|
|
const dislikeBox = box(180, 100, 80, 40);
|
|
const containerBox = box(100, 144, 160, 2);
|
|
const wrapperBox = box(100, 140, 160, 62);
|
|
const hitAreaBox = box(100, 138, 160, 20);
|
|
const topRowBox = box(80, 80, 400, 140);
|
|
const like = visibleBoxLocator(likeBox);
|
|
const dislike = visibleBoxLocator(dislikeBox);
|
|
const bar = visibleBoxLocator(fillBox);
|
|
const wrapper = visibleBoxLocator(wrapperBox);
|
|
wrapper.evaluate
|
|
.mockResolvedValueOnce({
|
|
inlineFillWidth: "75%",
|
|
negativeTrackBackgroundImage: "none",
|
|
negativeTrackColor: "rgb(115, 115, 115)",
|
|
negativeTrackOpacity: 1,
|
|
numberLocale: "en-US",
|
|
pageBackgroundColor: "rgb(255, 255, 255)",
|
|
pageBackgroundSource: "body",
|
|
pageTheme: "light",
|
|
tooltipText: "3 / 1",
|
|
wrapperVideoId: "abcdefghijk",
|
|
...appearanceOverrides,
|
|
})
|
|
.mockResolvedValueOnce({ hitArea: hitAreaBox, nearbyActions: [], topRow: topRowBox });
|
|
const container = visibleBoxLocator(containerBox, {
|
|
centerInViewport: true,
|
|
connected: true,
|
|
visible: true,
|
|
});
|
|
container.locator = jest.fn(() => wrapper);
|
|
const page = {
|
|
locator: jest.fn((selector) => (selector === "#return-youtube-dislike-bar-container" ? container : bar)),
|
|
setDefaultNavigationTimeout: jest.fn(),
|
|
setDefaultTimeout: jest.fn(),
|
|
url: jest.fn(() => "https://www.youtube.com/watch?v=abcdefghijk"),
|
|
};
|
|
const driver = new LiveYoutubeDriver(page, {});
|
|
driver.captureCroppedScreenshot = jest.fn(async () => ({}));
|
|
driver.readViewportSize = jest.fn(async () => viewport);
|
|
driver.visibleDislikeButton = jest.fn(async () => dislike);
|
|
driver.visibleLikeButton = jest.fn(async () => like);
|
|
driver.assertRenderedDislikeCount = jest.fn(async () => ({}));
|
|
driver.waitForDislikeText = jest.fn(async () => "1");
|
|
return driver;
|
|
}
|
|
|
|
test("passes the full ratio wrapper to the crop so its bottom label is retained", async () => {
|
|
const viewport = { height: 300, width: 500 };
|
|
const likeBox = box(100, 100, 80, 40);
|
|
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 hitAreaBox = box(100, 138, 160, 20);
|
|
const nearbyActionBox = box(280, 100, 80, 40);
|
|
const topRowBox = box(80, 80, 400, 140);
|
|
const like = visibleBoxLocator(likeBox);
|
|
const dislike = visibleBoxLocator(dislikeBox);
|
|
const bar = visibleBoxLocator(barBox);
|
|
const wrapper = visibleBoxLocator(wrapperBox);
|
|
wrapper.evaluate
|
|
.mockResolvedValueOnce({
|
|
inlineFillWidth: "75%",
|
|
negativeTrackBackgroundImage: "none",
|
|
negativeTrackColor: "rgb(115, 115, 115)",
|
|
negativeTrackOpacity: 1,
|
|
numberLocale: "en-US",
|
|
pageBackgroundColor: "rgb(255, 255, 255)",
|
|
pageBackgroundSource: "body",
|
|
pageTheme: "light",
|
|
tooltipText: "3 / 1",
|
|
wrapperVideoId: "abcdefghijk",
|
|
})
|
|
.mockResolvedValueOnce({
|
|
hitArea: hitAreaBox,
|
|
nearbyActions: [nearbyActionBox],
|
|
topRow: topRowBox,
|
|
});
|
|
const container = visibleBoxLocator(containerBox, {
|
|
centerInViewport: true,
|
|
connected: true,
|
|
visible: true,
|
|
});
|
|
container.locator = jest.fn(() => wrapper);
|
|
const page = {
|
|
locator: jest.fn((selector) => (selector === "#return-youtube-dislike-bar-container" ? container : bar)),
|
|
setDefaultNavigationTimeout: jest.fn(),
|
|
setDefaultTimeout: jest.fn(),
|
|
url: jest.fn(() => "https://www.youtube.com/watch?v=abcdefghijk"),
|
|
};
|
|
const driver = new LiveYoutubeDriver(page, {});
|
|
driver.captureCroppedScreenshot = jest.fn(async (_path, boxes) => croppedScreenshotClip(boxes, viewport));
|
|
driver.readViewportSize = jest.fn(async () => viewport);
|
|
driver.visibleDislikeButton = jest.fn(async () => dislike);
|
|
driver.visibleLikeButton = jest.fn(async () => like);
|
|
driver.assertRenderedDislikeCount = jest.fn(async () => ({}));
|
|
driver.waitForDislikeText = jest.fn(async () => "1");
|
|
|
|
const result = await driver.captureWatchRatioVisual("userscript", "watch-liked.png", {
|
|
expectedCounts: { dislikes: 1, likes: 3 },
|
|
expectedVideoId: "abcdefghijk",
|
|
});
|
|
|
|
expect(driver.captureCroppedScreenshot).toHaveBeenCalledWith("watch-liked.png", [
|
|
topRowBox,
|
|
likeBox,
|
|
dislikeBox,
|
|
containerBox,
|
|
wrapperBox,
|
|
hitAreaBox,
|
|
nearbyActionBox,
|
|
]);
|
|
expect(result.screenshotClip.y + result.screenshotClip.height).toBeGreaterThanOrEqual(
|
|
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(),
|
|
url: jest.fn(() => "https://www.youtube.com/watch?v=abcdefghijk"),
|
|
};
|
|
const driver = new LiveYoutubeDriver(page, {});
|
|
|
|
await expect(driver.captureWatchRatioVisual("userscript", "duplicate.png")).rejects.toThrow(
|
|
"Expected exactly one visible userscript watch ratio bar; found 2.",
|
|
);
|
|
});
|
|
|
|
test.each([
|
|
["stale wrapper owner", { wrapperVideoId: "stalevid001" }, /Watch ratio bar is owned by stalevid001/],
|
|
["wrong API ratio", { inlineFillWidth: "25%" }, /inline width represents 25\.000%, expected 75\.000%/],
|
|
["wrong API tooltip", { tooltipText: "9 / 1" }, /tooltip.*does not exactly match the API counts/],
|
|
[
|
|
"near-invisible negative track",
|
|
{ negativeTrackColor: "rgb(248, 248, 248)" },
|
|
/negative track is effectively invisible/,
|
|
],
|
|
])("rejects a %s before recording Watch evidence", async (_label, appearance, expectedError) => {
|
|
const driver = createRatioCaptureDriver({ appearance });
|
|
|
|
await expect(
|
|
driver.captureWatchRatioVisual("userscript", "invalid.png", {
|
|
expectedCounts: { dislikes: 1, likes: 3 },
|
|
expectedVideoId: "abcdefghijk",
|
|
}),
|
|
).rejects.toThrow(expectedError);
|
|
expect(driver.captureCroppedScreenshot).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("live Shorts Next navigation retry", () => {
|
|
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("rejects the captured 390px failure even when the ratio bar shares the native clipping", () => {
|
|
const container = box(-25.828, 744, 165.428, 2);
|
|
|
|
expect(() => assertWatchRatioViewportAlignment(container, like, dislike, viewport)).toThrow(
|
|
/Watch like control is clipped past the viewport's left edge/,
|
|
);
|
|
});
|
|
|
|
test("rejects native reaction controls clipped past the right edge", () => {
|
|
const rightClippedLike = box(240, 700, 80, 40);
|
|
const rightClippedDislike = box(320, 700, 84, 40);
|
|
const container = box(240, 744, 164, 2);
|
|
|
|
expect(() => assertWatchRatioViewportAlignment(container, rightClippedLike, rightClippedDislike, viewport)).toThrow(
|
|
/Watch dislike control is clipped past the viewport's right edge/,
|
|
);
|
|
});
|
|
|
|
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/,
|
|
);
|
|
});
|
|
|
|
test("accepts only an entirely visible reaction pair and ratio bar", () => {
|
|
const inBoundsLike = box(10, 700, 80, 40);
|
|
const inBoundsDislike = box(90, 700, 85, 40);
|
|
const container = box(10, 744, 165, 2);
|
|
|
|
expect(assertWatchRatioViewportAlignment(container, inBoundsLike, inBoundsDislike, viewport)).toEqual({
|
|
nativeControlsAreHorizontallyClipped: false,
|
|
nativeLeft: 10,
|
|
nativeRight: 175,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("watch ratio surroundings", () => {
|
|
const viewport = { height: 844, width: 390 };
|
|
|
|
test("discovers visible non-reaction controls sharing the current top row", () => {
|
|
document.body.innerHTML = `
|
|
<div id="top-row">
|
|
<button id="owner-action" type="button">Owner action</button>
|
|
<ytd-menu-renderer class="ytd-watch-metadata">
|
|
<div id="top-level-buttons-computed">
|
|
<segmented-like-dislike-button-view-model>
|
|
<like-button-view-model><button type="button">Like</button></like-button-view-model>
|
|
<dislike-button-view-model><button type="button">Dislike</button></dislike-button-view-model>
|
|
</segmented-like-dislike-button-view-model>
|
|
<button-view-model><button type="button">Save</button></button-view-model>
|
|
<div class="ryd-tooltip"><div class="ryd-tooltip-bar-container"></div></div>
|
|
</div>
|
|
</ytd-menu-renderer>
|
|
</div>`;
|
|
Object.defineProperty(window, "innerWidth", { configurable: true, value: viewport.width });
|
|
Object.defineProperty(window, "innerHeight", { configurable: true, value: viewport.height });
|
|
const setBox = (selector, measurement) => {
|
|
document.querySelector(selector).getBoundingClientRect = () => ({
|
|
...measurement,
|
|
bottom: measurement.y + measurement.height,
|
|
left: measurement.x,
|
|
right: measurement.x + measurement.width,
|
|
top: measurement.y,
|
|
});
|
|
};
|
|
setBox("#top-row", box(-26, 690, 427, 154));
|
|
setBox("#owner-action", box(-20, 700, 40, 40));
|
|
setBox("like-button-view-model button", box(12, 700, 90, 40));
|
|
setBox("dislike-button-view-model button", box(102, 700, 90, 40));
|
|
setBox("button-view-model button", box(20, 754, 96, 40));
|
|
setBox(".ryd-tooltip-bar-container", box(12, 742, 180, 20));
|
|
|
|
expect(readWatchRatioSurroundings(document.querySelector(".ryd-tooltip"))).toEqual({
|
|
hitArea: box(12, 742, 180, 20),
|
|
nearbyActions: [box(20, 754, 96, 40)],
|
|
topRow: box(-26, 690, 427, 154),
|
|
});
|
|
});
|
|
|
|
test("rejects a second-row action that encroaches on the ratio bar hit area", () => {
|
|
const surroundings = {
|
|
hitArea: box(12, 742, 180, 20),
|
|
nearbyActions: [box(20, 754, 96, 40)],
|
|
topRow: box(0, 690, 390, 154),
|
|
};
|
|
|
|
expect(() => assertWatchRatioSurroundings(surroundings, viewport)).toThrow(
|
|
/Watch nearby action 1 overlaps the ratio bar hit area/,
|
|
);
|
|
});
|
|
|
|
test("rejects a partially visible adjacent action", () => {
|
|
const surroundings = {
|
|
hitArea: box(12, 742, 180, 20),
|
|
nearbyActions: [box(370, 700, 48, 40)],
|
|
topRow: box(0, 690, 390, 154),
|
|
};
|
|
|
|
expect(() => assertWatchRatioSurroundings(surroundings, viewport)).toThrow(
|
|
/Watch nearby action 1 is clipped past the viewport's right edge/,
|
|
);
|
|
});
|
|
|
|
test("accepts separated in-viewport controls", () => {
|
|
const surroundings = {
|
|
hitArea: box(12, 742, 180, 20),
|
|
nearbyActions: [box(210, 700, 80, 40)],
|
|
topRow: box(-26, 690, 427, 154),
|
|
};
|
|
|
|
expect(assertWatchRatioSurroundings(surroundings, viewport)).toBe(surroundings);
|
|
});
|
|
});
|
|
|
|
describe("watch ratio appearance", () => {
|
|
test("uses the runtime number locale rather than the YouTube document language for tooltip expectations", () => {
|
|
const numberFormat = jest.spyOn(Intl, "NumberFormat").mockImplementation(() => ({
|
|
resolvedOptions: () => ({ locale: "en-US" }),
|
|
}));
|
|
document.documentElement.lang = "ru-RU";
|
|
document.body.style.backgroundColor = "rgb(255, 255, 255)";
|
|
document.body.innerHTML = `
|
|
<div class="ryd-tooltip" data-ryd-video-id="abcdefghijk">
|
|
<div id="return-youtube-dislike-bar-container" style="background: rgb(115, 115, 115)">
|
|
<div id="return-youtube-dislike-bar" style="width: 75%"></div>
|
|
</div>
|
|
<div id="ryd-dislike-tooltip">48,515 / 13</div>
|
|
</div>`;
|
|
const wrapper = document.querySelector(".ryd-tooltip");
|
|
const container = document.querySelector("#return-youtube-dislike-bar-container");
|
|
const bar = document.querySelector("#return-youtube-dislike-bar");
|
|
container.getBoundingClientRect = () => ({ bottom: 102, height: 2, left: 0, right: 200, top: 100 });
|
|
bar.getBoundingClientRect = () => ({ bottom: 102, height: 2, left: 0, right: 150, top: 100 });
|
|
|
|
try {
|
|
const appearance = readWatchRatioAppearance(wrapper, {
|
|
bar: "#return-youtube-dislike-bar",
|
|
container: "#return-youtube-dislike-bar-container",
|
|
tooltip: "#ryd-dislike-tooltip",
|
|
});
|
|
|
|
expect(appearance.numberLocale).toBe("en-US");
|
|
expect(appearance.numberLocale).not.toBe(document.documentElement.lang);
|
|
expect(appearance.tooltipText).toBe("48,515 / 13");
|
|
} finally {
|
|
numberFormat.mockRestore();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("Watch action-topology assertions", () => {
|
|
test("discovers current flexible action siblings without mistaking them for More buttons", () => {
|
|
document.body.innerHTML = `
|
|
<ytd-watch-flexy video-id="abcdefghijk">
|
|
<div id="top-row">
|
|
<ytd-menu-renderer class="ytd-watch-metadata">
|
|
<div id="top-level-buttons-computed">
|
|
<segmented-like-dislike-button-view-model>
|
|
<like-button-view-model><button aria-label="Like"></button></like-button-view-model>
|
|
<dislike-button-view-model><button aria-label="Dislike"></button></dislike-button-view-model>
|
|
</segmented-like-dislike-button-view-model>
|
|
<yt-button-view-model><button aria-label="Share"><span role="text">Share</span></button></yt-button-view-model>
|
|
</div>
|
|
<div id="flexible-item-buttons">
|
|
<yt-button-view-model><button aria-label="Add to playlist"><span role="text">Save</span></button></yt-button-view-model>
|
|
<yt-button-view-model><button aria-label="Thanks"><span role="text">Thanks</span></button></yt-button-view-model>
|
|
</div>
|
|
<yt-button-shape><button aria-label="More actions"></button></yt-button-shape>
|
|
</ytd-menu-renderer>
|
|
</div>
|
|
</ytd-watch-flexy>`;
|
|
const topRow = document.querySelector("#top-row");
|
|
for (const element of document.querySelectorAll("*")) {
|
|
element.getBoundingClientRect = () => ({
|
|
bottom: 140,
|
|
height: 40,
|
|
left: 10,
|
|
right: 110,
|
|
top: 100,
|
|
width: 100,
|
|
x: 10,
|
|
y: 100,
|
|
});
|
|
}
|
|
|
|
const topology = readWatchTopRowTopology(topRow, "abcdefghijk");
|
|
|
|
expect(topology.counts).toMatchObject({
|
|
dislikeButtons: 1,
|
|
likeButtons: 1,
|
|
moreButtons: 1,
|
|
reactionGroups: 1,
|
|
});
|
|
expect(topology.topLevelOptionalSignatures).toEqual(["Share", "Save", "Thanks"]);
|
|
});
|
|
|
|
function validTopology() {
|
|
return {
|
|
boxes: {
|
|
actionSurface: box(600, 520, 500, 50),
|
|
dislikeButton: box(680, 525, 80, 40),
|
|
likeButton: box(600, 525, 80, 40),
|
|
menu: box(590, 510, 570, 70),
|
|
moreButton: box(1110, 525, 40, 40),
|
|
reactionGroup: box(600, 525, 160, 40),
|
|
topLevelActionHosts: [box(770, 525, 80, 40)],
|
|
topRow: box(10, 500, 1180, 100),
|
|
},
|
|
counts: {
|
|
dislikeButtons: 1,
|
|
likeButtons: 1,
|
|
moreButtons: 1,
|
|
reactionGroups: 1,
|
|
visibleCurrentWatchRoots: 1,
|
|
visibleMenus: 1,
|
|
visibleOverflowMenus: 1,
|
|
visibleSurfaces: 1,
|
|
visibleTopRows: 1,
|
|
},
|
|
inventorySignatures: ["Report", "Save", "Share"],
|
|
overflowBox: box(850, 200, 250, 180),
|
|
overflowItemBoxes: [box(860, 210, 230, 50), box(860, 270, 230, 50)],
|
|
overflowSignatures: ["Save", "Report"],
|
|
topLevelOptionalSignatures: ["Share"],
|
|
videoId: "abcdefghijk",
|
|
videoMatches: true,
|
|
viewport: { height: 800, width: 1200 },
|
|
};
|
|
}
|
|
|
|
function createActionTopologyDriver({ keyboardOpensPopup = true, pointerOpensPopup = true } = {}) {
|
|
const topology = validTopology();
|
|
let popupVisible = false;
|
|
const reportProgress = jest.fn();
|
|
const actionable = {
|
|
centerHitTarget: true,
|
|
centerInViewport: true,
|
|
connected: true,
|
|
enabled: true,
|
|
visible: true,
|
|
};
|
|
const moreButton = {
|
|
click: jest.fn(async (options) => {
|
|
if (!options?.force) throw new Error("simulated stability timeout");
|
|
if (pointerOpensPopup) popupVisible = true;
|
|
}),
|
|
evaluate: jest.fn(async (callback) => (callback === readElementActionability ? actionable : true)),
|
|
focus: jest.fn(async () => undefined),
|
|
isEnabled: jest.fn(async () => true),
|
|
isVisible: jest.fn(async () => true),
|
|
press: jest.fn(async (key) => {
|
|
if (key === "Enter" && keyboardOpensPopup) popupVisible = true;
|
|
}),
|
|
};
|
|
const menuButtons = {
|
|
count: jest.fn(async () => 1),
|
|
nth: jest.fn(() => moreButton),
|
|
};
|
|
const menu = {
|
|
count: jest.fn(async () => 1),
|
|
isVisible: jest.fn(async () => true),
|
|
locator: jest.fn(() => menuButtons),
|
|
nth: jest.fn(() => menu),
|
|
};
|
|
const topRow = {
|
|
evaluate: jest.fn(async (callback) =>
|
|
callback === readElementActionability
|
|
? actionable
|
|
: {
|
|
boxes: topology.boxes,
|
|
counts: topology.counts,
|
|
topLevelOptionalSignatures: topology.topLevelOptionalSignatures,
|
|
videoId: topology.videoId,
|
|
videoMatches: topology.videoMatches,
|
|
},
|
|
),
|
|
isVisible: jest.fn(async () => true),
|
|
locator: jest.fn(() => menu),
|
|
};
|
|
const topRows = {
|
|
count: jest.fn(async () => 1),
|
|
nth: jest.fn(() => topRow),
|
|
};
|
|
const watchRoot = { isVisible: jest.fn(async () => true), locator: jest.fn(() => topRows) };
|
|
const watchRoots = {
|
|
count: jest.fn(async () => 1),
|
|
nth: jest.fn(() => watchRoot),
|
|
};
|
|
const popup = {
|
|
evaluate: jest.fn(async () => ({
|
|
box: topology.overflowBox,
|
|
itemBoxes: topology.overflowItemBoxes,
|
|
signatures: topology.overflowSignatures,
|
|
})),
|
|
isVisible: jest.fn(async () => popupVisible),
|
|
};
|
|
const popups = {
|
|
evaluateAll: jest.fn(async () => (popupVisible ? [0] : [])),
|
|
nth: jest.fn(() => popup),
|
|
};
|
|
const page = {
|
|
evaluate: jest.fn(async () => undefined),
|
|
keyboard: {
|
|
press: jest.fn(async () => {
|
|
popupVisible = false;
|
|
}),
|
|
},
|
|
locator: jest.fn((selector) =>
|
|
selector.startsWith('ytd-watch-flexy[video-id="abcdefghijk"]') ? watchRoots : popups,
|
|
),
|
|
setDefaultNavigationTimeout: jest.fn(),
|
|
setDefaultTimeout: jest.fn(),
|
|
url: jest.fn(() => "https://www.youtube.com/watch?v=abcdefghijk"),
|
|
};
|
|
const driver = new LiveYoutubeDriver(page, {}, { reportProgress });
|
|
driver.inspectWatchRatioVisual = jest.fn(async () => ({}));
|
|
driver.readViewportSize = jest.fn(async () => topology.viewport);
|
|
|
|
return { driver, moreButton, page, reportProgress, topology };
|
|
}
|
|
|
|
test("opens More through independent hit testing without Playwright stability scrolling", async () => {
|
|
const { driver, moreButton, page, topology } = createActionTopologyDriver();
|
|
|
|
await expect(driver.inspectWatchActionTopology("extension", { presenceTimeoutMs: 100 })).resolves.toMatchObject({
|
|
inventorySignatures: topology.inventorySignatures,
|
|
overflowActivation: "pointer",
|
|
overflowSignatures: topology.overflowSignatures,
|
|
topLevelOptionalSignatures: topology.topLevelOptionalSignatures,
|
|
});
|
|
|
|
expect(moreButton.click).toHaveBeenCalledWith({ force: true, timeout: 100 });
|
|
expect(moreButton.focus).not.toHaveBeenCalled();
|
|
expect(moreButton.press).not.toHaveBeenCalled();
|
|
expect(page.keyboard.press).toHaveBeenCalledWith("Escape");
|
|
});
|
|
|
|
test("re-resolves More and retries once with a hit-tested keyboard activation when YouTube ignores the pointer click", async () => {
|
|
const { driver, moreButton, page, reportProgress, topology } = createActionTopologyDriver({
|
|
pointerOpensPopup: false,
|
|
});
|
|
|
|
await expect(driver.inspectWatchActionTopology("extension", { presenceTimeoutMs: 250 })).resolves.toMatchObject({
|
|
inventorySignatures: topology.inventorySignatures,
|
|
overflowActivation: "keyboard-retry",
|
|
overflowSignatures: topology.overflowSignatures,
|
|
});
|
|
|
|
expect(moreButton.click).toHaveBeenCalledTimes(1);
|
|
expect(moreButton.focus).toHaveBeenCalledTimes(1);
|
|
expect(moreButton.press).toHaveBeenCalledWith("Enter", { timeout: 250 });
|
|
expect(
|
|
page.locator.mock.calls.filter(([selector]) => selector.startsWith('ytd-watch-flexy[video-id="abcdefghijk"]')),
|
|
).toHaveLength(2);
|
|
expect(reportProgress).toHaveBeenCalledWith(
|
|
"watch-action-topology.more-menu.retrying",
|
|
expect.objectContaining({ runtime: "extension", videoId: "abcdefghijk" }),
|
|
);
|
|
});
|
|
|
|
test("fails after the single Watch More retry instead of hiding a persistently broken native menu", async () => {
|
|
const { driver, moreButton } = createActionTopologyDriver({
|
|
keyboardOpensPopup: false,
|
|
pointerOpensPopup: false,
|
|
});
|
|
|
|
await expect(driver.inspectWatchActionTopology("extension", { presenceTimeoutMs: 250 })).rejects.toThrow(
|
|
/hit-tested pointer activation or its single keyboard retry/,
|
|
);
|
|
|
|
expect(moreButton.click).toHaveBeenCalledTimes(1);
|
|
expect(moreButton.focus).toHaveBeenCalledTimes(1);
|
|
expect(moreButton.press).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
test("accepts one complete, accessible, non-overlapping action topology", () => {
|
|
const snapshot = validTopology();
|
|
|
|
expect(() =>
|
|
assertWatchActionTopologySnapshot(snapshot, {
|
|
expectedInventorySignatures: ["Share", "Save", "Report"],
|
|
expectedTopLevelOptionalSignatures: ["Share"],
|
|
minimumTopLevelOptionalActions: 1,
|
|
}),
|
|
).not.toThrow();
|
|
});
|
|
|
|
test("accepts YouTube's four-pixel menu-shell overhang while keeping actionable controls inside", () => {
|
|
const snapshot = validTopology();
|
|
snapshot.boxes.menu.width = 604;
|
|
|
|
expect(() => assertWatchActionTopologySnapshot(snapshot)).not.toThrow();
|
|
});
|
|
|
|
test("rejects a menu shell that overhangs the top row by more than five pixels", () => {
|
|
const snapshot = validTopology();
|
|
snapshot.boxes.menu.width = 606;
|
|
|
|
expect(() => assertWatchActionTopologySnapshot(snapshot)).toThrow(/Watch action menu escapes the top row/);
|
|
});
|
|
|
|
test("does not apply the menu-shell tolerance to the actionable More button", () => {
|
|
const snapshot = validTopology();
|
|
snapshot.boxes.moreButton.x = 1153;
|
|
|
|
expect(() => assertWatchActionTopologySnapshot(snapshot)).toThrow(/More-actions button escapes the top row/);
|
|
});
|
|
|
|
test("rejects the former false-green state where every wide optional action is hidden", () => {
|
|
const snapshot = validTopology();
|
|
snapshot.topLevelOptionalSignatures = [];
|
|
snapshot.overflowSignatures = ["Share", "Save", "Report"];
|
|
|
|
expect(() => assertWatchActionTopologySnapshot(snapshot, { minimumTopLevelOptionalActions: 1 })).toThrow(
|
|
/at least 1 optional action/,
|
|
);
|
|
});
|
|
|
|
test("rejects an action duplicated across top level and More", () => {
|
|
const snapshot = validTopology();
|
|
snapshot.overflowSignatures = ["Share", "Save", "Report"];
|
|
snapshot.inventorySignatures = ["Report", "Save", "Share", "Share"];
|
|
|
|
expect(() => assertWatchActionTopologySnapshot(snapshot)).toThrow(/both at top level and in the More menu/);
|
|
});
|
|
|
|
test("rejects an action missing after a width change", () => {
|
|
const snapshot = validTopology();
|
|
snapshot.overflowSignatures = ["Report"];
|
|
snapshot.inventorySignatures = ["Report", "Share"];
|
|
|
|
expect(() =>
|
|
assertWatchActionTopologySnapshot(snapshot, {
|
|
expectedInventorySignatures: ["Report", "Save", "Share"],
|
|
}),
|
|
).toThrow(/disappeared or duplicated while the layout changed/);
|
|
});
|
|
|
|
test.each([
|
|
["stale current root", (snapshot) => (snapshot.videoMatches = false), /stale video root/],
|
|
[
|
|
"clipped More button",
|
|
(snapshot) => {
|
|
snapshot.boxes.moreButton.x = 1190;
|
|
},
|
|
/More-actions button is clipped/,
|
|
],
|
|
[
|
|
"overlapping optional action",
|
|
(snapshot) => {
|
|
snapshot.boxes.topLevelActionHosts[0].x = 740;
|
|
},
|
|
/top-row controls 1 and 2 overlap/,
|
|
],
|
|
[
|
|
"clipped overflow menu",
|
|
(snapshot) => {
|
|
snapshot.overflowBox.x = 1100;
|
|
},
|
|
/More-actions menu is clipped/,
|
|
],
|
|
[
|
|
"overlapping overflow items",
|
|
(snapshot) => {
|
|
snapshot.overflowItemBoxes[1].y = 250;
|
|
},
|
|
/More-actions items 1 and 2 overlap/,
|
|
],
|
|
])("rejects a %s", (_label, mutate, expected) => {
|
|
const snapshot = validTopology();
|
|
mutate(snapshot);
|
|
expect(() => assertWatchActionTopologySnapshot(snapshot)).toThrow(expected);
|
|
});
|
|
});
|
|
|
|
describe("assertReactionPressedStates", () => {
|
|
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("accepts a centered host widened by a localized count", () => {
|
|
const measurement = validSyntheticShortsGeometry();
|
|
widenShortsControlForLocalizedCount(measurement.like);
|
|
widenShortsControlForLocalizedCount(measurement.synthetic);
|
|
|
|
expect(() => assertSyntheticShortsGeometry(measurement)).not.toThrow();
|
|
});
|
|
|
|
test("accepts the real 51.6125px count-driven shell while preserving native Like control parity", () => {
|
|
const measurement = validSyntheticShortsGeometry();
|
|
widenShortsControlForLocalizedCount(measurement.synthetic, {
|
|
countWidth: 39.6125,
|
|
shellWidth: 51.6125,
|
|
});
|
|
|
|
expect(() => assertSyntheticShortsGeometry(measurement)).not.toThrow();
|
|
});
|
|
|
|
test("accepts the exact Next-hop rail width when a native peer establishes the 51.6125px column", () => {
|
|
const measurement = validSyntheticShortsGeometry();
|
|
widenShortsControlForLocalizedCount(measurement.synthetic, { countWidth: 7, shellWidth: 51.6125 });
|
|
measurement.nativeActionHosts.push(box(98.19375, 244, 51.6125, 78));
|
|
|
|
expect(() => assertSyntheticShortsGeometry(measurement)).not.toThrow();
|
|
});
|
|
|
|
test("rejects a 51.6125px shell with a short count when every native action remains 48px", () => {
|
|
const measurement = validSyntheticShortsGeometry();
|
|
widenShortsControlForLocalizedCount(measurement.synthetic, { countWidth: 7, shellWidth: 51.6125 });
|
|
|
|
expect(() => assertSyntheticShortsGeometry(measurement)).toThrow(/content-aware maximum of 48px/);
|
|
});
|
|
|
|
test("uses the measured fractional native Like button width instead of assuming 48px", () => {
|
|
const measurement = validSyntheticShortsGeometry();
|
|
widenShortsButtonAroundCenter(measurement.like, 51.6125);
|
|
widenShortsButtonAroundCenter(measurement.synthetic, 51.6125);
|
|
widenShortsControlForLocalizedCount(measurement.like, { countWidth: 39.6125, shellWidth: 51.6125 });
|
|
widenShortsControlForLocalizedCount(measurement.synthetic, { countWidth: 39.6125, shellWidth: 51.6125 });
|
|
|
|
expect(() => assertSyntheticShortsGeometry(measurement)).not.toThrow();
|
|
});
|
|
|
|
test("rejects a synthetic button width that diverges from a fractional native Like control", () => {
|
|
const measurement = validSyntheticShortsGeometry();
|
|
widenShortsButtonAroundCenter(measurement.like, 51.6125);
|
|
widenShortsControlForLocalizedCount(measurement.like, { countWidth: 39.6125, shellWidth: 51.6125 });
|
|
|
|
expect(() => assertSyntheticShortsGeometry(measurement)).toThrow(/button width parity with native Like/);
|
|
});
|
|
|
|
test.each([
|
|
["icon container", "icon"],
|
|
["SVG", "svg"],
|
|
])("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("rejects a host and label that are centered but much wider than their rendered content", () => {
|
|
const measurement = validSyntheticShortsGeometry();
|
|
widenShortsControlForLocalizedCount(measurement.synthetic, { shellWidth: 96 });
|
|
|
|
expect(() => assertSyntheticShortsGeometry(measurement)).toThrow(/content-aware maximum/);
|
|
});
|
|
|
|
test("rejects an overlapping synthetic action even when its internal geometry remains valid", () => {
|
|
const measurement = validSyntheticShortsGeometry();
|
|
measurement.synthetic.host.y -= 4;
|
|
|
|
expect(() => assertSyntheticShortsGeometry(measurement)).toThrow(
|
|
/Gap between native Like and synthetic Shorts action hosts/,
|
|
);
|
|
});
|
|
|
|
test("allows a one-pixel fractional rendering difference", () => {
|
|
const measurement = validSyntheticShortsGeometry();
|
|
measurement.synthetic.button.width = 47;
|
|
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("accepts a native Dislike host widened by a localized count", () => {
|
|
const measurement = validNativeShortsPairGeometry();
|
|
widenShortsControlForLocalizedCount(measurement.dislike);
|
|
|
|
expect(() => assertNativeShortsPairGeometry(measurement)).not.toThrow();
|
|
});
|
|
|
|
test("rejects controls selected from different retained Shorts reels", () => {
|
|
const measurement = validNativeShortsPairGeometry();
|
|
measurement.dislike.reelIndex = 1;
|
|
|
|
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,
|
|
svgHeight: 24,
|
|
svgPresent: true,
|
|
svgWidth: 24,
|
|
}),
|
|
).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, svgHeight: 24, svgPresent: true, svgWidth: 24 },
|
|
],
|
|
[
|
|
"missing SVG",
|
|
{ effectiveOpacity: 1, paintedGraphicCount: 0, rendered: true, svgHeight: 0, svgPresent: false, svgWidth: 0 },
|
|
],
|
|
[
|
|
"zero-width SVG",
|
|
{ effectiveOpacity: 1, paintedGraphicCount: 1, rendered: true, svgHeight: 24, svgPresent: true, svgWidth: 0 },
|
|
],
|
|
[
|
|
"zero-height SVG",
|
|
{ effectiveOpacity: 1, paintedGraphicCount: 1, rendered: true, svgHeight: 0, svgPresent: true, svgWidth: 24 },
|
|
],
|
|
[
|
|
"hidden ancestor",
|
|
{ effectiveOpacity: 0, paintedGraphicCount: 1, rendered: true, svgHeight: 24, svgPresent: true, svgWidth: 24 },
|
|
],
|
|
[
|
|
"non-rendered ancestor",
|
|
{ effectiveOpacity: 1, paintedGraphicCount: 1, rendered: false, svgHeight: 24, svgPresent: true, svgWidth: 24 },
|
|
],
|
|
])("does not screenshot a %s as a ready icon", (_label, state) => {
|
|
expect(isShortsIconVisualReady(state)).toBe(false);
|
|
});
|
|
});
|