Files
return-youtube-dislike/Extensions/e2e/hermetic-artifact-smoke.spec.js
T
Anarios 3657de9b2c Fix Shorts support and add userscript voting
Harden Shorts controls, reaction state, and navigation; share voting logic
with the extension; improve ratio-bar rendering; and add comprehensive Jest,
Playwright, artifact, and live-browser regression coverage.
2026-09-01 23:41:28 +02:00

545 lines
19 KiB
JavaScript

const fs = require("node:fs");
const { EventEmitter } = require("node:events");
const os = require("node:os");
const path = require("node:path");
const {
ARTIFACT_SMOKE_SCENARIO_ID,
ARTIFACT_WATCH_SPA_SCENARIO_ID,
ARTIFACT_WATCH_SPA_VOTE_SCENARIO_ID,
SHARED_ARTIFACT_SCENARIO_IDS,
assertLoopbackOrigin,
createPageSignalCollector,
isArtifactVoteHandshakeValid,
isSpaDestinationValid,
prepareHermeticExtensionArtifact,
readArtifactVoteHandshake,
runArtifactWatchRenderScenario,
runArtifactWatchSpaScenario,
runArtifactWatchSpaVoteScenario,
} = require("./hermetic-artifact-smoke");
const PRODUCTION_API_ORIGIN = "https://returnyoutubedislikeapi.com";
const temporaryDirectories = [];
function createExtensionFixture() {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "ryd-mv3-source-fixture-"));
temporaryDirectories.push(directory);
fs.writeFileSync(
path.join(directory, "manifest.json"),
JSON.stringify({ host_permissions: ["*://returnyoutubedislikeapi.com/*"], manifest_version: 3 }),
);
fs.writeFileSync(
path.join(directory, "ryd.background.js"),
`fetch("${PRODUCTION_API_ORIGIN}/register")\napi.runtime.onInstalled.addListener((details) => {\n maybeShowChangelog(details);\n});`,
);
fs.writeFileSync(path.join(directory, "ryd.content-script.js"), `fetch("${PRODUCTION_API_ORIGIN}/votes")`);
fs.writeFileSync(path.join(directory, "menu-fixer.js"), "document.documentElement.dataset.menuFixerLoaded = 'true';");
return directory;
}
afterEach(() => {
for (const directory of temporaryDirectories.splice(0)) {
fs.rmSync(directory, { force: true, recursive: true });
}
});
test("the artifact smoke is the shared watch-render scenario", () => {
expect(ARTIFACT_SMOKE_SCENARIO_ID).toBe("watch-render");
expect(ARTIFACT_WATCH_SPA_SCENARIO_ID).toBe("watch-spa-side-panel");
expect(ARTIFACT_WATCH_SPA_VOTE_SCENARIO_ID).toBe("watch-spa-dislike-activation");
expect(SHARED_ARTIFACT_SCENARIO_IDS).toEqual([
"watch-render",
"watch-spa-side-panel",
"watch-spa-dislike-activation",
]);
});
function validSpaSnapshot() {
const outgoing = {
barCount: 0,
containerCount: 0,
controlVideoIds: ["abcdefghijk"],
hidden: true,
present: true,
wrapperCount: 0,
};
return {
actionHostCount: 1,
barOwnedByDestination: true,
containerOwnedByDestination: true,
count: "65",
currentVideoId: "zyxwvutsrqp",
destinationBarCount: 1,
destinationContainerCount: 1,
destinationControlCount: 1,
destinationWrapperCount: 1,
fillRatio: 0.35,
globalBarCount: 1,
globalContainerCount: 1,
globalWrapperCount: 1,
insideOutgoing: { ...outgoing },
retainedBefore: { ...outgoing },
retainedDestination: { ...outgoing, controlVideoIds: ["zyxwvutsrqp"] },
tooltipText: "35 / 65",
urlVideoId: "zyxwvutsrqp",
visibleContainer: true,
visibleFill: true,
};
}
const ARTIFACT_USER_ID = "A".repeat(36);
function validVoteHandshake(change = {}) {
return {
confirmation: {
body: { solution: "AAAAAA==", userId: ARTIFACT_USER_ID, videoId: "zyxwvutsrqp" },
responded: true,
responseBody: true,
responseStatus: 200,
},
confirmationCount: 1,
expectedValue: -1,
expectedVideoId: "zyxwvutsrqp",
interactionCount: 2,
interactionPaths: ["/interact/vote", "/interact/confirmVote"],
sharedUserId: ARTIFACT_USER_ID,
vote: {
body: { userId: ARTIFACT_USER_ID, value: -1, videoId: "zyxwvutsrqp" },
},
voteCount: 1,
...change,
};
}
test.each(["http://127.0.0.1:43127", "http://localhost:43127", "http://[::1]:43127"])(
"accepts a loopback-only API origin: %s",
(origin) => {
expect(assertLoopbackOrigin(origin)).toBe(origin);
},
);
test.each(["https://returnyoutubedislikeapi.com", "https://api.example.test", "http://192.168.1.20:43127"])(
"rejects a non-loopback API origin: %s",
(origin) => {
expect(() => assertLoopbackOrigin(origin)).toThrow("Refusing to prepare a hermetic extension artifact");
},
);
test("redirects eager MV3 background traffic while leaving routed content-script traffic intact", () => {
const sourceDirectory = createExtensionFixture();
const prepared = prepareHermeticExtensionArtifact(sourceDirectory, "http://127.0.0.1:43127");
temporaryDirectories.push(prepared.temporaryRoot);
expect(prepared.replacements).toEqual({ "ryd.background.js": 1, firstInstallChangelogListener: 1 });
expect(prepared.routedBundles).toEqual(["ryd.content-script.js"]);
expect(fs.readFileSync(path.join(prepared.extensionDirectory, "ryd.background.js"), "utf8")).toContain(
"http://127.0.0.1:43127/register",
);
expect(fs.readFileSync(path.join(prepared.extensionDirectory, "ryd.background.js"), "utf8")).toContain(
"api.runtime.onInstalled.addListener(() => {});",
);
expect(fs.readFileSync(path.join(prepared.extensionDirectory, "ryd.content-script.js"), "utf8")).toContain(
`${PRODUCTION_API_ORIGIN}/votes`,
);
expect(fs.readFileSync(path.join(prepared.extensionDirectory, "menu-fixer.js"), "utf8")).toContain("menuFixerLoaded");
expect(JSON.parse(fs.readFileSync(path.join(prepared.extensionDirectory, "manifest.json"), "utf8"))).toMatchObject({
host_permissions: expect.arrayContaining(["http://127.0.0.1/*"]),
manifest_version: 3,
});
expect(fs.readFileSync(path.join(sourceDirectory, "ryd.background.js"), "utf8")).toContain(PRODUCTION_API_ORIGIN);
});
test("rejects an extension artifact whose injected auxiliary script was dropped by the build", () => {
const sourceDirectory = createExtensionFixture();
fs.rmSync(path.join(sourceDirectory, "menu-fixer.js"));
expect(() => prepareHermeticExtensionArtifact(sourceDirectory, "http://127.0.0.1:43127")).toThrow(
/missing menu-fixer\.js/,
);
});
test.each(["userscript", "extension"])("runs one shared artifact scenario contract for %s", async (runtime) => {
const events = [];
const adapter = {
assertNoPageSignals: jest.fn(async (scenarioId) => events.push(`signals:${scenarioId}`)),
close: jest.fn(async () => events.push("close")),
openWatch: jest.fn(async (videoId) => events.push(`open:${videoId}`)),
runtime,
start: jest.fn(async () => events.push("start")),
waitForWatchResult: jest.fn(async (videoId) => ({
count: "25",
fillVisible: true,
rateBarVisible: true,
videoId,
})),
};
await expect(runArtifactWatchRenderScenario(adapter, { videoId: "abcdefghijk" })).resolves.toMatchObject({
count: "25",
runtime,
scenarioId: "watch-render",
videoId: "abcdefghijk",
});
expect(events).toEqual(["start", "open:abcdefghijk", "signals:watch-render", "close"]);
});
test.each(["userscript", "extension"])("runs the same continuous A-to-B SPA contract for %s", async (runtime) => {
const events = [];
const adapter = {
assertNoPageSignals: jest.fn(async (scenarioId) => events.push(`signals:${scenarioId}`)),
assertSpaNetwork: jest.fn(async () => ({ fromVideoRequests: 1, interactionRequests: 0, toVideoRequests: 1 })),
close: jest.fn(async () => events.push("close")),
navigateSpaWatch: jest.fn(async () => ({
destination: { destinationReplaced: true },
outgoing: { beforeBarCount: 1 },
})),
openSpaWatch: jest.fn(async (videoId) => events.push(`open:${videoId}`)),
readSpaWatchSnapshot: jest.fn(async () => validSpaSnapshot()),
runtime,
start: jest.fn(async () => events.push("start")),
waitForWatchResult: jest.fn(async (videoId) => ({ count: "10", fillRatio: 0.9, videoId })),
};
await expect(
runArtifactWatchSpaScenario(adapter, {
intervalMs: 1,
stabilityDurationMs: 0,
stableForMs: 0,
timeoutMs: 1,
}),
).resolves.toMatchObject({
destination: { count: "65", fillRatio: 0.35, videoId: "zyxwvutsrqp" },
initial: { count: "10", fillRatio: 0.9, videoId: "abcdefghijk" },
readiness: { maxFirstValidMs: 1_000 },
runtime,
scenarioId: "watch-spa-side-panel",
traffic: { fromVideoRequests: 1, interactionRequests: 0, toVideoRequests: 1 },
});
expect(adapter.navigateSpaWatch).toHaveBeenCalledWith("abcdefghijk", "zyxwvutsrqp");
expect(events).toEqual(["start", "open:abcdefghijk", "signals:watch-spa-side-panel", "close"]);
});
test("recognizes only one ordered, successful destination dislike handshake", () => {
const records = [
{ method: "POST", pathname: "/puzzle/registration" },
{
body: { userId: ARTIFACT_USER_ID, value: -1, videoId: "zyxwvutsrqp" },
method: "POST",
pathname: "/interact/vote",
},
{
body: { solution: "AAAAAA==", userId: ARTIFACT_USER_ID, videoId: "zyxwvutsrqp" },
method: "POST",
pathname: "/interact/confirmVote",
respondedAt: 123,
responseBody: true,
responseStatus: 200,
},
];
const handshake = readArtifactVoteHandshake(records, 1, "zyxwvutsrqp", -1);
expect(handshake).toEqual(validVoteHandshake());
expect(isArtifactVoteHandshakeValid(handshake)).toBe(true);
});
test.each([
["duplicate listener requests", (value) => ({ ...value, interactionCount: 4, voteCount: 2, confirmationCount: 2 })],
["reversed request order", (value) => ({ ...value, interactionPaths: ["/interact/confirmVote", "/interact/vote"] })],
[
"the wrong destination video",
(value) => ({ ...value, vote: { body: { ...value.vote.body, videoId: "abcdefghijk" } } }),
],
["the wrong vote value", (value) => ({ ...value, vote: { body: { ...value.vote.body, value: 1 } } })],
[
"different vote and confirmation identities",
(value) => ({
...value,
confirmation: {
...value.confirmation,
body: { ...value.confirmation.body, userId: "B".repeat(36) },
},
}),
],
["a malformed identity", (value) => ({ ...value, sharedUserId: "short-user" })],
["a false confirmation", (value) => ({ ...value, confirmation: { ...value.confirmation, responseBody: false } })],
[
"a failed confirmation status",
(value) => ({ ...value, confirmation: { ...value.confirmation, responseStatus: 500 } }),
],
[
"a malformed proof solution",
(value) => ({
...value,
confirmation: { ...value.confirmation, body: { ...value.confirmation.body, solution: "AA==" } },
}),
],
["an extra vote field", (value) => ({ ...value, vote: { body: { ...value.vote.body, duplicate: true } } })],
])("rejects a post-SPA vote handshake with %s", (_label, mutate) => {
expect(isArtifactVoteHandshakeValid(mutate(validVoteHandshake()))).toBe(false);
});
test.each(["userscript", "extension"])(
"runs one post-SPA dislike activation and confirmation contract for %s",
async (runtime) => {
const events = [];
const adapter = {
activateSpaDislike: jest.fn(async (videoId) => ({
ariaPressedBefore: "false",
interactionStartIndex: 7,
videoId,
})),
assertNoPageSignals: jest.fn(async (scenarioId) => events.push(`signals:${scenarioId}`)),
assertSpaVoteNetwork: jest.fn(async () => ({ fromVideoRequests: 1, toVideoRequests: 1 })),
close: jest.fn(async () => events.push("close")),
navigateSpaWatch: jest.fn(async () => ({
destination: { destinationReplaced: true },
outgoing: { beforeBarCount: 1 },
})),
openSpaWatch: jest.fn(async (videoId) => events.push(`open:${videoId}`)),
readSpaVoteHandshake: jest.fn(async () => validVoteHandshake()),
readSpaWatchSnapshot: jest.fn(async () => validSpaSnapshot()),
runtime,
start: jest.fn(async () => events.push("start")),
waitForWatchResult: jest.fn(async (videoId) => ({ count: "10", fillRatio: 0.9, videoId })),
};
await expect(
runArtifactWatchSpaVoteScenario(adapter, {
handshakeStableForMs: 0,
handshakeTimeoutMs: 1,
intervalMs: 1,
stabilityDurationMs: 0,
stableForMs: 0,
timeoutMs: 1,
}),
).resolves.toMatchObject({
activation: { ariaPressedBefore: "false", videoId: "zyxwvutsrqp" },
destination: { count: "65", fillRatio: 0.35, videoId: "zyxwvutsrqp" },
handshake: {
confirmationRequests: 1,
confirmationStatus: 200,
confirmed: true,
interactionRequests: 2,
userId: ARTIFACT_USER_ID,
value: -1,
videoId: "zyxwvutsrqp",
voteRequests: 1,
},
runtime,
scenarioId: "watch-spa-dislike-activation",
traffic: { confirmationRequests: 1, interactionRequests: 2, voteRequests: 1 },
});
expect(adapter.activateSpaDislike).toHaveBeenCalledTimes(1);
expect(adapter.activateSpaDislike).toHaveBeenCalledWith("zyxwvutsrqp");
expect(adapter.readSpaVoteHandshake).toHaveBeenCalledWith(7, "zyxwvutsrqp", -1);
expect(adapter.assertSpaVoteNetwork).toHaveBeenCalledWith("abcdefghijk", "zyxwvutsrqp", 7);
expect(events).toEqual(["start", "open:abcdefghijk", "signals:watch-spa-dislike-activation", "close"]);
},
);
test("rejects a duplicated post-SPA vote chain and still closes the adapter", async () => {
const duplicatedHandshake = validVoteHandshake({
confirmationCount: 2,
interactionCount: 4,
interactionPaths: ["/interact/vote", "/interact/confirmVote", "/interact/vote", "/interact/confirmVote"],
voteCount: 2,
});
const adapter = {
activateSpaDislike: jest.fn(async (videoId) => ({ interactionStartIndex: 0, videoId })),
assertNoPageSignals: jest.fn(),
assertSpaVoteNetwork: jest.fn(),
close: jest.fn(),
navigateSpaWatch: jest.fn(async () => ({
destination: { destinationReplaced: true },
outgoing: { beforeBarCount: 1 },
})),
openSpaWatch: jest.fn(),
readSpaVoteHandshake: jest.fn(async () => duplicatedHandshake),
readSpaWatchSnapshot: jest.fn(async () => validSpaSnapshot()),
runtime: "userscript",
start: jest.fn(),
waitForWatchResult: jest.fn(async (videoId) => ({ count: "10", fillRatio: 0.9, videoId })),
};
await expect(
runArtifactWatchSpaVoteScenario(adapter, {
handshakeStableForMs: 0,
handshakeTimeoutMs: 1,
intervalMs: 1,
stabilityDurationMs: 0,
stableForMs: 0,
timeoutMs: 1,
}),
).rejects.toThrow("post-SPA dislike handshake did not remain valid");
expect(adapter.assertSpaVoteNetwork).not.toHaveBeenCalled();
expect(adapter.assertNoPageSignals).not.toHaveBeenCalled();
expect(adapter.close).toHaveBeenCalledTimes(1);
});
test("rejects a destination that becomes correct after the explicit latency budget", async () => {
const adapter = {
assertNoPageSignals: jest.fn(),
assertSpaNetwork: jest.fn(),
close: jest.fn(),
navigateSpaWatch: jest.fn(async () => ({
destination: { destinationReplaced: true },
outgoing: { beforeBarCount: 1 },
})),
openSpaWatch: jest.fn(),
readSpaWatchSnapshot: jest.fn(async () => {
await new Promise((resolve) => setTimeout(resolve, 5));
return validSpaSnapshot();
}),
runtime: "userscript",
start: jest.fn(),
waitForWatchResult: jest.fn(async (videoId) => ({ count: "10", fillRatio: 0.9, videoId })),
};
await expect(
runArtifactWatchSpaScenario(adapter, {
intervalMs: 1,
maxFirstValidMs: 1,
stabilityDurationMs: 0,
stableForMs: 0,
timeoutMs: 20,
}),
).rejects.toThrow(/first became valid after .*the budget is 1ms/);
expect(adapter.close).toHaveBeenCalledTimes(1);
});
test.each([
["duplicate global bar", { globalBarCount: 2 }],
["stale outgoing bar", { insideOutgoing: { ...validSpaSnapshot().insideOutgoing, barCount: 1 } }],
["wrong destination ratio", { fillRatio: 0.9 }],
["wrong destination count", { count: "10" }],
])("rejects a settled SPA snapshot with %s", (_label, change) => {
expect(
isSpaDestinationValid(
{ ...validSpaSnapshot(), ...change },
{
expectedCount: 65,
expectedRatio: 0.35,
fromVideoId: "abcdefghijk",
toVideoId: "zyxwvutsrqp",
},
),
).toBe(false);
});
test("always closes an artifact adapter after a failed visual assertion", async () => {
const adapter = {
assertNoPageSignals: jest.fn(),
close: jest.fn(),
openWatch: jest.fn(),
runtime: "extension",
start: jest.fn(),
waitForWatchResult: jest.fn(async () => ({
count: "25",
fillVisible: true,
rateBarVisible: false,
videoId: "abcdefghijk",
})),
};
await expect(runArtifactWatchRenderScenario(adapter, { videoId: "abcdefghijk" })).rejects.toThrow(
"extension did not render a visible watch ratio bar",
);
expect(adapter.close).toHaveBeenCalledTimes(1);
});
test("turns an otherwise successful artifact result into a failure when the page emitted an error", async () => {
const adapter = {
assertNoPageSignals: jest.fn(async () => {
throw new Error("unexpected browser signals");
}),
close: jest.fn(),
openWatch: jest.fn(),
runtime: "userscript",
start: jest.fn(),
waitForWatchResult: jest.fn(async (videoId) => ({
count: "25",
fillVisible: true,
rateBarVisible: true,
videoId,
})),
};
await expect(runArtifactWatchRenderScenario(adapter, { videoId: "abcdefghijk" })).rejects.toThrow(
"unexpected browser signals",
);
expect(adapter.close).toHaveBeenCalledTimes(1);
});
function createPageDouble() {
const page = new EventEmitter();
page.addInitScript = jest.fn(async () => {});
page.evaluate = jest.fn(async (callback) => callback());
page.exposeBinding = jest.fn(async (name, callback) => {
page.exposedBinding = { callback, name };
});
return page;
}
function consoleMessage(
type,
text,
location = { columnNumber: 5, lineNumber: 4, url: "https://www.youtube.com/watch" },
) {
return {
location: () => location,
text: () => text,
type: () => type,
};
}
test.each(["userscript", "extension"])(
"collects clean page signals through one shared %s collector",
async (runtime) => {
const page = createPageDouble();
const collector = await createPageSignalCollector(page, runtime);
page.emit("console", consoleMessage("warning", "harmless warning"));
await expect(collector.assertClean("watch-render")).resolves.toEqual({
consoleErrors: [],
pageErrors: [],
runtime,
unhandledRejections: [],
});
expect(page.exposeBinding).toHaveBeenCalledWith("__rydArtifactReportUnhandledRejection", expect.any(Function));
expect(page.addInitScript).toHaveBeenCalledTimes(1);
},
);
test.each([
["console error", (page) => page.emit("console", consoleMessage("error", "fixture exploded")), "fixture exploded"],
[
"failed browser resource load",
(page) => page.emit("console", consoleMessage("error", "Failed to load resource: net::ERR_FILE_NOT_FOUND")),
"ERR_FILE_NOT_FOUND",
],
[
"failed console assertion",
(page) => page.emit("console", consoleMessage("assert", "bad assertion")),
"bad assertion",
],
["page error", (page) => page.emit("pageerror", new TypeError("page exploded")), "page exploded"],
[
"unhandled rejection",
(page) =>
page.exposedBinding.callback(
{ frame: { url: () => "https://www.youtube.com/watch?v=abcdefghijk" } },
{ message: "promise exploded", name: "Error", stack: "Error: promise exploded" },
),
"promise exploded",
],
])("fails a successful scenario on %s with diagnostics", async (_label, emitSignal, expectedDiagnostic) => {
const page = createPageDouble();
const collector = await createPageSignalCollector(page, "userscript");
emitSignal(page);
await expect(collector.assertClean("watch-render")).rejects.toThrow(
new RegExp(`userscript emitted unexpected browser signals.*${expectedDiagnostic}`, "s"),
);
});