mirror of
https://github.com/Anarios/return-youtube-dislike.git
synced 2026-09-13 02:42:06 +02: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.
1098 lines
54 KiB
JavaScript
1098 lines
54 KiB
JavaScript
const { test, expect } = require("@playwright/test");
|
|
const {
|
|
CREDENTIAL_KEY,
|
|
VIDEO_A,
|
|
VIDEO_B,
|
|
createFakeBackend,
|
|
injectGeneratedUserscript,
|
|
installGmEnvironment,
|
|
installHermeticRoutes,
|
|
openNavigationFixture,
|
|
openShortsFixture,
|
|
} = require("./harness");
|
|
|
|
const EXISTING_CREDENTIALS = {
|
|
userId: "ExistingUserscriptCredential000000000001",
|
|
registrationConfirmed: true,
|
|
};
|
|
const COUNTS = {
|
|
[VIDEO_A]: { dislikes: 11, likes: 100 },
|
|
[VIDEO_B]: { dislikes: 22, likes: 200 },
|
|
};
|
|
|
|
async function launchNavigationHarness({ context, page }, initialPage = {}, { beforeInject } = {}) {
|
|
const consoleErrors = [];
|
|
const pageErrors = [];
|
|
page.on("console", (message) => {
|
|
if (message.type() === "error") consoleErrors.push(message.text());
|
|
});
|
|
page.on("pageerror", (error) => pageErrors.push(error.message));
|
|
|
|
await context.addInitScript(() => {
|
|
globalThis.__unhandledRejections = [];
|
|
addEventListener("unhandledrejection", (event) => {
|
|
const reason = event.reason;
|
|
globalThis.__unhandledRejections.push(reason instanceof Error ? reason.message : String(reason));
|
|
});
|
|
});
|
|
|
|
const backend = createFakeBackend({ countsByVideo: COUNTS });
|
|
await installGmEnvironment(context, { [CREDENTIAL_KEY]: EXISTING_CREDENTIALS });
|
|
await installHermeticRoutes(context, backend);
|
|
await openNavigationFixture(page, initialPage);
|
|
if (beforeInject) await beforeInject(page);
|
|
await injectGeneratedUserscript(page);
|
|
return { backend, consoleErrors, pageErrors };
|
|
}
|
|
|
|
async function expectWatchInitialized(page, videoId, expectedDislikes = COUNTS[videoId].dislikes) {
|
|
const expectedCount = String(expectedDislikes);
|
|
await expect(page).toHaveURL((url) => url.pathname === "/watch" && url.searchParams.get("v") === videoId);
|
|
await expect(page.locator(`ytd-watch-flexy[video-id="${videoId}"]`)).toHaveCount(1);
|
|
await expect(page.locator('[data-fixture-page-kind="watch"] [data-fixture-role="buttons"]')).toHaveCount(1);
|
|
await expect(page.locator('[data-fixture-page-kind="watch"] [data-fixture-role="dislike"] #text')).toHaveText(
|
|
expectedCount,
|
|
);
|
|
await expect(page.locator("#return-youtube-dislike-bar-container")).toHaveCount(1);
|
|
await expect(page.locator("#return-youtube-dislike-bar")).toHaveCount(1);
|
|
await expect(page.locator(".ryd-tooltip")).toHaveCount(1);
|
|
await expect(page.locator("[data-ryd-synthetic-shorts-dislike]")).toHaveCount(0);
|
|
}
|
|
|
|
async function expectShortInitialized(page, videoId, expectedDislikes = COUNTS[videoId].dislikes) {
|
|
const expectedCount = String(expectedDislikes);
|
|
await expect(page).toHaveURL((url) => url.pathname === `/shorts/${videoId}`);
|
|
const activeRenderer = page.locator(`ytd-reel-video-renderer[video-id="${videoId}"][is-active]`);
|
|
await expect(page.locator("ytd-shorts")).toHaveCount(1);
|
|
await expect(activeRenderer).toHaveCount(1);
|
|
await expect(
|
|
page.locator('ytd-reel-video-renderer[is-active] reel-action-bar-view-model[data-fixture-role="buttons"]'),
|
|
).toHaveCount(1);
|
|
const syntheticDislike = activeRenderer.locator("[data-ryd-synthetic-shorts-dislike]");
|
|
await expect(syntheticDislike).toHaveCount(1);
|
|
await expect(syntheticDislike).toBeVisible();
|
|
await expect(page.locator("[data-ryd-synthetic-shorts-dislike]:visible")).toHaveCount(1);
|
|
await expect(syntheticDislike).toHaveAttribute("data-ryd-video-id", videoId);
|
|
await expect(syntheticDislike.locator("button")).toBeEnabled();
|
|
await expect(syntheticDislike.locator("#text")).toHaveText(expectedCount);
|
|
await expect(page.locator("#return-youtube-dislike-bar-container")).toHaveCount(0);
|
|
await expect(page.locator(".ryd-tooltip")).toHaveCount(0);
|
|
}
|
|
|
|
async function expectMobileShortInitialized(page, videoId, expectedDislikes = COUNTS[videoId].dislikes) {
|
|
await expect(page).toHaveURL((url) => url.pathname === `/shorts/${videoId}`);
|
|
const activeShort = page.locator(`[data-fixture-mobile-short="${videoId}"][is-active]`);
|
|
await expect(activeShort).toHaveCount(1);
|
|
await expect(activeShort).toBeVisible();
|
|
await expect(page.locator("[data-fixture-mobile-short][is-active]:visible")).toHaveCount(1);
|
|
await expect(activeShort.locator('ytm-like-button-renderer[data-fixture-role="buttons"]')).toHaveCount(1);
|
|
await expect(activeShort.locator('[data-fixture-role="dislike"] #text')).toHaveText(String(expectedDislikes));
|
|
await expect(activeShort.locator('[data-fixture-role="dislike"] button')).toBeEnabled();
|
|
await expect(page.locator("[data-ryd-synthetic-shorts-dislike]")).toHaveCount(0);
|
|
await expect(page.locator("#return-youtube-dislike-bar-container")).toHaveCount(0);
|
|
}
|
|
|
|
async function expectOneActivation(page, backend, videoId) {
|
|
const voteCount = backend.requestsFor("POST", "/interact/vote").length;
|
|
const confirmationCount = backend.requestsFor("POST", "/interact/confirmVote").length;
|
|
await page
|
|
.locator('[data-fixture-role="dislike"]:visible button, [data-ryd-synthetic-shorts-dislike]:visible button')
|
|
.click();
|
|
await expect.poll(() => backend.requestsFor("POST", "/interact/confirmVote").length).toBe(confirmationCount + 1);
|
|
await page.waitForTimeout(600);
|
|
|
|
const votes = backend.requestsFor("POST", "/interact/vote");
|
|
const confirmations = backend.requestsFor("POST", "/interact/confirmVote");
|
|
expect(votes).toHaveLength(voteCount + 1);
|
|
expect(confirmations).toHaveLength(confirmationCount + 1);
|
|
expect(votes.at(-1).body).toMatchObject({ videoId, value: -1 });
|
|
expect(confirmations.at(-1).body).toMatchObject({ videoId });
|
|
}
|
|
|
|
async function expectHealthyRuntime(page, harness) {
|
|
expect(harness.backend.blockedRequests).toEqual([]);
|
|
expect(harness.consoleErrors).toEqual([]);
|
|
expect(harness.pageErrors).toEqual([]);
|
|
expect(await page.evaluate(() => globalThis.__unhandledRejections)).toEqual([]);
|
|
}
|
|
|
|
test("cold channel reload then immediate Short link initializes delayed Shorts controls once", async ({
|
|
context,
|
|
page,
|
|
}) => {
|
|
const harness = await launchNavigationHarness({ context, page });
|
|
|
|
await page.reload({ waitUntil: "domcontentloaded" });
|
|
await injectGeneratedUserscript(page);
|
|
await page.locator("#channel-short").click();
|
|
|
|
await expect(page).toHaveURL((url) => url.pathname === `/shorts/${VIDEO_A}`);
|
|
const decoyDislikeCount = page.locator('[data-fixture-decoy-controls] [data-fixture-role="dislike"] #text');
|
|
await page.waitForTimeout(250);
|
|
await expect(decoyDislikeCount).toHaveText("");
|
|
await expect(page.locator("[data-ryd-synthetic-shorts-dislike]")).toHaveCount(0);
|
|
expect(harness.backend.requestsFor("GET", "/votes")).toHaveLength(0);
|
|
|
|
await expectShortInitialized(page, VIDEO_A);
|
|
await expectOneActivation(page, harness.backend, VIDEO_A);
|
|
expect(
|
|
harness.backend.requestsFor("GET", "/votes").filter((request) => request.query.videoId === VIDEO_A),
|
|
).toHaveLength(1);
|
|
await expectHealthyRuntime(page, harness);
|
|
});
|
|
|
|
test("the live read-only prelude leaves one native Shorts Like activation", async ({ context, page }) => {
|
|
const harness = await launchNavigationHarness({ context, page });
|
|
|
|
await page.reload({ waitUntil: "domcontentloaded" });
|
|
await injectGeneratedUserscript(page);
|
|
await page.locator("#channel-short").click();
|
|
await expectShortInitialized(page, VIDEO_A);
|
|
await page.locator("#short-next").click();
|
|
await expectShortInitialized(page, VIDEO_B);
|
|
expect(harness.backend.requestsFor("POST", "/interact/vote")).toHaveLength(0);
|
|
expect(harness.backend.requestsFor("POST", "/interact/confirmVote")).toHaveLength(0);
|
|
await expectHealthyRuntime(page, harness);
|
|
|
|
const responsiveViewports = [
|
|
{ height: 720, width: 1280 },
|
|
{ height: 720, width: 768 },
|
|
{ height: 844, width: 390 },
|
|
];
|
|
for (const [index, viewport] of responsiveViewports.entries()) {
|
|
await page.setViewportSize(viewport);
|
|
await openNavigationFixture(page, { pageKind: "watch", videoId: VIDEO_A });
|
|
await injectGeneratedUserscript(page);
|
|
await expectWatchInitialized(page, VIDEO_A);
|
|
if (index === 0) {
|
|
await page.reload({ waitUntil: "domcontentloaded" });
|
|
await injectGeneratedUserscript(page);
|
|
await expectWatchInitialized(page, VIDEO_A);
|
|
}
|
|
|
|
await openNavigationFixture(page, { pageKind: "shorts", videoId: VIDEO_A });
|
|
await injectGeneratedUserscript(page);
|
|
await expectShortInitialized(page, VIDEO_A);
|
|
expect(harness.backend.requestsFor("POST", "/interact/vote")).toHaveLength(0);
|
|
expect(harness.backend.requestsFor("POST", "/interact/confirmVote")).toHaveLength(0);
|
|
await expectHealthyRuntime(page, harness);
|
|
}
|
|
|
|
await page.setViewportSize({ height: 720, width: 1280 });
|
|
await openNavigationFixture(page, { pageKind: "watch", videoId: VIDEO_A });
|
|
await injectGeneratedUserscript(page);
|
|
await expectWatchInitialized(page, VIDEO_A);
|
|
await page.locator("#watch-next").click();
|
|
await expectWatchInitialized(page, VIDEO_B);
|
|
expect(harness.backend.requestsFor("POST", "/interact/vote")).toHaveLength(0);
|
|
expect(harness.backend.requestsFor("POST", "/interact/confirmVote")).toHaveLength(0);
|
|
await expectHealthyRuntime(page, harness);
|
|
|
|
await openShortsFixture(page, VIDEO_A);
|
|
await injectGeneratedUserscript(page);
|
|
const finalShort = page.locator(`[data-short-video="${VIDEO_A}"]:not([hidden])`);
|
|
const nativeLike = finalShort.locator('[data-ryd-role="like"] button');
|
|
await expect(finalShort.locator("[data-ryd-synthetic-shorts-dislike] #text")).toHaveText(
|
|
String(COUNTS[VIDEO_A].dislikes),
|
|
);
|
|
await expect(nativeLike).toHaveAttribute("aria-pressed", "false");
|
|
expect(harness.backend.requestsFor("POST", "/interact/vote")).toHaveLength(0);
|
|
expect(harness.backend.requestsFor("POST", "/interact/confirmVote")).toHaveLength(0);
|
|
|
|
await nativeLike.click();
|
|
await expect.poll(() => harness.backend.requestsFor("POST", "/interact/confirmVote").length).toBe(1);
|
|
await page.waitForTimeout(600);
|
|
|
|
const votes = harness.backend.requestsFor("POST", "/interact/vote");
|
|
const confirmations = harness.backend.requestsFor("POST", "/interact/confirmVote");
|
|
expect(votes).toHaveLength(1);
|
|
expect(confirmations).toHaveLength(1);
|
|
expect(votes[0].body).toEqual({ userId: EXISTING_CREDENTIALS.userId, videoId: VIDEO_A, value: 1 });
|
|
expect(confirmations[0].body).toMatchObject({ userId: EXISTING_CREDENTIALS.userId, videoId: VIDEO_A });
|
|
await expect(nativeLike).toHaveAttribute("aria-pressed", "true");
|
|
await expect(finalShort.locator("[data-ryd-synthetic-shorts-dislike] button")).toHaveAttribute(
|
|
"aria-pressed",
|
|
"false",
|
|
);
|
|
await expectHealthyRuntime(page, harness);
|
|
});
|
|
|
|
test("inactive sibling identity churn cannot restart cold channel-to-Short hydration", async ({ context, page }) => {
|
|
const harness = await launchNavigationHarness(
|
|
{ context, page },
|
|
{},
|
|
{
|
|
beforeInject: (fixturePage) =>
|
|
fixturePage.evaluate((syntheticStateKey) => {
|
|
const originalGetValue = globalThis.GM.getValue;
|
|
let releaseRead;
|
|
const readGate = new Promise((resolve) => {
|
|
releaseRead = resolve;
|
|
});
|
|
const state = {
|
|
count: 0,
|
|
release() {
|
|
releaseRead();
|
|
},
|
|
};
|
|
const delayedGetValue = async (key, fallbackValue) => {
|
|
if (key === syntheticStateKey) {
|
|
state.count += 1;
|
|
await readGate;
|
|
}
|
|
return originalGetValue(key, fallbackValue);
|
|
};
|
|
globalThis.__fixtureSyntheticReadGate = state;
|
|
globalThis.GM.getValue = delayedGetValue;
|
|
globalThis.GM_getValue = delayedGetValue;
|
|
}, `rydSyntheticDislikedShort:${VIDEO_A}`),
|
|
},
|
|
);
|
|
|
|
await page.locator("#channel-short").click();
|
|
await expect(page).toHaveURL((url) => url.pathname === `/shorts/${VIDEO_A}`);
|
|
await expect.poll(() => page.evaluate(() => globalThis.__fixtureSyntheticReadGate.count)).toBe(1);
|
|
|
|
for (let sequence = 0; sequence < 20; sequence += 1) {
|
|
await page.evaluate((value) => window.__navigationFixture.churnInactiveDesktopShortIdentity(value), sequence);
|
|
await page.waitForTimeout(10);
|
|
}
|
|
await expect(page.locator(`ytd-reel-video-renderer[video-id="${VIDEO_A}"][is-active]`)).toBeVisible();
|
|
await expect(page.locator("ytd-reel-video-renderer:not([is-active])")).toBeHidden();
|
|
expect(
|
|
harness.backend.requestsFor("GET", "/votes").filter((request) => request.query.videoId === VIDEO_A),
|
|
).toHaveLength(1);
|
|
|
|
await page.evaluate(() => globalThis.__fixtureSyntheticReadGate.release());
|
|
await expectShortInitialized(page, VIDEO_A);
|
|
await page.waitForTimeout(600);
|
|
|
|
expect(await page.evaluate(() => globalThis.__fixtureSyntheticReadGate.count)).toBe(1);
|
|
expect(
|
|
harness.backend.requestsFor("GET", "/votes").filter((request) => request.query.videoId === VIDEO_A),
|
|
).toHaveLength(1);
|
|
await expect(page.locator("[data-ryd-synthetic-shorts-dislike]:visible")).toHaveCount(1);
|
|
await expectHealthyRuntime(page, harness);
|
|
});
|
|
|
|
test("channel video link initializes delayed watch controls and one ratio bar", async ({ context, page }) => {
|
|
const harness = await launchNavigationHarness({ context, page });
|
|
|
|
await page.locator("#channel-watch").click();
|
|
|
|
await expectWatchInitialized(page, VIDEO_A);
|
|
await expectOneActivation(page, harness.backend, VIDEO_A);
|
|
await expectWatchInitialized(page, VIDEO_A, COUNTS[VIDEO_A].dislikes + 1);
|
|
await expectHealthyRuntime(page, harness);
|
|
});
|
|
|
|
test("rendered fixed watch controls initialize without an offset parent", async ({ context, page }) => {
|
|
const harness = await launchNavigationHarness(
|
|
{ context, page },
|
|
{ pageKind: "watch", videoId: VIDEO_A },
|
|
{
|
|
beforeInject: async (fixturePage) => {
|
|
await fixturePage.evaluate(() => {
|
|
const buttons = document.querySelector('[data-fixture-page-kind="watch"] #top-level-buttons-computed');
|
|
if (!buttons) throw new Error("The fixed-controls fixture has no watch buttons.");
|
|
buttons.style.left = "20px";
|
|
buttons.style.setProperty("position", "fixed", "important");
|
|
buttons.style.top = "120px";
|
|
const rect = buttons.getBoundingClientRect();
|
|
if (buttons.offsetParent !== null || rect.width <= 0 || rect.height <= 0) {
|
|
throw new Error("The fixed-controls fixture did not reproduce positive geometry without offsetParent.");
|
|
}
|
|
});
|
|
},
|
|
},
|
|
);
|
|
|
|
await expectWatchInitialized(page, VIDEO_A);
|
|
await expect(page.locator('[data-fixture-page-kind="watch"] #top-level-buttons-computed')).toHaveCSS(
|
|
"position",
|
|
"fixed",
|
|
);
|
|
await expectHealthyRuntime(page, harness);
|
|
});
|
|
|
|
test("watch to Short link switches page kind, identity, and controls", async ({ context, page }) => {
|
|
const harness = await launchNavigationHarness({ context, page }, { pageKind: "watch", videoId: VIDEO_A });
|
|
await expectWatchInitialized(page, VIDEO_A);
|
|
|
|
await page.locator("#watch-to-short").click();
|
|
|
|
await expect(page).toHaveURL((url) => url.pathname === `/shorts/${VIDEO_B}`);
|
|
await expect(page.locator('[data-fixture-page-kind="watch"] [data-fixture-role="dislike"] #text')).toHaveText("11");
|
|
await page.waitForTimeout(50);
|
|
await expect(page.locator('[data-fixture-page-kind="watch"] [data-fixture-role="dislike"] #text')).toHaveText("11");
|
|
await expect(page.locator("[data-ryd-synthetic-shorts-dislike]")).toHaveCount(0);
|
|
|
|
await expectShortInitialized(page, VIDEO_B);
|
|
await expectOneActivation(page, harness.backend, VIDEO_B);
|
|
await expectShortInitialized(page, VIDEO_B, COUNTS[VIDEO_B].dislikes + 1);
|
|
await expectHealthyRuntime(page, harness);
|
|
});
|
|
|
|
test("Short to watch link removes Shorts control and initializes the target bar", async ({ context, page }) => {
|
|
const harness = await launchNavigationHarness({ context, page }, { pageKind: "shorts", videoId: VIDEO_A });
|
|
await expectShortInitialized(page, VIDEO_A);
|
|
|
|
await page.locator("#short-to-watch").click();
|
|
|
|
await expectWatchInitialized(page, VIDEO_B);
|
|
await expectOneActivation(page, harness.backend, VIDEO_B);
|
|
await expectWatchInitialized(page, VIDEO_B, COUNTS[VIDEO_B].dislikes + 1);
|
|
await expectHealthyRuntime(page, harness);
|
|
});
|
|
|
|
test("Short next navigation activates one preloaded sibling control", async ({ context, page }) => {
|
|
const harness = await launchNavigationHarness({ context, page }, { pageKind: "shorts", videoId: VIDEO_A });
|
|
await expectShortInitialized(page, VIDEO_A);
|
|
const preloadedNextRenderer = page.locator(`ytd-reel-video-renderer[video-id="${VIDEO_B}"]`);
|
|
await expect(preloadedNextRenderer).toBeHidden();
|
|
await preloadedNextRenderer.evaluate((renderer) => renderer.setAttribute("data-fixture-preloaded-marker", "true"));
|
|
|
|
await page.locator("#short-next").click();
|
|
|
|
await expectShortInitialized(page, VIDEO_B);
|
|
await expect(page.locator(`ytd-reel-video-renderer[video-id="${VIDEO_B}"][is-active]`)).toHaveAttribute(
|
|
"data-fixture-preloaded-marker",
|
|
"true",
|
|
);
|
|
await expectOneActivation(page, harness.backend, VIDEO_B);
|
|
await expectShortInitialized(page, VIDEO_B, COUNTS[VIDEO_B].dislikes + 1);
|
|
await expectHealthyRuntime(page, harness);
|
|
});
|
|
|
|
test("delayed Short navigation never retags or submits the outgoing reel as the target video", async ({
|
|
context,
|
|
page,
|
|
}) => {
|
|
const harness = await launchNavigationHarness({ context, page }, { pageKind: "shorts", videoId: VIDEO_A });
|
|
await expectShortInitialized(page, VIDEO_A);
|
|
const outgoingRenderer = page.locator(`ytd-reel-video-renderer[video-id="${VIDEO_A}"][is-active]`);
|
|
|
|
await page.evaluate((videoId) => window.__navigationFixture.navigateDelayedShort(videoId), VIDEO_B);
|
|
|
|
await expect(page).toHaveURL((url) => url.pathname === `/shorts/${VIDEO_B}`);
|
|
await expect(outgoingRenderer).toBeVisible();
|
|
await expect(outgoingRenderer).toHaveAttribute("video-id", VIDEO_A);
|
|
await expect(outgoingRenderer.locator(`a[href="/shorts/${VIDEO_B}"]`)).toHaveCount(1);
|
|
await expect(page.locator(`ytd-reel-video-renderer[video-id="${VIDEO_B}"]`)).toHaveCount(0);
|
|
await page.waitForTimeout(600);
|
|
await expect(outgoingRenderer).toBeVisible();
|
|
await expect(outgoingRenderer.locator(`[data-ryd-video-id="${VIDEO_B}"]`)).toHaveCount(0);
|
|
await expect(outgoingRenderer.locator("[data-ryd-synthetic-shorts-dislike] #text")).toHaveText("11");
|
|
await outgoingRenderer.locator("[data-ryd-synthetic-shorts-dislike] button").click();
|
|
await page.waitForTimeout(100);
|
|
expect(harness.backend.requestsFor("POST", "/interact/vote")).toHaveLength(0);
|
|
expect(harness.backend.requestsFor("POST", "/interact/confirmVote")).toHaveLength(0);
|
|
|
|
await page.evaluate(() => window.__navigationFixture.finishDelayedNavigation());
|
|
await expectShortInitialized(page, VIDEO_B);
|
|
await expectOneActivation(page, harness.backend, VIDEO_B);
|
|
expect(
|
|
harness.backend.requestsFor("GET", "/votes").filter((request) => request.query.videoId === VIDEO_B),
|
|
).toHaveLength(1);
|
|
await expectHealthyRuntime(page, harness);
|
|
});
|
|
|
|
test("delayed mobile Short navigation never binds the outgoing renderer to the target video", async ({
|
|
context,
|
|
page,
|
|
}) => {
|
|
const harness = await launchNavigationHarness(
|
|
{ context, page },
|
|
{ hostname: "m.youtube.com", pageKind: "shorts", videoId: VIDEO_A },
|
|
);
|
|
await expectMobileShortInitialized(page, VIDEO_A);
|
|
const outgoingRenderer = page.locator(`ytm-reel-video-renderer[video-id="${VIDEO_A}"][is-active]`);
|
|
|
|
await page.evaluate((videoId) => window.__navigationFixture.navigateDelayedShort(videoId), VIDEO_B);
|
|
|
|
await expect(page).toHaveURL((url) => url.pathname === `/shorts/${VIDEO_B}`);
|
|
await expect(outgoingRenderer).toBeVisible();
|
|
await expect(outgoingRenderer).toHaveAttribute("video-id", VIDEO_A);
|
|
await expect(outgoingRenderer.locator("ytm-reel-player-overlay-renderer:not([video-id])")).toHaveCount(1);
|
|
await expect(outgoingRenderer.locator(`a[href="/shorts/${VIDEO_B}"]`)).toHaveCount(1);
|
|
await expect(page.locator(`ytm-reel-video-renderer[video-id="${VIDEO_B}"]`)).toHaveCount(0);
|
|
await page.waitForTimeout(600);
|
|
await expect(outgoingRenderer.locator('[data-fixture-role="dislike"] #text')).toHaveText("11");
|
|
await outgoingRenderer.locator('[data-fixture-role="dislike"] button').click();
|
|
await page.waitForTimeout(100);
|
|
expect(
|
|
harness.backend.requestsFor("GET", "/votes").filter((request) => request.query.videoId === VIDEO_B),
|
|
).toHaveLength(0);
|
|
expect(harness.backend.requestsFor("POST", "/interact/vote")).toHaveLength(0);
|
|
expect(harness.backend.requestsFor("POST", "/interact/confirmVote")).toHaveLength(0);
|
|
|
|
await page.evaluate(() => window.__navigationFixture.finishDelayedNavigation());
|
|
await expectMobileShortInitialized(page, VIDEO_B);
|
|
await expectOneActivation(page, harness.backend, VIDEO_B);
|
|
await expectMobileShortInitialized(page, VIDEO_B, COUNTS[VIDEO_B].dislikes + 1);
|
|
expect(
|
|
harness.backend.requestsFor("GET", "/votes").filter((request) => request.query.videoId === VIDEO_B),
|
|
).toHaveLength(1);
|
|
await expectHealthyRuntime(page, harness);
|
|
});
|
|
|
|
test("a metadata-free Short action bar keeps its original fallback ownership across a delayed route", async ({
|
|
context,
|
|
page,
|
|
}) => {
|
|
const harness = await launchNavigationHarness(
|
|
{ context, page },
|
|
{ pageKind: "shorts", videoId: VIDEO_A },
|
|
{
|
|
beforeInject: (fixturePage) =>
|
|
fixturePage.evaluate(() => window.__navigationFixture.anonymizeActiveDesktopShort()),
|
|
},
|
|
);
|
|
const outgoingRenderer = page.locator("ytd-reel-video-renderer[is-active]:not([video-id])");
|
|
const outgoingDislike = outgoingRenderer.locator("[data-ryd-synthetic-shorts-dislike]");
|
|
await expect(outgoingRenderer).toBeVisible();
|
|
await expect(outgoingRenderer.locator('a[href*="/shorts/"]')).toHaveCount(0);
|
|
await expect(outgoingDislike).toHaveAttribute("data-ryd-video-id", VIDEO_A);
|
|
await expect(outgoingDislike.locator("#text")).toHaveText("11");
|
|
await expect(outgoingDislike.locator("button")).toBeEnabled();
|
|
|
|
await page.evaluate((videoId) => window.__navigationFixture.navigateDelayedAnonymousShort(videoId), VIDEO_B);
|
|
|
|
await expect(page).toHaveURL((url) => url.pathname === `/shorts/${VIDEO_B}`);
|
|
await page.waitForTimeout(600);
|
|
await expect(outgoingRenderer).toBeVisible();
|
|
await expect(outgoingRenderer.locator(`[data-ryd-video-id="${VIDEO_B}"]`)).toHaveCount(0);
|
|
await expect(outgoingDislike).toHaveAttribute("data-ryd-video-id", VIDEO_A);
|
|
await outgoingDislike.locator("button").click();
|
|
await page.waitForTimeout(100);
|
|
expect(
|
|
harness.backend.requestsFor("GET", "/votes").filter((request) => request.query.videoId === VIDEO_B),
|
|
).toHaveLength(0);
|
|
expect(harness.backend.requestsFor("POST", "/interact/vote")).toHaveLength(0);
|
|
|
|
await page.evaluate(() => window.__navigationFixture.finishDelayedNavigation());
|
|
await expectShortInitialized(page, VIDEO_B);
|
|
await expectOneActivation(page, harness.backend, VIDEO_B);
|
|
expect(
|
|
harness.backend.requestsFor("GET", "/votes").filter((request) => request.query.videoId === VIDEO_B),
|
|
).toHaveLength(1);
|
|
await expectHealthyRuntime(page, harness);
|
|
});
|
|
|
|
test("a descendant Shorts path is not accepted as the current video identity", async ({ context, page }) => {
|
|
const harness = await launchNavigationHarness({ context, page }, { pageKind: "shorts", videoId: VIDEO_A });
|
|
await expectShortInitialized(page, VIDEO_A);
|
|
const activeRenderer = page.locator(`ytd-reel-video-renderer[video-id="${VIDEO_A}"][is-active]`);
|
|
|
|
await page.evaluate((videoId) => window.__navigationFixture.navigateToShortsDescendant(videoId), VIDEO_A);
|
|
|
|
await expect(page).toHaveURL((url) => url.pathname === `/shorts/${VIDEO_A}/extra`);
|
|
await page.waitForTimeout(600);
|
|
await expect(activeRenderer.locator(`[data-ryd-video-id="${VIDEO_A}/extra"]`)).toHaveCount(0);
|
|
expect(
|
|
harness.backend.requestsFor("GET", "/votes").filter((request) => request.query.videoId === `${VIDEO_A}/extra`),
|
|
).toHaveLength(0);
|
|
await expectHealthyRuntime(page, harness);
|
|
});
|
|
|
|
test("Short autoplay activates a preloaded sibling reel without a navigation event", async ({ context, page }) => {
|
|
const harness = await launchNavigationHarness({ context, page }, { pageKind: "shorts", videoId: VIDEO_A });
|
|
await expectShortInitialized(page, VIDEO_A);
|
|
|
|
await page.evaluate(() => window.__navigationFixture.dispatchEnded());
|
|
|
|
await expectShortInitialized(page, VIDEO_B);
|
|
await expectOneActivation(page, harness.backend, VIDEO_B);
|
|
await expectShortInitialized(page, VIDEO_B, COUNTS[VIDEO_B].dislikes + 1);
|
|
expect(
|
|
harness.backend.requestsFor("GET", "/votes").filter((request) => request.query.videoId === VIDEO_B),
|
|
).toHaveLength(1);
|
|
await expectHealthyRuntime(page, harness);
|
|
});
|
|
|
|
test("watch next navigation replaces controls and leaves one target bar", async ({ context, page }) => {
|
|
const harness = await launchNavigationHarness({ context, page }, { pageKind: "watch", videoId: VIDEO_A });
|
|
await expectWatchInitialized(page, VIDEO_A);
|
|
|
|
await page.locator("#watch-next").click();
|
|
|
|
await expectWatchInitialized(page, VIDEO_B);
|
|
await expectOneActivation(page, harness.backend, VIDEO_B);
|
|
await expectWatchInitialized(page, VIDEO_B, COUNTS[VIDEO_B].dislikes + 1);
|
|
await expectHealthyRuntime(page, harness);
|
|
});
|
|
|
|
test("watch SPA navigation refreshes one aligned ratio bar with the target video's data", async ({ context, page }) => {
|
|
const harness = await launchNavigationHarness({ context, page }, { pageKind: "watch", videoId: VIDEO_A });
|
|
await expectWatchInitialized(page, VIDEO_A);
|
|
await expect(page.locator("#ryd-dislike-tooltip")).toContainText("100 / 11");
|
|
|
|
await page.evaluate(
|
|
({ fromVideoId, toVideoId }) => {
|
|
const outgoingTopRow = document.querySelector('[data-fixture-page-kind="watch"] #top-row');
|
|
if (!outgoingTopRow?.querySelector("#return-youtube-dislike-bar-container")) {
|
|
throw new Error("The outgoing watch reaction tree has no initialized ratio bar to retain.");
|
|
}
|
|
const retainedOutgoingTree = document.createElement("div");
|
|
retainedOutgoingTree.hidden = true;
|
|
retainedOutgoingTree.setAttribute("data-fixture-retained-watch-video-id", fromVideoId);
|
|
retainedOutgoingTree.appendChild(outgoingTopRow);
|
|
document.body.appendChild(retainedOutgoingTree);
|
|
window.__navigationFixture.navigate("watch", toVideoId);
|
|
},
|
|
{ fromVideoId: VIDEO_A, toVideoId: VIDEO_B },
|
|
);
|
|
|
|
await expectWatchInitialized(page, VIDEO_B);
|
|
const retainedOutgoingTree = page.locator(`[data-fixture-retained-watch-video-id="${VIDEO_A}"]`);
|
|
const currentControls = page.locator(`[data-fixture-control-video-id="${VIDEO_B}"]`);
|
|
const currentReactionRegion = currentControls.locator("xpath=..");
|
|
const wrapper = currentReactionRegion.locator(":scope > .ryd-tooltip");
|
|
const container = wrapper.locator("#return-youtube-dislike-bar-container");
|
|
const bar = container.locator("#return-youtube-dislike-bar");
|
|
const tooltip = wrapper.locator("#ryd-dislike-tooltip");
|
|
|
|
await expect(page.locator(".ryd-tooltip")).toHaveCount(1);
|
|
await expect(page.locator("#return-youtube-dislike-bar-container")).toHaveCount(1);
|
|
await expect(page.locator("#return-youtube-dislike-bar")).toHaveCount(1);
|
|
await expect(page.locator("#ryd-dislike-tooltip")).toHaveCount(1);
|
|
await expect(retainedOutgoingTree).toHaveCount(1);
|
|
await expect(retainedOutgoingTree).toBeHidden();
|
|
await expect(retainedOutgoingTree.locator(".ryd-tooltip")).toHaveCount(0);
|
|
await expect(retainedOutgoingTree.locator("#return-youtube-dislike-bar-container")).toHaveCount(0);
|
|
await expect(wrapper).toBeVisible();
|
|
await expect(container).toBeVisible();
|
|
await expect(bar).toBeVisible();
|
|
await expect(currentControls.locator('[data-fixture-role="dislike"] #text')).toHaveText("22");
|
|
await expect(tooltip).toContainText("200 / 22");
|
|
await expect(tooltip).toBeHidden();
|
|
|
|
const geometry = await currentReactionRegion.evaluate((reactionRegion) => {
|
|
const readBox = (element) => {
|
|
const box = element.getBoundingClientRect();
|
|
return {
|
|
bottom: box.bottom,
|
|
height: box.height,
|
|
left: box.left,
|
|
right: box.right,
|
|
top: box.top,
|
|
width: box.width,
|
|
};
|
|
};
|
|
const controls = reactionRegion.querySelector("[data-fixture-control-video-id]");
|
|
const like = controls.querySelector('[data-fixture-role="like"] button');
|
|
const dislike = controls.querySelector('[data-fixture-role="dislike"] button');
|
|
const ratioWrapper = reactionRegion.querySelector(":scope > .ryd-tooltip");
|
|
const ratioContainer = ratioWrapper.querySelector("#return-youtube-dislike-bar-container");
|
|
const ratioFill = ratioContainer.querySelector("#return-youtube-dislike-bar");
|
|
return {
|
|
bar: readBox(ratioFill),
|
|
container: readBox(ratioContainer),
|
|
dislike: readBox(dislike),
|
|
like: readBox(like),
|
|
wrapper: readBox(ratioWrapper),
|
|
};
|
|
});
|
|
|
|
expect(geometry.wrapper.width).toBeCloseTo(geometry.like.width + geometry.dislike.width, 0);
|
|
expect(geometry.container.width).toBeCloseTo(geometry.wrapper.width, 0);
|
|
expect(geometry.container.top).toBeGreaterThanOrEqual(Math.max(geometry.like.bottom, geometry.dislike.bottom) - 1);
|
|
expect(geometry.bar.left).toBeGreaterThanOrEqual(geometry.container.left - 1);
|
|
expect(geometry.bar.right).toBeLessThanOrEqual(geometry.container.right + 1);
|
|
expect(geometry.bar.width / geometry.container.width).toBeCloseTo(200 / (200 + 22), 2);
|
|
|
|
await wrapper.hover({ position: { x: 1, y: 1 } });
|
|
await expect(tooltip).toBeVisible();
|
|
await expect(tooltip).toContainText("200 / 22");
|
|
expect(
|
|
harness.backend.requestsFor("GET", "/votes").filter((request) => request.query.videoId === VIDEO_B),
|
|
).toHaveLength(1);
|
|
await expectHealthyRuntime(page, harness);
|
|
});
|
|
|
|
test("a pruned current watch ratio bar is restored once with the current video's data", async ({ context, page }) => {
|
|
const harness = await launchNavigationHarness({ context, page }, { pageKind: "watch", videoId: VIDEO_A });
|
|
await expectWatchInitialized(page, VIDEO_A);
|
|
|
|
await page.locator("#watch-next").click();
|
|
|
|
await expectWatchInitialized(page, VIDEO_B);
|
|
const currentControls = page.locator(`[data-fixture-control-video-id="${VIDEO_B}"]`);
|
|
const currentReactionRegion = currentControls.locator("xpath=..");
|
|
const wrapper = currentReactionRegion.locator(":scope > .ryd-tooltip");
|
|
const container = wrapper.locator("#return-youtube-dislike-bar-container");
|
|
const bar = container.locator("#return-youtube-dislike-bar");
|
|
const tooltip = wrapper.locator("#ryd-dislike-tooltip");
|
|
const countRequestsBeforePrune = harness.backend.requestsFor("GET", "/votes").length;
|
|
|
|
await expect(wrapper).toHaveCount(1);
|
|
await expect(tooltip).toContainText("200 / 22");
|
|
await currentReactionRegion.evaluate((reactionRegion) => {
|
|
const mutationStats = { addedWrappers: 0, removedWrappers: 0 };
|
|
const observer = new MutationObserver((mutations) => {
|
|
for (const mutation of mutations) {
|
|
for (const node of mutation.addedNodes) {
|
|
if (node instanceof Element && node.matches(".ryd-tooltip")) mutationStats.addedWrappers += 1;
|
|
}
|
|
for (const node of mutation.removedNodes) {
|
|
if (node instanceof Element && node.matches(".ryd-tooltip")) mutationStats.removedWrappers += 1;
|
|
}
|
|
}
|
|
});
|
|
observer.observe(reactionRegion, { childList: true });
|
|
globalThis.__fixtureWatchBarMutationObserver = observer;
|
|
globalThis.__fixtureWatchBarMutationStats = mutationStats;
|
|
|
|
const currentWrapper = reactionRegion.querySelector(":scope > .ryd-tooltip");
|
|
if (!currentWrapper) throw new Error("The current watch reaction tree has no ratio bar to prune.");
|
|
globalThis.__fixturePrunedWatchBar = currentWrapper;
|
|
currentWrapper.remove();
|
|
});
|
|
|
|
await expect(wrapper).toHaveCount(1);
|
|
await expect(wrapper).toBeVisible();
|
|
await expect(container).toBeVisible();
|
|
await expect(bar).toBeVisible();
|
|
await expect(currentControls.locator('[data-fixture-role="dislike"] #text')).toHaveText("22");
|
|
await expect(tooltip).toContainText("200 / 22");
|
|
await expect(tooltip).toBeHidden();
|
|
expect(await wrapper.evaluate((restoredWrapper) => restoredWrapper !== globalThis.__fixturePrunedWatchBar)).toBe(
|
|
true,
|
|
);
|
|
expect(
|
|
await bar.evaluate((fill) => fill.getBoundingClientRect().width / fill.parentElement.getBoundingClientRect().width),
|
|
).toBeCloseTo(200 / (200 + 22), 2);
|
|
|
|
await page.waitForTimeout(600);
|
|
const mutationStats = await page.evaluate(() => {
|
|
globalThis.__fixtureWatchBarMutationObserver.disconnect();
|
|
return globalThis.__fixtureWatchBarMutationStats;
|
|
});
|
|
expect(mutationStats).toEqual({ addedWrappers: 1, removedWrappers: 1 });
|
|
await expect(page.locator(".ryd-tooltip")).toHaveCount(1);
|
|
await expect(page.locator("#return-youtube-dislike-bar-container")).toHaveCount(1);
|
|
await expect(page.locator("#return-youtube-dislike-bar")).toHaveCount(1);
|
|
await expect(page.locator("#ryd-dislike-tooltip")).toHaveCount(1);
|
|
expect(harness.backend.requestsFor("GET", "/votes")).toHaveLength(countRequestsBeforePrune);
|
|
await expectHealthyRuntime(page, harness);
|
|
});
|
|
|
|
test("same-video whole watch action replacement rehydrates without navigation or refetch", async ({
|
|
context,
|
|
page,
|
|
}) => {
|
|
const harness = await launchNavigationHarness({ context, page }, { pageKind: "watch", videoId: VIDEO_B });
|
|
await expectWatchInitialized(page, VIDEO_B);
|
|
const countRequestsBeforeReplacement = harness.backend
|
|
.requestsFor("GET", "/votes")
|
|
.filter((request) => request.query.videoId === VIDEO_B).length;
|
|
|
|
expect(await page.evaluate(() => window.__navigationFixture.replaceCurrentWatchActions())).toBe(true);
|
|
|
|
const replacement = page.locator(`#top-level-buttons-computed[data-fixture-watch-actions-replacement="${VIDEO_B}"]`);
|
|
await expect(replacement).toHaveCount(1);
|
|
await expectWatchInitialized(page, VIDEO_B);
|
|
await expect(replacement.locator(":scope > .ryd-tooltip")).toHaveCount(1);
|
|
await expect(replacement.locator("#ryd-dislike-tooltip")).toContainText("200 / 22");
|
|
expect(await page.evaluate(() => !globalThis.__fixtureReplacedWatchActions.isConnected)).toBe(true);
|
|
|
|
await page.waitForTimeout(600);
|
|
await expect(page.locator(".ryd-tooltip")).toHaveCount(1);
|
|
await expect(page.locator("#return-youtube-dislike-bar-container")).toHaveCount(1);
|
|
await expect(page.locator("#return-youtube-dislike-bar")).toHaveCount(1);
|
|
await expect(page.locator("#ryd-dislike-tooltip")).toHaveCount(1);
|
|
expect(
|
|
harness.backend.requestsFor("GET", "/votes").filter((request) => request.query.videoId === VIDEO_B),
|
|
).toHaveLength(countRequestsBeforeReplacement);
|
|
|
|
await expectOneActivation(page, harness.backend, VIDEO_B);
|
|
await expectWatchInitialized(page, VIDEO_B, COUNTS[VIDEO_B].dislikes + 1);
|
|
expect(
|
|
harness.backend.requestsFor("GET", "/votes").filter((request) => request.query.videoId === VIDEO_B),
|
|
).toHaveLength(countRequestsBeforeReplacement);
|
|
await expectHealthyRuntime(page, harness);
|
|
});
|
|
|
|
test("sidebar watch navigation survives a retained settling action-container replacement", async ({
|
|
context,
|
|
page,
|
|
}) => {
|
|
const harness = await launchNavigationHarness({ context, page }, { pageKind: "watch", videoId: VIDEO_A });
|
|
await expectWatchInitialized(page, VIDEO_A);
|
|
|
|
await page.locator("#watch-related").click();
|
|
await expectWatchInitialized(page, VIDEO_B);
|
|
const countRequestsBeforeReplacement = harness.backend
|
|
.requestsFor("GET", "/votes")
|
|
.filter((request) => request.query.videoId === VIDEO_B).length;
|
|
|
|
expect(
|
|
await page.evaluate(() => window.__navigationFixture.replaceCurrentWatchActions({ retainOutgoing: true })),
|
|
).toBe(true);
|
|
|
|
const replacement = page.locator(`#top-level-buttons-computed[data-fixture-watch-actions-replacement="${VIDEO_B}"]`);
|
|
const retained = page.locator(`[data-fixture-retained-settling-watch-actions="${VIDEO_B}"]`);
|
|
await expect(replacement).toHaveCount(1);
|
|
await expect(retained).toHaveCount(1);
|
|
await expect(retained).toBeHidden();
|
|
await expectWatchInitialized(page, VIDEO_B);
|
|
await expect(replacement.locator(":scope > .ryd-tooltip")).toHaveCount(1);
|
|
await expect(replacement.locator("#ryd-dislike-tooltip")).toContainText("200 / 22");
|
|
await expect(retained.locator(".ryd-tooltip")).toHaveCount(0);
|
|
await expect(retained.locator("#return-youtube-dislike-bar-container")).toHaveCount(0);
|
|
await expect(retained.locator("#return-youtube-dislike-bar")).toHaveCount(0);
|
|
await expect(retained.locator("#ryd-dislike-tooltip")).toHaveCount(0);
|
|
|
|
await page.waitForTimeout(600);
|
|
await expect(page.locator(".ryd-tooltip")).toHaveCount(1);
|
|
await expect(page.locator("#return-youtube-dislike-bar-container")).toHaveCount(1);
|
|
await expect(page.locator("#return-youtube-dislike-bar")).toHaveCount(1);
|
|
await expect(page.locator("#ryd-dislike-tooltip")).toHaveCount(1);
|
|
expect(
|
|
harness.backend.requestsFor("GET", "/votes").filter((request) => request.query.videoId === VIDEO_B),
|
|
).toHaveLength(countRequestsBeforeReplacement);
|
|
|
|
await expectOneActivation(page, harness.backend, VIDEO_B);
|
|
await expectWatchInitialized(page, VIDEO_B, COUNTS[VIDEO_B].dislikes + 1);
|
|
expect(
|
|
harness.backend.requestsFor("GET", "/votes").filter((request) => request.query.videoId === VIDEO_B),
|
|
).toHaveLength(countRequestsBeforeReplacement);
|
|
await expectHealthyRuntime(page, harness);
|
|
});
|
|
|
|
test("watch autoplay initializes replaced controls without a navigation event", async ({ context, page }) => {
|
|
const harness = await launchNavigationHarness({ context, page }, { pageKind: "watch", videoId: VIDEO_A });
|
|
await expectWatchInitialized(page, VIDEO_A);
|
|
|
|
await page.evaluate(() => window.__navigationFixture.dispatchEnded());
|
|
|
|
await expectWatchInitialized(page, VIDEO_B);
|
|
await expectOneActivation(page, harness.backend, VIDEO_B);
|
|
await expectWatchInitialized(page, VIDEO_B, COUNTS[VIDEO_B].dislikes + 1);
|
|
expect(
|
|
harness.backend.requestsFor("GET", "/votes").filter((request) => request.query.videoId === VIDEO_B),
|
|
).toHaveLength(1);
|
|
await expectHealthyRuntime(page, harness);
|
|
});
|
|
|
|
test("delayed watch navigation never binds outgoing controls to the target video", async ({ context, page }) => {
|
|
const harness = await launchNavigationHarness({ context, page }, { pageKind: "watch", videoId: VIDEO_A });
|
|
await expectWatchInitialized(page, VIDEO_A);
|
|
const outgoingControls = page.locator(`[data-fixture-control-video-id="${VIDEO_A}"]`);
|
|
|
|
await page.evaluate((videoId) => window.__navigationFixture.navigateDelayedWatch(videoId), VIDEO_B);
|
|
|
|
await expect(page).toHaveURL((url) => url.pathname === "/watch" && url.searchParams.get("v") === VIDEO_B);
|
|
await expect(page.locator(`ytd-watch-flexy[video-id="${VIDEO_B}"]`)).toHaveCount(1);
|
|
await expect(outgoingControls).toBeVisible();
|
|
await page.evaluate(() => window.__navigationFixture.mutateOutgoingWatchDescendant());
|
|
await expect(outgoingControls.locator('[data-fixture-irrelevant-watch-count-mutation="true"]')).toHaveCount(1);
|
|
await expect(page.locator('[data-fixture-irrelevant-watch-tooltip-mutation="true"]')).toHaveCount(1);
|
|
await page.waitForTimeout(600);
|
|
await expect(outgoingControls.locator('[data-fixture-role="dislike"] #text')).toHaveText("11");
|
|
await outgoingControls.locator('[data-fixture-role="dislike"] button').click();
|
|
await page.waitForTimeout(100);
|
|
expect(
|
|
harness.backend.requestsFor("GET", "/votes").filter((request) => request.query.videoId === VIDEO_B),
|
|
).toHaveLength(0);
|
|
expect(harness.backend.requestsFor("POST", "/interact/vote")).toHaveLength(0);
|
|
expect(harness.backend.requestsFor("POST", "/interact/confirmVote")).toHaveLength(0);
|
|
|
|
const outgoingDislike = outgoingControls.locator('[data-fixture-role="dislike"]');
|
|
await page.evaluate(() => window.__navigationFixture.replaceDelayedWatchControl("like"));
|
|
await expect(outgoingControls.locator('[data-fixture-watch-replacement="like"]')).toHaveCount(1);
|
|
await expect(outgoingDislike).toBeVisible();
|
|
await page.waitForTimeout(600);
|
|
await outgoingDislike.locator("button").click();
|
|
await page.waitForTimeout(100);
|
|
expect(
|
|
harness.backend.requestsFor("GET", "/votes").filter((request) => request.query.videoId === VIDEO_B),
|
|
).toHaveLength(0);
|
|
expect(harness.backend.requestsFor("POST", "/interact/vote")).toHaveLength(0);
|
|
|
|
await page.evaluate(() => window.__navigationFixture.replaceDelayedWatchControl("dislike"));
|
|
await expect(outgoingControls.locator('[data-fixture-watch-replacement="dislike"]')).toHaveCount(1);
|
|
await expectWatchInitialized(page, VIDEO_B);
|
|
await expectOneActivation(page, harness.backend, VIDEO_B);
|
|
await expectWatchInitialized(page, VIDEO_B, COUNTS[VIDEO_B].dislikes + 1);
|
|
expect(
|
|
harness.backend.requestsFor("GET", "/votes").filter((request) => request.query.videoId === VIDEO_B),
|
|
).toHaveLength(1);
|
|
await expectHealthyRuntime(page, harness);
|
|
});
|
|
|
|
test("same-node watch reuse requires independent native refresh on both activation paths", async ({
|
|
context,
|
|
page,
|
|
}) => {
|
|
const harness = await launchNavigationHarness({ context, page }, { pageKind: "watch", videoId: VIDEO_A });
|
|
await expectWatchInitialized(page, VIDEO_A);
|
|
const reusedControls = page.locator(`[data-fixture-control-video-id="${VIDEO_A}"]`);
|
|
const reusedLikeButton = reusedControls.locator('[data-fixture-role="like"] button');
|
|
const reusedDislikeButton = reusedControls.locator('[data-fixture-role="dislike"] button');
|
|
await reusedLikeButton.evaluate((button) => button.setAttribute("data-fixture-same-node", "like"));
|
|
await reusedDislikeButton.evaluate((button) => button.setAttribute("data-fixture-same-node", "dislike"));
|
|
|
|
await page.evaluate((videoId) => window.__navigationFixture.navigateDelayedWatch(videoId), VIDEO_B);
|
|
await page.evaluate(() => window.__navigationFixture.mutateOutgoingWatchDescendant());
|
|
|
|
await expect(page).toHaveURL((url) => url.pathname === "/watch" && url.searchParams.get("v") === VIDEO_B);
|
|
await page.waitForTimeout(600);
|
|
await expect(reusedControls.locator('[data-fixture-role="dislike"] #text')).toHaveText("11");
|
|
await reusedDislikeButton.click();
|
|
await page.waitForTimeout(100);
|
|
expect(
|
|
harness.backend.requestsFor("GET", "/votes").filter((request) => request.query.videoId === VIDEO_B),
|
|
).toHaveLength(0);
|
|
expect(harness.backend.requestsFor("POST", "/interact/vote")).toHaveLength(0);
|
|
|
|
await page.evaluate((videoId) => window.__navigationFixture.refreshReusedWatchControl("like", videoId), VIDEO_B);
|
|
await expect(reusedLikeButton).toHaveAttribute("aria-label", `like refreshed for ${VIDEO_B}`);
|
|
await expect(reusedLikeButton).toHaveAttribute("data-fixture-same-node", "like");
|
|
await page.waitForTimeout(600);
|
|
await reusedDislikeButton.click();
|
|
await page.waitForTimeout(100);
|
|
expect(
|
|
harness.backend.requestsFor("GET", "/votes").filter((request) => request.query.videoId === VIDEO_B),
|
|
).toHaveLength(0);
|
|
expect(harness.backend.requestsFor("POST", "/interact/vote")).toHaveLength(0);
|
|
|
|
await page.evaluate((videoId) => window.__navigationFixture.refreshReusedWatchControl("dislike", videoId), VIDEO_B);
|
|
await expect(reusedDislikeButton).toHaveAttribute("aria-label", `dislike refreshed for ${VIDEO_B}`);
|
|
await expect(reusedDislikeButton).toHaveAttribute("data-fixture-same-node", "dislike");
|
|
await expectWatchInitialized(page, VIDEO_B);
|
|
await expectOneActivation(page, harness.backend, VIDEO_B);
|
|
await expectWatchInitialized(page, VIDEO_B, COUNTS[VIDEO_B].dislikes + 1);
|
|
expect(
|
|
harness.backend.requestsFor("GET", "/votes").filter((request) => request.query.videoId === VIDEO_B),
|
|
).toHaveLength(1);
|
|
expect(harness.backend.requestsFor("POST", "/interact/vote")).toHaveLength(1);
|
|
await expectHealthyRuntime(page, harness);
|
|
});
|
|
|
|
test("pre-navigation native drift cannot satisfy same-node watch refresh for the next video", async ({
|
|
context,
|
|
page,
|
|
}) => {
|
|
const harness = await launchNavigationHarness({ context, page }, { pageKind: "watch", videoId: VIDEO_A });
|
|
await expectWatchInitialized(page, VIDEO_A);
|
|
const reusedControls = page.locator(`[data-fixture-control-video-id="${VIDEO_A}"]`);
|
|
const reusedLikeButton = reusedControls.locator('[data-fixture-role="like"] button');
|
|
const reusedDislikeButton = reusedControls.locator('[data-fixture-role="dislike"] button');
|
|
await reusedLikeButton.evaluate((button) => button.setAttribute("data-fixture-pre-navigation-node", "like"));
|
|
await reusedDislikeButton.evaluate((button) => button.setAttribute("data-fixture-pre-navigation-node", "dislike"));
|
|
|
|
await page.evaluate(
|
|
({ videoId }) => {
|
|
window.__navigationFixture.driftWatchControlBeforeNavigation("like", videoId);
|
|
window.__navigationFixture.driftWatchControlBeforeNavigation("dislike", videoId);
|
|
},
|
|
{ videoId: VIDEO_A },
|
|
);
|
|
await expect(reusedLikeButton).toHaveAttribute("aria-label", `like drift while ${VIDEO_A}`);
|
|
await expect(reusedDislikeButton).toHaveAttribute("aria-label", `dislike drift while ${VIDEO_A}`);
|
|
await page.waitForTimeout(600);
|
|
|
|
await page.evaluate((videoId) => window.__navigationFixture.navigateDelayedWatch(videoId), VIDEO_B);
|
|
await page.evaluate(() => window.__navigationFixture.mutateOutgoingWatchDescendant());
|
|
|
|
await expect(page).toHaveURL((url) => url.pathname === "/watch" && url.searchParams.get("v") === VIDEO_B);
|
|
await page.waitForTimeout(600);
|
|
await expect(reusedControls.locator('[data-fixture-role="dislike"] #text')).toHaveText("11");
|
|
await expect(page.locator("#ryd-dislike-tooltip")).toContainText("100 / 11");
|
|
await reusedDislikeButton.click();
|
|
await expect(reusedDislikeButton).toHaveAttribute("aria-pressed", "true");
|
|
await page.waitForTimeout(100);
|
|
expect(
|
|
harness.backend.requestsFor("GET", "/votes").filter((request) => request.query.videoId === VIDEO_B),
|
|
).toHaveLength(0);
|
|
expect(harness.backend.requestsFor("POST", "/interact/vote")).toHaveLength(0);
|
|
|
|
await page.evaluate((videoId) => window.__navigationFixture.refreshReusedWatchControl("like", videoId), VIDEO_B);
|
|
await expect(reusedLikeButton).toHaveAttribute("aria-label", `like refreshed for ${VIDEO_B}`);
|
|
await expect(reusedLikeButton).toHaveAttribute("data-fixture-pre-navigation-node", "like");
|
|
await page.waitForTimeout(600);
|
|
expect(
|
|
harness.backend.requestsFor("GET", "/votes").filter((request) => request.query.videoId === VIDEO_B),
|
|
).toHaveLength(0);
|
|
|
|
await page.evaluate((videoId) => window.__navigationFixture.refreshReusedWatchControl("dislike", videoId), VIDEO_B);
|
|
await expect(reusedDislikeButton).toHaveAttribute("aria-label", `dislike refreshed for ${VIDEO_B}`);
|
|
await expect(reusedDislikeButton).toHaveAttribute("aria-pressed", "false");
|
|
await expect(reusedDislikeButton).toHaveAttribute("data-fixture-pre-navigation-node", "dislike");
|
|
await expectWatchInitialized(page, VIDEO_B);
|
|
await expectOneActivation(page, harness.backend, VIDEO_B);
|
|
await expectWatchInitialized(page, VIDEO_B, COUNTS[VIDEO_B].dislikes + 1);
|
|
expect(
|
|
harness.backend.requestsFor("GET", "/votes").filter((request) => request.query.videoId === VIDEO_B),
|
|
).toHaveLength(1);
|
|
expect(harness.backend.requestsFor("POST", "/interact/vote")).toHaveLength(1);
|
|
await expectHealthyRuntime(page, harness);
|
|
});
|
|
|
|
test("navigation-start snapshots same-node watch refreshes that occur before navigation finish", async ({
|
|
context,
|
|
page,
|
|
}) => {
|
|
const harness = await launchNavigationHarness({ context, page }, { pageKind: "watch", videoId: VIDEO_A });
|
|
await expectWatchInitialized(page, VIDEO_A);
|
|
const reusedControls = page.locator(`[data-fixture-control-video-id="${VIDEO_A}"]`);
|
|
const reusedLikeButton = reusedControls.locator('[data-fixture-role="like"] button');
|
|
const reusedDislikeButton = reusedControls.locator('[data-fixture-role="dislike"] button');
|
|
await reusedLikeButton.evaluate((button) => button.setAttribute("data-fixture-pre-finish-node", "like"));
|
|
await reusedDislikeButton.evaluate((button) => button.setAttribute("data-fixture-pre-finish-node", "dislike"));
|
|
|
|
await page.evaluate(
|
|
({ videoId }) => {
|
|
window.__navigationFixture.driftWatchControlBeforeNavigation("like", videoId);
|
|
window.__navigationFixture.driftWatchControlBeforeNavigation("dislike", videoId);
|
|
},
|
|
{ videoId: VIDEO_A },
|
|
);
|
|
await page.waitForTimeout(600);
|
|
|
|
await page.evaluate((videoId) => window.__navigationFixture.beginSameNodeWatchNavigation(videoId), VIDEO_B);
|
|
await page.evaluate((videoId) => {
|
|
window.__navigationFixture.refreshReusedWatchControl("like", videoId);
|
|
window.__navigationFixture.refreshReusedWatchControl("dislike", videoId);
|
|
}, VIDEO_B);
|
|
|
|
await expect(page).toHaveURL((url) => url.pathname === "/watch" && url.searchParams.get("v") === VIDEO_B);
|
|
await expect(page.locator(`ytd-watch-flexy[video-id="${VIDEO_B}"]`)).toHaveCount(1);
|
|
await expect(reusedLikeButton).toHaveAttribute("aria-label", `like refreshed for ${VIDEO_B}`);
|
|
await expect(reusedDislikeButton).toHaveAttribute("aria-label", `dislike refreshed for ${VIDEO_B}`);
|
|
expect(
|
|
harness.backend.requestsFor("GET", "/votes").filter((request) => request.query.videoId === VIDEO_B),
|
|
).toHaveLength(0);
|
|
|
|
await page.evaluate(() => window.__navigationFixture.finishSameNodeWatchNavigation());
|
|
|
|
await expectWatchInitialized(page, VIDEO_B);
|
|
await expect(reusedLikeButton).toHaveAttribute("data-fixture-pre-finish-node", "like");
|
|
await expect(reusedDislikeButton).toHaveAttribute("data-fixture-pre-finish-node", "dislike");
|
|
await expectOneActivation(page, harness.backend, VIDEO_B);
|
|
await expectWatchInitialized(page, VIDEO_B, COUNTS[VIDEO_B].dislikes + 1);
|
|
expect(
|
|
harness.backend.requestsFor("GET", "/votes").filter((request) => request.query.videoId === VIDEO_B),
|
|
).toHaveLength(1);
|
|
expect(harness.backend.requestsFor("POST", "/interact/vote")).toHaveLength(1);
|
|
await expectHealthyRuntime(page, harness);
|
|
});
|
|
|
|
test("navigation-start combines a replaced Like target with a reused Dislike refresh before finish", async ({
|
|
context,
|
|
page,
|
|
}) => {
|
|
const harness = await launchNavigationHarness({ context, page }, { pageKind: "watch", videoId: VIDEO_A });
|
|
await expectWatchInitialized(page, VIDEO_A);
|
|
const controls = page.locator(`[data-fixture-control-video-id="${VIDEO_A}"]`);
|
|
const originalLikeButton = controls.locator('[data-fixture-role="like"] button');
|
|
const reusedDislikeButton = controls.locator('[data-fixture-role="dislike"] button');
|
|
await originalLikeButton.evaluate((button) => {
|
|
window.__fixtureOriginalMixedLikeTarget = button;
|
|
});
|
|
await reusedDislikeButton.evaluate((button) => button.setAttribute("data-fixture-mixed-reused-node", "dislike"));
|
|
|
|
await page.evaluate((videoId) => window.__navigationFixture.beginSameNodeWatchNavigation(videoId), VIDEO_B);
|
|
await page.evaluate((videoId) => {
|
|
window.__navigationFixture.replaceDelayedWatchControl("like");
|
|
window.__navigationFixture.refreshReusedWatchControl("dislike", videoId);
|
|
}, VIDEO_B);
|
|
|
|
const replacementLike = controls.locator('[data-fixture-watch-replacement="like"]');
|
|
await expect(page).toHaveURL((url) => url.pathname === "/watch" && url.searchParams.get("v") === VIDEO_B);
|
|
await expect(page.locator(`ytd-watch-flexy[video-id="${VIDEO_B}"]`)).toHaveCount(1);
|
|
await expect(replacementLike).toHaveCount(1);
|
|
expect(
|
|
await replacementLike
|
|
.locator("button")
|
|
.evaluate(
|
|
(button) =>
|
|
!window.__fixtureOriginalMixedLikeTarget.isConnected && button !== window.__fixtureOriginalMixedLikeTarget,
|
|
),
|
|
).toBe(true);
|
|
await expect(reusedDislikeButton).toHaveAttribute("aria-label", `dislike refreshed for ${VIDEO_B}`);
|
|
await expect(reusedDislikeButton).toHaveAttribute("data-fixture-mixed-reused-node", "dislike");
|
|
expect(
|
|
harness.backend.requestsFor("GET", "/votes").filter((request) => request.query.videoId === VIDEO_B),
|
|
).toHaveLength(0);
|
|
|
|
await page.evaluate(() => window.__navigationFixture.finishSameNodeWatchNavigation());
|
|
|
|
await expectWatchInitialized(page, VIDEO_B);
|
|
await expect(replacementLike).toHaveCount(1);
|
|
await expect(reusedDislikeButton).toHaveAttribute("data-fixture-mixed-reused-node", "dislike");
|
|
expect(
|
|
harness.backend.requestsFor("GET", "/votes").filter((request) => request.query.videoId === VIDEO_B),
|
|
).toHaveLength(1);
|
|
await expectOneActivation(page, harness.backend, VIDEO_B);
|
|
await expectWatchInitialized(page, VIDEO_B, COUNTS[VIDEO_B].dislikes + 1);
|
|
expect(harness.backend.requestsFor("POST", "/interact/vote")).toHaveLength(1);
|
|
expect(harness.backend.requestsFor("POST", "/interact/confirmVote")).toHaveLength(1);
|
|
await expectHealthyRuntime(page, harness);
|
|
});
|
|
|
|
test("mobile channel to Short ignores stale channel controls until the target mounts", async ({ context, page }) => {
|
|
const harness = await launchNavigationHarness(
|
|
{ context, page },
|
|
{ hostname: "m.youtube.com", pageKind: "channel", videoId: VIDEO_A },
|
|
);
|
|
|
|
await page.locator("#channel-short").click();
|
|
|
|
await expect(page).toHaveURL((url) => url.pathname === `/shorts/${VIDEO_A}`);
|
|
const decoyDislikeCount = page.locator('[data-fixture-decoy-controls] [data-fixture-role="dislike"] #text');
|
|
await page.waitForTimeout(250);
|
|
await expect(decoyDislikeCount).toHaveText("");
|
|
expect(harness.backend.requestsFor("GET", "/votes")).toHaveLength(0);
|
|
await expectMobileShortInitialized(page, VIDEO_A);
|
|
await expectOneActivation(page, harness.backend, VIDEO_A);
|
|
await expectMobileShortInitialized(page, VIDEO_A, COUNTS[VIDEO_A].dislikes + 1);
|
|
await expectHealthyRuntime(page, harness);
|
|
});
|
|
|
|
test("mobile Short autoplay activates its preloaded sibling without a navigation event", async ({ context, page }) => {
|
|
const harness = await launchNavigationHarness(
|
|
{ context, page },
|
|
{ hostname: "m.youtube.com", pageKind: "shorts", videoId: VIDEO_A },
|
|
);
|
|
await expectMobileShortInitialized(page, VIDEO_A);
|
|
const preloadedNextShort = page.locator(`[data-fixture-mobile-short="${VIDEO_B}"]`);
|
|
await expect(preloadedNextShort).toBeHidden();
|
|
await preloadedNextShort.evaluate((renderer) => renderer.setAttribute("data-fixture-preloaded-marker", "true"));
|
|
|
|
await page.evaluate(() => window.__navigationFixture.dispatchEnded());
|
|
|
|
await expectMobileShortInitialized(page, VIDEO_B);
|
|
await expect(page.locator(`[data-fixture-mobile-short="${VIDEO_B}"][is-active]`)).toHaveAttribute(
|
|
"data-fixture-preloaded-marker",
|
|
"true",
|
|
);
|
|
await expectOneActivation(page, harness.backend, VIDEO_B);
|
|
await expectMobileShortInitialized(page, VIDEO_B, COUNTS[VIDEO_B].dislikes + 1);
|
|
await expectHealthyRuntime(page, harness);
|
|
});
|
|
|
|
test("same-video mobile overlay replacement reinitializes one control without a navigation event", async ({
|
|
context,
|
|
page,
|
|
}) => {
|
|
const harness = await launchNavigationHarness(
|
|
{ context, page },
|
|
{ hostname: "m.youtube.com", pageKind: "shorts", videoId: VIDEO_A },
|
|
);
|
|
await expectMobileShortInitialized(page, VIDEO_A);
|
|
|
|
await page.evaluate(() => window.__navigationFixture.replaceActiveMobileOverlay());
|
|
|
|
const replacement = page.locator(
|
|
`ytm-reel-video-renderer[video-id="${VIDEO_A}"][is-active] ytm-reel-player-overlay-renderer[data-fixture-replacement="true"]`,
|
|
);
|
|
await expect(replacement).toBeVisible();
|
|
await expectMobileShortInitialized(page, VIDEO_A);
|
|
await expectOneActivation(page, harness.backend, VIDEO_A);
|
|
await expectMobileShortInitialized(page, VIDEO_A, COUNTS[VIDEO_A].dislikes + 1);
|
|
expect(harness.backend.requestsFor("POST", "/interact/vote")).toHaveLength(1);
|
|
await expectHealthyRuntime(page, harness);
|
|
});
|