Files
return-youtube-dislike/Extensions/UserScript/e2e/userscript-initialization-race.e2e.js
T

419 lines
16 KiB
JavaScript
Raw Normal View History

const { test, expect } = require("@playwright/test");
const {
CREDENTIAL_KEY,
VIDEO_A,
VIDEO_B,
createFakeBackend,
injectGeneratedUserscript,
installGmEnvironment,
installHermeticRoutes,
openShortsFixture,
readGmValue,
} = require("./harness");
const EXISTING_CREDENTIALS = {
userId: "ExistingUserscriptCredential000000000001",
registrationConfirmed: true,
};
const SYNTHETIC_STATE_KEY = `rydSyntheticDislikedShort:${VIDEO_A}`;
function visibleVoteButton(page, role) {
return page.locator(`[data-ryd-role="${role}"]:visible button`);
}
async function installDelayedSyntheticStateRead(page) {
await page.evaluate((delayedKey) => {
const originalGetValue = globalThis.GM.getValue;
let releaseRead;
let reportReadStarted;
let delayed = false;
const readGate = new Promise((resolve) => {
releaseRead = resolve;
});
globalThis.__rydDelayedGmReadStarted = new Promise((resolve) => {
reportReadStarted = resolve;
});
globalThis.__releaseRydDelayedGmRead = releaseRead;
globalThis.GM.getValue = async (key, fallbackValue) => {
if (key === delayedKey) {
if (!delayed) {
delayed = true;
reportReadStarted();
await readGate;
}
}
return originalGetValue(key, fallbackValue);
};
}, SYNTHETIC_STATE_KEY);
}
async function launchDelayedShorts(
{ context, page },
{ backendOptions, configureBackend, nativeDislike = false } = {},
) {
const consoleErrors = [];
const pageErrors = [];
page.on("console", (message) => {
if (message.type() === "error") consoleErrors.push(message.text());
});
page.on("pageerror", (error) => pageErrors.push(error.message));
const backend = createFakeBackend(backendOptions);
configureBackend?.(backend);
await installGmEnvironment(context, { [CREDENTIAL_KEY]: EXISTING_CREDENTIALS });
await installHermeticRoutes(context, backend);
await openShortsFixture(page, VIDEO_A);
await page.evaluate(() => {
globalThis.__unhandledRejections = [];
addEventListener("unhandledrejection", (event) => {
globalThis.__unhandledRejections.push(
event.reason instanceof Error ? event.reason.message : String(event.reason),
);
});
});
if (nativeDislike) {
await page.evaluate(() => window.__shortsFixture.installNativeDislike());
}
await installDelayedSyntheticStateRead(page);
await injectGeneratedUserscript(page);
await page.evaluate(() => globalThis.__rydDelayedGmReadStarted);
return { backend, consoleErrors, pageErrors };
}
async function releaseDelayedRead(page) {
await page.evaluate(() => globalThis.__releaseRydDelayedGmRead());
}
async function expectCompletedHandshakes(backend, expectedValues) {
await expect.poll(() => backend.requestsFor("POST", "/interact/confirmVote").length).toBe(expectedValues.length);
await expect
.poll(() => backend.requestsFor("POST", "/interact/confirmVote").every((request) => request.respondedAt))
.toBe(true);
await new Promise((resolve) => setTimeout(resolve, 150));
const votes = backend.requestsFor("POST", "/interact/vote");
const confirmations = backend.requestsFor("POST", "/interact/confirmVote");
expect(votes).toHaveLength(expectedValues.length);
expect(confirmations).toHaveLength(expectedValues.length);
expect(votes.map((request) => request.body.value)).toEqual(expectedValues);
for (let index = 0; index < expectedValues.length; index += 1) {
expect(votes[index].body).toEqual({
userId: EXISTING_CREDENTIALS.userId,
videoId: VIDEO_A,
value: expectedValues[index],
});
expect(confirmations[index].body).toMatchObject({
userId: EXISTING_CREDENTIALS.userId,
videoId: VIDEO_A,
});
}
}
async function expectSingleCompletedHandshake(backend, { value, videoId }) {
await expect.poll(() => backend.requestsFor("POST", "/interact/confirmVote").length).toBe(1);
await expect.poll(() => backend.requestsFor("POST", "/interact/confirmVote")[0]?.respondedAt).toBeTruthy();
await new Promise((resolve) => setTimeout(resolve, 150));
const votes = backend.requestsFor("POST", "/interact/vote");
const confirmations = backend.requestsFor("POST", "/interact/confirmVote");
expect(votes).toHaveLength(1);
expect(confirmations).toHaveLength(1);
expect(votes[0].body).toEqual({ userId: EXISTING_CREDENTIALS.userId, videoId, value });
expect(confirmations[0].body).toMatchObject({ userId: EXISTING_CREDENTIALS.userId, videoId });
}
async function expectNoRuntimeFailures(page, harness) {
expect(harness.backend.blockedRequests).toEqual([]);
expect(harness.consoleErrors).toEqual([]);
expect(harness.pageErrors).toEqual([]);
expect(await page.evaluate(() => globalThis.__unhandledRejections)).toEqual([]);
}
test("immediate native Like during synthetic Shorts initialization submits once and remains coherent", async ({
context,
page,
}) => {
const harness = await launchDelayedShorts({ context, page });
const likeButton = visibleVoteButton(page, "like");
await likeButton.click();
try {
await expect.poll(() => harness.backend.requestsFor("POST", "/interact/confirmVote").length).toBe(1);
} finally {
await releaseDelayedRead(page);
}
await expect(page.locator("[data-ryd-synthetic-shorts-dislike]:visible #text")).toHaveText("25");
await expect(likeButton).toHaveAttribute("aria-pressed", "true");
await expect(visibleVoteButton(page, "dislike")).toHaveAttribute("aria-pressed", "false");
await likeButton.click();
await expectCompletedHandshakes(harness.backend, [1, 0]);
await expect(likeButton).toHaveAttribute("aria-pressed", "false");
await expect(visibleVoteButton(page, "dislike")).toHaveAttribute("aria-pressed", "false");
await expectNoRuntimeFailures(page, harness);
});
test("immediate native Dislike during native Shorts initialization submits once and remains coherent", async ({
context,
page,
}) => {
const harness = await launchDelayedShorts({ context, page }, { nativeDislike: true });
const dislikeButton = visibleVoteButton(page, "dislike");
await dislikeButton.click();
try {
await expect.poll(() => harness.backend.requestsFor("POST", "/interact/confirmVote").length).toBe(1);
} finally {
await releaseDelayedRead(page);
}
await expect(dislikeButton.locator("#text")).toHaveText(/\d/);
await expect(dislikeButton).toHaveAttribute("aria-pressed", "true");
await expect(visibleVoteButton(page, "like")).toHaveAttribute("aria-pressed", "false");
await dislikeButton.click();
await expectCompletedHandshakes(harness.backend, [-1, 0]);
await expect(dislikeButton).toHaveAttribute("aria-pressed", "false");
await expect(visibleVoteButton(page, "like")).toHaveAttribute("aria-pressed", "false");
await expectNoRuntimeFailures(page, harness);
});
test("stale native Shorts hydration preserves video A's captured dislike across a recycled video B", async ({
context,
page,
}) => {
const harness = await launchDelayedShorts(
{ context, page },
{
backendOptions: {
countsByVideo: {
[VIDEO_A]: { dislikes: 11, likes: 100 },
[VIDEO_B]: { dislikes: 22, likes: 200 },
},
},
nativeDislike: true,
},
);
await visibleVoteButton(page, "dislike").click();
try {
await expect.poll(() => harness.backend.requestsFor("POST", "/interact/confirmVote").length).toBe(1);
expect(harness.backend.requestsFor("POST", "/interact/vote")[0].body).toEqual({
userId: EXISTING_CREDENTIALS.userId,
videoId: VIDEO_A,
value: -1,
});
await page.evaluate((videoId) => window.__shortsFixture.recycleActiveRenderer(videoId), VIDEO_B);
} finally {
await releaseDelayedRead(page);
}
await expect(page.locator('[data-ryd-role="dislike"]:visible #text')).toHaveText("22");
await expect.poll(() => readGmValue(page, SYNTHETIC_STATE_KEY)).toBe(true);
await page.evaluate((videoId) => {
window.__shortsFixture.recycleActiveRenderer(videoId);
window.__shortsFixture.removeNativeDislike();
}, VIDEO_A);
await expect(page.locator('[data-ryd-role="dislike"]:visible #text')).toHaveText("11");
const restoredSyntheticDislike = page.locator("[data-ryd-synthetic-shorts-dislike]:visible button");
await expect(restoredSyntheticDislike).toHaveAttribute("aria-pressed", "true");
await restoredSyntheticDislike.click();
await expectCompletedHandshakes(harness.backend, [-1, 0]);
await expect(restoredSyntheticDislike).toHaveAttribute("aria-pressed", "false");
await expect.poll(() => readGmValue(page, SYNTHETIC_STATE_KEY)).toBe(null);
expect(
harness.backend.requestsFor("POST", "/interact/vote").filter((request) => request.body.videoId === VIDEO_B),
).toHaveLength(0);
await expectNoRuntimeFailures(page, harness);
});
test("video B Like remains bound while video A hydration is delayed", async ({ context, page }) => {
const harness = await launchDelayedShorts(
{ context, page },
{
backendOptions: {
countsByVideo: {
[VIDEO_A]: { dislikes: 11, likes: 100 },
[VIDEO_B]: { dislikes: 22, likes: 200 },
},
},
},
);
await page.evaluate((videoId) => window.__shortsFixture.recycleActiveRenderer(videoId), VIDEO_B);
const videoBLike = visibleVoteButton(page, "like");
await videoBLike.click();
try {
await expectSingleCompletedHandshake(harness.backend, { videoId: VIDEO_B, value: 1 });
} finally {
await releaseDelayedRead(page);
}
await expect(page.locator('[data-ryd-role="dislike"]:visible #text')).toHaveText("22");
await expect(videoBLike).toHaveAttribute("aria-pressed", "true");
await expect(visibleVoteButton(page, "dislike")).toHaveAttribute("aria-pressed", "false");
expect(
harness.backend.requestsFor("POST", "/interact/vote").filter((request) => request.body.videoId === VIDEO_A),
).toHaveLength(0);
await expectNoRuntimeFailures(page, harness);
});
for (const replacement of ["inner Like button", "action bar"]) {
test(`a replacement ${replacement} receives the pending same-video hydration listener`, async ({ context, page }) => {
const harness = await launchDelayedShorts({ context, page });
await page.evaluate((replacementKind) => {
if (replacementKind === "inner Like button") {
window.__shortsFixture.replaceInnerButton("like");
} else {
window.__shortsFixture.replaceActionBar();
}
}, replacement);
const replacementLike = visibleVoteButton(page, "like");
await replacementLike.click();
try {
await expectSingleCompletedHandshake(harness.backend, { videoId: VIDEO_A, value: 1 });
} finally {
await releaseDelayedRead(page);
}
await expect(page.locator('[data-ryd-role="dislike"]:visible #text')).toHaveText("25");
await expect(replacementLike).toHaveAttribute("aria-pressed", "true");
await expect(page.locator("[data-ryd-synthetic-shorts-dislike]:visible")).toHaveCount(1);
await expectNoRuntimeFailures(page, harness);
});
}
test("enabled synthetic Dislike clears native Like during a pending same-wrapper rehydration", async ({
context,
page,
}) => {
const harness = await launchDelayedShorts({ context, page });
await releaseDelayedRead(page);
const syntheticDislike = visibleVoteButton(page, "dislike");
await expect(page.locator('[data-ryd-role="dislike"]:visible #text')).toHaveText("25");
await expect(syntheticDislike).toHaveAttribute("aria-disabled", "false");
await installDelayedSyntheticStateRead(page);
await page.evaluate((videoId) => window.__shortsFixture.activate(videoId, { state: "liked" }), VIDEO_A);
await page.evaluate(() => globalThis.__rydDelayedGmReadStarted);
await expect(visibleVoteButton(page, "like")).toHaveAttribute("aria-pressed", "true");
await syntheticDislike.click();
try {
await expectSingleCompletedHandshake(harness.backend, { videoId: VIDEO_A, value: -1 });
} finally {
await releaseDelayedRead(page);
}
await expect(visibleVoteButton(page, "like")).toHaveAttribute("aria-pressed", "false");
await expect(syntheticDislike).toHaveAttribute("aria-pressed", "true");
await expect.poll(() => readGmValue(page, SYNTHETIC_STATE_KEY)).toBe(true);
expect(harness.backend.requestsFor("GET", "/votes")).toHaveLength(1);
await expectNoRuntimeFailures(page, harness);
});
test("new same-video hydration wins after two native Dislike transitions complete before the old read", async ({
context,
page,
}) => {
const harness = await launchDelayedShorts(
{ context, page },
{
backendOptions: {
countsByVideo: {
[VIDEO_A]: { dislikes: 25, likes: 100 },
},
},
nativeDislike: true,
},
);
await expect(page.locator('[data-ryd-role="dislike"]:visible #text')).toHaveText("25");
let readReleased = false;
try {
await visibleVoteButton(page, "dislike").click();
await expectSingleCompletedHandshake(harness.backend, { videoId: VIDEO_A, value: -1 });
await expect(page.locator('[data-ryd-role="dislike"]:visible #text')).toHaveText("26");
await page.evaluate(() => window.__shortsFixture.replaceActionBar());
const replacementDislike = visibleVoteButton(page, "dislike");
await replacementDislike.click();
await expectCompletedHandshakes(harness.backend, [-1, 0]);
await expect(replacementDislike).toHaveAttribute("aria-pressed", "false");
await expect(page.locator('[data-ryd-role="dislike"]:visible #text')).toHaveText("25");
await expect.poll(() => readGmValue(page, SYNTHETIC_STATE_KEY)).toBe(null);
expect(harness.backend.requestsFor("GET", "/votes")).toHaveLength(1);
} finally {
await releaseDelayedRead(page);
readReleased = true;
}
expect(readReleased).toBe(true);
const finalDislike = visibleVoteButton(page, "dislike");
await expect(finalDislike).toHaveAttribute("aria-pressed", "false");
await expect(page.locator('[data-ryd-role="dislike"]:visible #text')).toHaveText("25");
await expect.poll(() => readGmValue(page, SYNTHETIC_STATE_KEY)).toBe(null);
await page.waitForTimeout(200);
expect(harness.backend.requestsFor("GET", "/votes")).toHaveLength(1);
expect(harness.backend.requestsFor("POST", "/interact/vote").map((request) => request.body.value)).toEqual([-1, 0]);
await finalDislike.click();
await expectCompletedHandshakes(harness.backend, [-1, 0, -1]);
await expect(finalDislike).toHaveAttribute("aria-pressed", "true");
await expect(page.locator('[data-ryd-role="dislike"]:visible #text')).toHaveText("26");
await expect.poll(() => readGmValue(page, SYNTHETIC_STATE_KEY)).toBe(true);
expect(harness.backend.requestsFor("GET", "/votes")).toHaveLength(1);
await expectNoRuntimeFailures(page, harness);
});
test("fresh same-video count state supersedes stale hydration correction", async ({ context, page }) => {
const harness = await launchDelayedShorts(
{ context, page },
{
configureBackend: (backend) => {
backend.enqueue("GET", "/votes", { body: { dislikes: 25, likes: 100, rating: 4.5 } });
backend.enqueue("GET", "/votes", { body: { dislikes: 22, likes: 200, rating: 4.5 } });
backend.enqueue("GET", "/votes", { body: { dislikes: 26, likes: 100, rating: 4.5 } });
},
nativeDislike: true,
},
);
const dislikeCount = page.locator('[data-ryd-role="dislike"]:visible #text');
await expect(dislikeCount).toHaveText("25");
try {
await visibleVoteButton(page, "dislike").click();
await expectSingleCompletedHandshake(harness.backend, { videoId: VIDEO_A, value: -1 });
await expect(dislikeCount).toHaveText("26");
await page.evaluate((videoId) => window.__shortsFixture.recycleActiveRenderer(videoId), VIDEO_B);
await expect(dislikeCount).toHaveText("22");
await page.evaluate(
(videoId) => window.__shortsFixture.recycleActiveRenderer(videoId, { state: "disliked" }),
VIDEO_A,
);
await expect(dislikeCount).toHaveText("26");
expect(
harness.backend.requestsFor("GET", "/votes").filter((request) => request.query.videoId === VIDEO_A),
).toHaveLength(2);
} finally {
await releaseDelayedRead(page);
}
await page.waitForTimeout(250);
await expect(dislikeCount).toHaveText("26");
await expect(visibleVoteButton(page, "dislike")).toHaveAttribute("aria-pressed", "true");
await expect.poll(() => readGmValue(page, SYNTHETIC_STATE_KEY)).toBe(true);
expect(harness.backend.requestsFor("POST", "/interact/vote").map((request) => request.body.value)).toEqual([-1]);
expect(harness.backend.requestsFor("POST", "/interact/confirmVote")).toHaveLength(1);
expect(harness.backend.requestsFor("GET", "/votes")).toHaveLength(3);
expect(
harness.backend.requestsFor("GET", "/votes").filter((request) => request.query.videoId === VIDEO_B),
).toHaveLength(1);
await expectNoRuntimeFailures(page, harness);
});