mirror of
https://github.com/Alvin-Zilverstand/return-youtube-dislike.git
synced 2026-10-28 07:40:27 +01:00
Prepare 4.0.5 Firefox update and Shorts fixes
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.
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
const { assertCurrentLiveArtifact } = require("../e2e/live/live-artifact-readiness");
|
||||
|
||||
const BUILD_ID = "0123456789abcdef0123456789abcdef";
|
||||
|
||||
function verifiers() {
|
||||
return {
|
||||
verifyExtensionBuildReceipt: jest.fn(() => ({ inputHash: "extension-inputs" })),
|
||||
verifyExtensionJavaScript: jest.fn(() => ({ version: "4.0.5" })),
|
||||
verifyUserscript: jest.fn(() => ({ inputHash: "userscript-inputs" })),
|
||||
};
|
||||
}
|
||||
|
||||
test("an extension live run verifies current source inputs and the generated browser bundles", () => {
|
||||
const checks = verifiers();
|
||||
|
||||
expect(assertCurrentLiveArtifact("extension", BUILD_ID, checks)).toEqual({
|
||||
artifact: { version: "4.0.5" },
|
||||
buildId: BUILD_ID,
|
||||
receipt: { inputHash: "extension-inputs" },
|
||||
runtime: "extension",
|
||||
});
|
||||
expect(checks.verifyExtensionBuildReceipt).toHaveBeenCalledTimes(1);
|
||||
expect(checks.verifyExtensionJavaScript).toHaveBeenCalledTimes(1);
|
||||
expect(checks.verifyUserscript).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("a userscript live run verifies the exact live receipt and build ID", () => {
|
||||
const checks = verifiers();
|
||||
|
||||
expect(assertCurrentLiveArtifact("userscript", BUILD_ID, checks)).toEqual({
|
||||
buildId: BUILD_ID,
|
||||
receipt: { inputHash: "userscript-inputs" },
|
||||
runtime: "userscript",
|
||||
});
|
||||
expect(checks.verifyUserscript).toHaveBeenCalledWith({ expectedBuildId: BUILD_ID, liveTestBuild: true });
|
||||
expect(checks.verifyExtensionBuildReceipt).not.toHaveBeenCalled();
|
||||
expect(checks.verifyExtensionJavaScript).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test.each([
|
||||
[
|
||||
"extension receipt",
|
||||
{
|
||||
verifyExtensionBuildReceipt: () => {
|
||||
throw new Error("stale extension source");
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
"extension bundles",
|
||||
{
|
||||
verifyExtensionJavaScript: () => {
|
||||
throw new Error("stale extension bundle");
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
"userscript receipt",
|
||||
{
|
||||
verifyUserscript: () => {
|
||||
throw new Error("stale userscript source");
|
||||
},
|
||||
},
|
||||
],
|
||||
])("propagates a failed %s check before browser attachment", (_label, override) => {
|
||||
const runtime = _label.startsWith("userscript") ? "userscript" : "extension";
|
||||
expect(() => assertCurrentLiveArtifact(runtime, BUILD_ID, { ...verifiers(), ...override })).toThrow(/stale/);
|
||||
});
|
||||
|
||||
test("rejects an unsupported live runtime without verifying another artifact", () => {
|
||||
const checks = verifiers();
|
||||
expect(() => assertCurrentLiveArtifact("browser", BUILD_ID, checks)).toThrow("Unsupported live artifact runtime");
|
||||
expect(checks.verifyExtensionBuildReceipt).not.toHaveBeenCalled();
|
||||
expect(checks.verifyExtensionJavaScript).not.toHaveBeenCalled();
|
||||
expect(checks.verifyUserscript).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -0,0 +1,390 @@
|
||||
/** @jest-environment jsdom */
|
||||
|
||||
const {
|
||||
classifyAccountProbeFailure,
|
||||
inspectVisibleAccountMenu,
|
||||
isCommittedYoutubePage,
|
||||
normalizeChannelHandle,
|
||||
selectAuthenticatedYoutubeContext,
|
||||
verifyCandidateYoutubePage,
|
||||
verifyAuthenticatedYoutubePage,
|
||||
} = require("../e2e/live/live-authenticated-context");
|
||||
|
||||
function page(url, { closed = false, context = null } = {}) {
|
||||
return {
|
||||
context: () => context,
|
||||
isClosed: jest.fn(() => closed),
|
||||
url: jest.fn(() => url),
|
||||
};
|
||||
}
|
||||
|
||||
function context(pages) {
|
||||
const value = { pages: jest.fn(() => pages) };
|
||||
for (const currentPage of pages) currentPage.context = () => value;
|
||||
return value;
|
||||
}
|
||||
|
||||
function makeHandle(value) {
|
||||
return {
|
||||
dispose: jest.fn().mockResolvedValue(undefined),
|
||||
jsonValue: jest.fn().mockResolvedValue(value),
|
||||
};
|
||||
}
|
||||
|
||||
describe("authenticated live Chromium context selection", () => {
|
||||
test.each([
|
||||
"https://youtube.com/",
|
||||
"https://www.youtube.com/watch?v=abcdefghijk",
|
||||
"https://m.youtube.com/shorts/abcdefghijk",
|
||||
])("accepts a committed ordinary YouTube page at %s", (url) => {
|
||||
expect(isCommittedYoutubePage(page(url))).toBe(true);
|
||||
});
|
||||
|
||||
test.each([
|
||||
"about:blank",
|
||||
"chrome://extensions/",
|
||||
"chrome-extension://abcdefghijklmnopabcdefghijklmnop/popup.html",
|
||||
"http://www.youtube.com/",
|
||||
"https://studio.youtube.com/",
|
||||
"https://www.youtube.com.example.test/",
|
||||
"https://user:password@www.youtube.com/",
|
||||
])("rejects an internal, non-YouTube, or unsafe page at %s", (url) => {
|
||||
expect(isCommittedYoutubePage(page(url))).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects closed and malformed pages", () => {
|
||||
expect(isCommittedYoutubePage(page("https://www.youtube.com/", { closed: true }))).toBe(false);
|
||||
expect(isCommittedYoutubePage(page("not a URL"))).toBe(false);
|
||||
expect(isCommittedYoutubePage(null)).toBe(false);
|
||||
});
|
||||
|
||||
test("normalizes public handles without accepting an account name or URL", () => {
|
||||
expect(normalizeChannelHandle(" @Anarios-RYD ")).toBe("@anarios-ryd");
|
||||
expect(() => normalizeChannelHandle("Anarios RYD")).toThrow(/exact public YouTube @handle/);
|
||||
expect(() => normalizeChannelHandle("https://youtube.com/@anarios-ryd")).toThrow(/exact public YouTube @handle/);
|
||||
});
|
||||
|
||||
test("scans each context until its profile is resolved and returns the existing matching page", async () => {
|
||||
const internal = page("chrome://extensions/");
|
||||
const unrelated = page("https://example.test/");
|
||||
const wrongYoutube = page("https://www.youtube.com/watch?v=wrongvideo1");
|
||||
const matchingYoutube = page("https://www.youtube.com/watch?v=rightvideo1");
|
||||
const laterYoutube = page("https://m.youtube.com/shorts/later-video");
|
||||
const firstContext = context([internal, wrongYoutube]);
|
||||
const secondContext = context([unrelated, matchingYoutube, laterYoutube]);
|
||||
const verifyPage = jest.fn(async (candidate) => candidate === matchingYoutube || candidate === laterYoutube);
|
||||
const browser = { contexts: jest.fn(() => [firstContext, secondContext]) };
|
||||
|
||||
await expect(selectAuthenticatedYoutubeContext(browser, "@Expected", { verifyPage })).resolves.toEqual({
|
||||
context: secondContext,
|
||||
contextIndex: 1,
|
||||
pageIndex: 1,
|
||||
sessionPage: matchingYoutube,
|
||||
});
|
||||
expect(firstContext.pages).toHaveBeenCalledTimes(1);
|
||||
expect(secondContext.pages).toHaveBeenCalledTimes(2);
|
||||
expect(verifyPage.mock.calls.map(([candidate]) => candidate)).toEqual([wrongYoutube, matchingYoutube]);
|
||||
expect(verifyPage).toHaveBeenCalledWith(expect.anything(), "@expected", {
|
||||
accountProbeTimeoutMilliseconds: 8000,
|
||||
});
|
||||
});
|
||||
|
||||
test("allows several matching YouTube pages only when they belong to the same context", async () => {
|
||||
const first = page("https://www.youtube.com/watch?v=abcdefghijk");
|
||||
const second = page("https://www.youtube.com/shorts/lmnopqrstuv");
|
||||
const matchingContext = context([first, second]);
|
||||
|
||||
await expect(
|
||||
selectAuthenticatedYoutubeContext({ contexts: () => [matchingContext] }, "@expected", {
|
||||
verifyPage: async () => true,
|
||||
}),
|
||||
).resolves.toMatchObject({ context: matchingContext, pageIndex: 0, sessionPage: first });
|
||||
});
|
||||
|
||||
test("refuses two matching authenticated contexts", async () => {
|
||||
const first = page("https://www.youtube.com/");
|
||||
const second = page("https://m.youtube.com/");
|
||||
|
||||
await expect(
|
||||
selectAuthenticatedYoutubeContext({ contexts: () => [context([first]), context([second])] }, "@expected", {
|
||||
verifyPage: async () => true,
|
||||
}),
|
||||
).rejects.toThrow(/More than one attached Chromium context/);
|
||||
});
|
||||
|
||||
test("refuses to select when another candidate context could not be verified", async () => {
|
||||
const matching = page("https://www.youtube.com/watch?v=abcdefghijk");
|
||||
const unresolved = page("https://www.youtube.com/watch?v=lmnopqrstuv");
|
||||
|
||||
await expect(
|
||||
selectAuthenticatedYoutubeContext({ contexts: () => [context([matching]), context([unresolved])] }, "@expected", {
|
||||
verifyPage: async (candidate) => {
|
||||
if (candidate === unresolved) throw new Error("page disappeared");
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow(/Could not safely verify every candidate Chromium profile/);
|
||||
});
|
||||
|
||||
test("retries a transient candidate-page failure before selecting its exact authenticated context", async () => {
|
||||
const matching = page("https://www.youtube.com/watch?v=abcdefghijk");
|
||||
const matchingContext = context([matching]);
|
||||
const waitForRetry = jest.fn().mockResolvedValue(undefined);
|
||||
const timeout = Object.assign(new Error("account menu did not hydrate"), { name: "TimeoutError" });
|
||||
const verifyPage = jest.fn().mockRejectedValueOnce(timeout).mockResolvedValueOnce(true);
|
||||
const contexts = jest.fn(() => [matchingContext]);
|
||||
|
||||
await expect(
|
||||
selectAuthenticatedYoutubeContext({ contexts }, "@expected", {
|
||||
accountSelectionRetryDelayMilliseconds: 10,
|
||||
verifyPage,
|
||||
waitForRetry,
|
||||
}),
|
||||
).resolves.toMatchObject({ context: matchingContext, pageIndex: 0, sessionPage: matching });
|
||||
expect(verifyPage).toHaveBeenCalledTimes(2);
|
||||
expect(waitForRetry).toHaveBeenCalledWith(10);
|
||||
expect(contexts).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test("re-enumerates pages and can select a matching YouTube page which commits before the second pass", async () => {
|
||||
const loading = page("about:blank");
|
||||
const matching = page("https://www.youtube.com/watch?v=abcdefghijk");
|
||||
const candidateContext = {
|
||||
pages: jest.fn().mockReturnValueOnce([loading]).mockReturnValue([matching]),
|
||||
};
|
||||
loading.context = matching.context = () => candidateContext;
|
||||
const contexts = jest.fn(() => [candidateContext]);
|
||||
const verifyPage = jest.fn().mockResolvedValue(true);
|
||||
|
||||
await expect(
|
||||
selectAuthenticatedYoutubeContext({ contexts }, "@expected", {
|
||||
verifyPage,
|
||||
waitForRetry: jest.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
).resolves.toMatchObject({ context: candidateContext, sessionPage: matching });
|
||||
expect(contexts).toHaveBeenCalledTimes(2);
|
||||
expect(verifyPage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("rescans instead of returning a selected page which closed after its account proof", async () => {
|
||||
const closedAfterProof = page("https://www.youtube.com/watch?v=abcdefghijk");
|
||||
const replacement = page("https://www.youtube.com/watch?v=lmnopqrstuv");
|
||||
const candidateContext = {
|
||||
pages: jest.fn().mockReturnValueOnce([closedAfterProof]).mockReturnValue([replacement]),
|
||||
};
|
||||
closedAfterProof.context = replacement.context = () => candidateContext;
|
||||
const verifyPage = jest.fn(async (candidate) => {
|
||||
if (candidate === closedAfterProof) closedAfterProof.isClosed.mockReturnValue(true);
|
||||
return true;
|
||||
});
|
||||
|
||||
await expect(
|
||||
selectAuthenticatedYoutubeContext({ contexts: () => [candidateContext] }, "@expected", {
|
||||
verifyPage,
|
||||
waitForRetry: jest.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
).resolves.toMatchObject({ context: candidateContext, sessionPage: replacement });
|
||||
expect(verifyPage.mock.calls.map(([candidate]) => candidate)).toEqual([closedAfterProof, replacement]);
|
||||
});
|
||||
|
||||
test("keeps a repeatedly failing committed candidate fail-closed and reports only a sanitized reason", async () => {
|
||||
const unresolved = page("https://www.youtube.com/watch?v=abcdefghijk");
|
||||
const privateFailure = Object.assign(new Error("private-page-detail@example.test"), { name: "TimeoutError" });
|
||||
|
||||
const selection = selectAuthenticatedYoutubeContext({ contexts: () => [context([unresolved])] }, "@expected", {
|
||||
verifyPage: jest.fn().mockRejectedValue(privateFailure),
|
||||
waitForRetry: jest.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
|
||||
await expect(selection).rejects.toThrow(
|
||||
"Could not safely verify every candidate Chromium profile for @expected; no extension was changed. Unresolved probes: context 1 page 1 (timeout).",
|
||||
);
|
||||
await expect(selection).rejects.not.toThrow(/private-page-detail/);
|
||||
});
|
||||
|
||||
test("ignores a candidate which leaves YouTube after a transient probe failure", async () => {
|
||||
const matching = page("https://www.youtube.com/watch?v=abcdefghijk");
|
||||
const stale = page("https://www.youtube.com/watch?v=lmnopqrstuv");
|
||||
const matchingContext = context([matching]);
|
||||
context([stale]);
|
||||
const verifyPage = jest.fn(async (candidate) => {
|
||||
if (candidate === matching) return true;
|
||||
stale.url.mockReturnValue("about:blank");
|
||||
throw new Error("Execution context was destroyed");
|
||||
});
|
||||
|
||||
await expect(
|
||||
selectAuthenticatedYoutubeContext({ contexts: () => [matchingContext, stale.context()] }, "@expected", {
|
||||
verifyPage,
|
||||
waitForRetry: jest.fn(),
|
||||
}),
|
||||
).resolves.toMatchObject({ context: matchingContext, sessionPage: matching });
|
||||
expect(verifyPage).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test.each([
|
||||
[Object.assign(new Error("menu timeout"), { name: "TimeoutError" }), "timeout"],
|
||||
[new Error("Target page has been closed"), "page-closed"],
|
||||
[new Error("Execution context was destroyed, most likely because of a navigation"), "navigation-race"],
|
||||
[new Error("unexpected failure"), "probe-error"],
|
||||
])("classifies account-probe failures without returning their message", (error, expectedKind) => {
|
||||
expect(classifyAccountProbeFailure(error)).toBe(expectedKind);
|
||||
});
|
||||
|
||||
test("resolves a candidate that closes during a failed probe as a non-match", async () => {
|
||||
const candidate = page("https://www.youtube.com/");
|
||||
const result = await verifyCandidateYoutubePage(
|
||||
candidate,
|
||||
"@expected",
|
||||
async () => {
|
||||
candidate.isClosed.mockReturnValue(true);
|
||||
throw new Error("Target page has been closed");
|
||||
},
|
||||
{ waitForRetry: jest.fn() },
|
||||
);
|
||||
|
||||
expect(result).toEqual({ matched: false, resolved: true });
|
||||
});
|
||||
|
||||
test("reports separately when there are no YouTube pages and when no account matches", async () => {
|
||||
await expect(
|
||||
selectAuthenticatedYoutubeContext(
|
||||
{ contexts: () => [context([page("chrome://extensions/"), page("https://example.test/")])] },
|
||||
"@expected",
|
||||
{ verifyPage: jest.fn() },
|
||||
),
|
||||
).rejects.toThrow(/No pre-existing committed HTTPS YouTube page/);
|
||||
|
||||
await expect(
|
||||
selectAuthenticatedYoutubeContext(
|
||||
{ contexts: () => [context([page("https://www.youtube.com/")])] },
|
||||
"@expected",
|
||||
{ verifyPage: async () => false },
|
||||
),
|
||||
).rejects.toThrow(/No attached Chromium context has a pre-existing signed-in YouTube page/);
|
||||
});
|
||||
|
||||
test("uses only scoped visible account-menu links and compares the exact handle", () => {
|
||||
document.body.innerHTML = `
|
||||
<a id="content-link" href="https://www.youtube.com/@expected">Unrelated video description link</a>
|
||||
<ytd-multi-page-menu-renderer>
|
||||
<a id="account-link" href="https://www.youtube.com/@expected/">View your channel</a>
|
||||
</ytd-multi-page-menu-renderer>
|
||||
`;
|
||||
for (const element of document.querySelectorAll("ytd-multi-page-menu-renderer, a")) {
|
||||
element.getBoundingClientRect = () => ({ height: 20, width: 100 });
|
||||
}
|
||||
|
||||
expect(inspectVisibleAccountMenu("@expected")).toEqual({ matchedExpectedHandle: true, ready: true });
|
||||
expect(inspectVisibleAccountMenu("@different")).toEqual({ matchedExpectedHandle: false, ready: true });
|
||||
document.querySelector("ytd-multi-page-menu-renderer").remove();
|
||||
expect(inspectVisibleAccountMenu("@expected")).toBeNull();
|
||||
});
|
||||
|
||||
test("can verify the active handle from the account header without returning account text", () => {
|
||||
document.body.innerHTML = `
|
||||
<ytd-multi-page-menu-renderer>
|
||||
<ytd-active-account-header-renderer><span>Private Name private@example.test</span><span id="channel-handle">@Expected</span></ytd-active-account-header-renderer>
|
||||
</ytd-multi-page-menu-renderer>
|
||||
`;
|
||||
for (const element of document.querySelectorAll(
|
||||
"ytd-multi-page-menu-renderer, ytd-active-account-header-renderer, span",
|
||||
)) {
|
||||
element.getBoundingClientRect = () => ({ height: 20, width: 100 });
|
||||
}
|
||||
|
||||
const result = inspectVisibleAccountMenu("@expected");
|
||||
expect(result).toEqual({ matchedExpectedHandle: true, ready: true });
|
||||
expect(JSON.stringify(result)).not.toContain("private@example.test");
|
||||
expect(JSON.stringify(result)).not.toContain("Private Name");
|
||||
});
|
||||
|
||||
test("verifies a signed-in page by opening and restoring the account menu", async () => {
|
||||
const menuStates = [null, { matchedExpectedHandle: true, ready: true }];
|
||||
const avatar = { click: jest.fn().mockResolvedValue(undefined), isVisible: jest.fn().mockResolvedValue(true) };
|
||||
const avatars = { count: jest.fn().mockResolvedValue(1), nth: jest.fn(() => avatar) };
|
||||
const browserPage = {
|
||||
bringToFront: jest.fn().mockResolvedValue(undefined),
|
||||
evaluate: jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ committed: true, configuredLoggedIn: true })
|
||||
.mockResolvedValueOnce(menuStates.shift()),
|
||||
isClosed: () => false,
|
||||
keyboard: { press: jest.fn().mockResolvedValue(undefined) },
|
||||
locator: jest.fn(() => avatars),
|
||||
url: () => "https://www.youtube.com/watch?v=abcdefghijk",
|
||||
waitForFunction: jest.fn().mockResolvedValue(makeHandle(menuStates.shift())),
|
||||
};
|
||||
|
||||
await expect(verifyAuthenticatedYoutubePage(browserPage, "@Expected")).resolves.toBe(true);
|
||||
expect(browserPage.bringToFront).toHaveBeenCalledTimes(1);
|
||||
expect(avatar.click).toHaveBeenCalledWith({ timeout: 8000 });
|
||||
expect(browserPage.keyboard.press).toHaveBeenCalledWith("Escape");
|
||||
expect(browserPage.waitForFunction).toHaveBeenCalledWith(inspectVisibleAccountMenu, "@expected", {
|
||||
polling: 100,
|
||||
timeout: 8000,
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects an explicitly signed-out page without opening the account menu", async () => {
|
||||
const browserPage = {
|
||||
evaluate: jest.fn().mockResolvedValue({ committed: true, configuredLoggedIn: false }),
|
||||
isClosed: () => false,
|
||||
locator: jest.fn(),
|
||||
url: () => "https://www.youtube.com/",
|
||||
};
|
||||
|
||||
await expect(verifyAuthenticatedYoutubePage(browserPage, "@expected")).resolves.toBe(false);
|
||||
expect(browserPage.locator).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("surfaces an account-menu timeout or broken page as an unresolved profile", async () => {
|
||||
const timeout = Object.assign(new Error("menu did not appear"), { name: "TimeoutError" });
|
||||
const createBrowserPage = (waitError) => {
|
||||
const avatar = { click: jest.fn().mockResolvedValue(undefined), isVisible: jest.fn().mockResolvedValue(true) };
|
||||
return {
|
||||
evaluate: jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ committed: true, configuredLoggedIn: true })
|
||||
.mockResolvedValueOnce(null),
|
||||
isClosed: () => false,
|
||||
keyboard: { press: jest.fn().mockResolvedValue(undefined) },
|
||||
locator: jest.fn(() => ({ count: async () => 1, nth: () => avatar })),
|
||||
url: () => "https://www.youtube.com/",
|
||||
waitForFunction: jest.fn().mockRejectedValue(waitError),
|
||||
};
|
||||
};
|
||||
|
||||
await expect(verifyAuthenticatedYoutubePage(createBrowserPage(timeout), "@expected")).rejects.toThrow(
|
||||
/menu did not appear/,
|
||||
);
|
||||
await expect(
|
||||
verifyAuthenticatedYoutubePage(createBrowserPage(new Error("Target page has been closed")), "@expected"),
|
||||
).rejects.toThrow(/Target page has been closed/);
|
||||
});
|
||||
|
||||
test("keeps a serialized account-menu match when its remote handle cannot be disposed during navigation", async () => {
|
||||
const menuHandle = makeHandle({ matchedExpectedHandle: true, ready: true });
|
||||
menuHandle.dispose.mockRejectedValue(new Error("Execution context was destroyed"));
|
||||
const avatar = { click: jest.fn().mockResolvedValue(undefined), isVisible: jest.fn().mockResolvedValue(true) };
|
||||
const browserPage = {
|
||||
evaluate: jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ committed: true, configuredLoggedIn: true })
|
||||
.mockResolvedValueOnce(null),
|
||||
isClosed: () => false,
|
||||
keyboard: { press: jest.fn().mockResolvedValue(undefined) },
|
||||
locator: jest.fn(() => ({ count: async () => 1, nth: () => avatar })),
|
||||
url: () => "https://www.youtube.com/",
|
||||
waitForFunction: jest.fn().mockResolvedValue(menuHandle),
|
||||
};
|
||||
|
||||
await expect(verifyAuthenticatedYoutubePage(browserPage, "@expected")).resolves.toBe(true);
|
||||
expect(menuHandle.dispose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("does not read or return cookies, storage, account names, or email addresses", () => {
|
||||
const probeSource = `${inspectVisibleAccountMenu}\n${verifyAuthenticatedYoutubePage}`;
|
||||
expect(probeSource).not.toMatch(/\.cookie|localStorage|sessionStorage|\.email|accountName/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,233 @@
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
|
||||
const ACTIVE_PORT_FILE_NAME = "DevToolsActivePort";
|
||||
const BROWSER_ALIASES = new Set(["brave", "chrome", "chromium", "edge"]);
|
||||
|
||||
function nonEmptyEnvironmentValue(environment, name) {
|
||||
const value = environment[name]?.trim();
|
||||
return value || null;
|
||||
}
|
||||
|
||||
function platformPath(platform, pathImpl) {
|
||||
if (pathImpl) return pathImpl;
|
||||
return platform === "win32" ? path.win32 : path.posix;
|
||||
}
|
||||
|
||||
function browserUserDataDirectories(browser, { environment, homedir, pathImpl, platform }) {
|
||||
const join = pathImpl.join.bind(pathImpl);
|
||||
const home = nonEmptyEnvironmentValue(environment, "HOME") || homedir();
|
||||
|
||||
if (platform === "win32") {
|
||||
const localAppData = nonEmptyEnvironmentValue(environment, "LOCALAPPDATA");
|
||||
if (!localAppData) return [];
|
||||
const directories = {
|
||||
brave: [join(localAppData, "BraveSoftware", "Brave-Browser", "User Data")],
|
||||
chrome: [
|
||||
join(localAppData, "Google", "Chrome", "User Data"),
|
||||
join(localAppData, "Google", "Chrome Beta", "User Data"),
|
||||
join(localAppData, "Google", "Chrome Dev", "User Data"),
|
||||
join(localAppData, "Google", "Chrome SxS", "User Data"),
|
||||
],
|
||||
chromium: [join(localAppData, "Chromium", "User Data")],
|
||||
edge: [join(localAppData, "Microsoft", "Edge", "User Data")],
|
||||
};
|
||||
return directories[browser] || [];
|
||||
}
|
||||
|
||||
if (platform === "darwin") {
|
||||
const applicationSupport = join(home, "Library", "Application Support");
|
||||
const directories = {
|
||||
brave: [join(applicationSupport, "BraveSoftware", "Brave-Browser")],
|
||||
chrome: [
|
||||
join(applicationSupport, "Google", "Chrome"),
|
||||
join(applicationSupport, "Google", "Chrome Beta"),
|
||||
join(applicationSupport, "Google", "Chrome Dev"),
|
||||
join(applicationSupport, "Google", "Chrome Canary"),
|
||||
],
|
||||
chromium: [join(applicationSupport, "Chromium")],
|
||||
edge: [join(applicationSupport, "Microsoft Edge")],
|
||||
};
|
||||
return directories[browser] || [];
|
||||
}
|
||||
|
||||
const configRoot = nonEmptyEnvironmentValue(environment, "XDG_CONFIG_HOME") || join(home, ".config");
|
||||
const directories = {
|
||||
brave: [join(configRoot, "BraveSoftware", "Brave-Browser")],
|
||||
chrome: [
|
||||
join(configRoot, "google-chrome"),
|
||||
join(configRoot, "google-chrome-beta"),
|
||||
join(configRoot, "google-chrome-unstable"),
|
||||
],
|
||||
chromium: [join(configRoot, "chromium")],
|
||||
edge: [join(configRoot, "microsoft-edge")],
|
||||
};
|
||||
return directories[browser] || [];
|
||||
}
|
||||
|
||||
function knownActivePortFiles(browser, settings) {
|
||||
const browsers = browser ? [browser] : ["chrome", "brave", "edge", "chromium"];
|
||||
return browsers.flatMap((name) =>
|
||||
browserUserDataDirectories(name, settings).map((directory) =>
|
||||
settings.pathImpl.join(directory, ACTIVE_PORT_FILE_NAME),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function parseBareHostAndPort(value) {
|
||||
let url;
|
||||
try {
|
||||
url = new URL(`http://${value}`);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!url.hostname || !url.port || url.username || url.password || url.pathname !== "/" || url.search || url.hash) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const port = Number(url.port);
|
||||
if (!Number.isSafeInteger(port) || port < 1 || port > 65535) return null;
|
||||
return { authority: url.host, port };
|
||||
}
|
||||
|
||||
function parseActivePortFile(contents, filePath) {
|
||||
const [rawPort, rawBrowserPath] = String(contents).split(/\r?\n/);
|
||||
const port = Number(rawPort?.trim());
|
||||
if (!/^\d+$/.test(rawPort?.trim() || "") || !Number.isSafeInteger(port) || port < 1 || port > 65535) {
|
||||
throw new Error(`The CDP active-port file at ${filePath} has an invalid port.`);
|
||||
}
|
||||
|
||||
let browserPath = rawBrowserPath?.trim();
|
||||
if (/^wss?:\/\//i.test(browserPath || "")) {
|
||||
let url;
|
||||
try {
|
||||
url = new URL(browserPath);
|
||||
} catch {
|
||||
throw new Error(`The CDP active-port file at ${filePath} has an invalid browser WebSocket URL.`);
|
||||
}
|
||||
browserPath = `${url.pathname}${url.search}`;
|
||||
}
|
||||
|
||||
if (!/^\/devtools\/browser\/[A-Za-z0-9._-]+$/.test(browserPath || "")) {
|
||||
throw new Error(`The CDP active-port file at ${filePath} has an invalid browser WebSocket path.`);
|
||||
}
|
||||
|
||||
return { browserPath, port };
|
||||
}
|
||||
|
||||
function unique(values, platform) {
|
||||
const keys = new Set();
|
||||
return values.filter((value) => {
|
||||
const key = platform === "win32" ? value.toLowerCase() : value;
|
||||
if (keys.has(key)) return false;
|
||||
keys.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function activePortFileCandidates(browser, settings) {
|
||||
const activePortFile = nonEmptyEnvironmentValue(settings.environment, "RYD_CDP_ACTIVE_PORT_FILE");
|
||||
const userDataDirectory = nonEmptyEnvironmentValue(settings.environment, "RYD_CDP_USER_DATA_DIR");
|
||||
if (activePortFile && userDataDirectory) {
|
||||
throw new Error("Set only one of RYD_CDP_ACTIVE_PORT_FILE and RYD_CDP_USER_DATA_DIR.");
|
||||
}
|
||||
if (activePortFile) return { explicit: true, paths: [settings.pathImpl.resolve(activePortFile)] };
|
||||
if (userDataDirectory) {
|
||||
return {
|
||||
explicit: true,
|
||||
paths: [settings.pathImpl.join(settings.pathImpl.resolve(userDataDirectory), ACTIVE_PORT_FILE_NAME)],
|
||||
};
|
||||
}
|
||||
return { explicit: false, paths: unique(knownActivePortFiles(browser, settings), settings.platform) };
|
||||
}
|
||||
|
||||
function readCandidate(filePath, settings, explicit) {
|
||||
try {
|
||||
return parseActivePortFile(settings.readFileSync(filePath, "utf8"), filePath);
|
||||
} catch (error) {
|
||||
if (!explicit && ["ENOENT", "ENOTDIR"].includes(error.code)) return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function protocolEndpoint(value) {
|
||||
if (!/^(?:https?|wss?):\/\//i.test(value)) return null;
|
||||
let url;
|
||||
try {
|
||||
url = new URL(value);
|
||||
} catch {
|
||||
throw new Error("RYD_CDP_ENDPOINT must be a valid HTTP(S), WebSocket, host:port, or browser-alias endpoint.");
|
||||
}
|
||||
if (!["http:", "https:", "ws:", "wss:"].includes(url.protocol) || !url.hostname) {
|
||||
throw new Error("RYD_CDP_ENDPOINT must be a valid HTTP(S), WebSocket, host:port, or browser-alias endpoint.");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function resolveCdpEndpoint(
|
||||
configuredEndpoint,
|
||||
{
|
||||
environment = process.env,
|
||||
homedir = os.homedir,
|
||||
pathImpl,
|
||||
platform = process.platform,
|
||||
readFileSync = fs.readFileSync,
|
||||
} = {},
|
||||
) {
|
||||
const value = configuredEndpoint?.trim() || "chrome";
|
||||
const explicitProtocolEndpoint = protocolEndpoint(value);
|
||||
if (explicitProtocolEndpoint) return explicitProtocolEndpoint;
|
||||
|
||||
const browser = BROWSER_ALIASES.has(value.toLowerCase()) ? value.toLowerCase() : null;
|
||||
const bareEndpoint = browser ? null : parseBareHostAndPort(value);
|
||||
if (!browser && !bareEndpoint) {
|
||||
throw new Error(
|
||||
"RYD_CDP_ENDPOINT must be an HTTP(S) URL, WebSocket URL, host:port shown by Chromium, or one of chrome, brave, edge, and chromium.",
|
||||
);
|
||||
}
|
||||
|
||||
const settings = {
|
||||
environment,
|
||||
homedir,
|
||||
pathImpl: platformPath(platform, pathImpl),
|
||||
platform,
|
||||
readFileSync,
|
||||
};
|
||||
const candidates = activePortFileCandidates(browser, settings);
|
||||
const matches = [];
|
||||
for (const filePath of candidates.paths) {
|
||||
const activePort = readCandidate(filePath, settings, candidates.explicit);
|
||||
if (!activePort || (bareEndpoint && activePort.port !== bareEndpoint.port)) continue;
|
||||
const authority = bareEndpoint?.authority || `127.0.0.1:${activePort.port}`;
|
||||
matches.push({ endpoint: `ws://${authority}${activePort.browserPath}`, filePath });
|
||||
}
|
||||
|
||||
const endpoints = [...new Set(matches.map(({ endpoint }) => endpoint))];
|
||||
if (endpoints.length === 1) return endpoints[0];
|
||||
if (endpoints.length > 1) {
|
||||
throw new Error(
|
||||
`More than one DevToolsActivePort file matches RYD_CDP_ENDPOINT=${value}. Set RYD_CDP_ACTIVE_PORT_FILE or RYD_CDP_USER_DATA_DIR to select the intended browser profile.`,
|
||||
);
|
||||
}
|
||||
|
||||
const fileDescription = candidates.explicit
|
||||
? candidates.paths[0]
|
||||
: candidates.paths.length
|
||||
? candidates.paths.join(", ")
|
||||
: "the platform's standard browser user-data directories";
|
||||
const portDescription = bareEndpoint ? ` matching port ${bareEndpoint.port}` : "";
|
||||
throw new Error(
|
||||
`Could not find a valid DevToolsActivePort file${portDescription} in ${fileDescription}. ` +
|
||||
"Set RYD_CDP_ACTIVE_PORT_FILE to that file, RYD_CDP_USER_DATA_DIR to its directory, or use an explicit ws:// or legacy http:// endpoint.",
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ACTIVE_PORT_FILE_NAME,
|
||||
browserUserDataDirectories,
|
||||
parseActivePortFile,
|
||||
parseBareHostAndPort,
|
||||
resolveCdpEndpoint,
|
||||
};
|
||||
@@ -0,0 +1,226 @@
|
||||
const path = require("node:path");
|
||||
const {
|
||||
browserUserDataDirectories,
|
||||
parseActivePortFile,
|
||||
parseBareHostAndPort,
|
||||
resolveCdpEndpoint,
|
||||
} = require("./live-cdp-endpoint");
|
||||
|
||||
function missingFile(filePath) {
|
||||
throw Object.assign(new Error(`missing ${filePath}`), { code: "ENOENT" });
|
||||
}
|
||||
|
||||
function mappedReader(files) {
|
||||
return jest.fn((filePath) => {
|
||||
if (Object.prototype.hasOwnProperty.call(files, filePath)) return files[filePath];
|
||||
return missingFile(filePath);
|
||||
});
|
||||
}
|
||||
|
||||
describe("live CDP endpoint resolver", () => {
|
||||
test.each([
|
||||
"ws://127.0.0.1:60011/devtools/browser/session-id",
|
||||
"wss://debug.example.test/devtools/browser/session-id",
|
||||
"http://127.0.0.1:9222",
|
||||
"https://debug.example.test:9222",
|
||||
])("preserves an explicit protocol endpoint without reading the filesystem: %s", (endpoint) => {
|
||||
const readFileSync = jest.fn();
|
||||
|
||||
expect(resolveCdpEndpoint(endpoint, { readFileSync })).toBe(endpoint);
|
||||
expect(readFileSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("resolves Chrome's host:port-only UI value through the matching Windows active-port file", () => {
|
||||
const chromeFile = "C:\\Users\\tester\\AppData\\Local\\Google\\Chrome\\User Data\\DevToolsActivePort";
|
||||
const braveFile = "C:\\Users\\tester\\AppData\\Local\\BraveSoftware\\Brave-Browser\\User Data\\DevToolsActivePort";
|
||||
const readFileSync = mappedReader({
|
||||
[braveFile]: "50000\n/devtools/browser/brave-session\n",
|
||||
[chromeFile]: "60011\r\n/devtools/browser/chrome-session\r\n",
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveCdpEndpoint("127.0.0.1:60011", {
|
||||
environment: { LOCALAPPDATA: "C:\\Users\\tester\\AppData\\Local" },
|
||||
homedir: () => "C:\\Users\\tester",
|
||||
pathImpl: path.win32,
|
||||
platform: "win32",
|
||||
readFileSync,
|
||||
}),
|
||||
).toBe("ws://127.0.0.1:60011/devtools/browser/chrome-session");
|
||||
expect(readFileSync).toHaveBeenCalledWith(chromeFile, "utf8");
|
||||
});
|
||||
|
||||
test("uses the configured bare hostname while taking the browser path from the file", () => {
|
||||
const activePortFile = "C:\\portable\\profile\\DevToolsActivePort";
|
||||
|
||||
expect(
|
||||
resolveCdpEndpoint("localhost:60011", {
|
||||
environment: { RYD_CDP_USER_DATA_DIR: "C:\\portable\\profile" },
|
||||
pathImpl: path.win32,
|
||||
platform: "win32",
|
||||
readFileSync: mappedReader({ [activePortFile]: "60011\n/devtools/browser/portable-session\n" }),
|
||||
}),
|
||||
).toBe("ws://localhost:60011/devtools/browser/portable-session");
|
||||
});
|
||||
|
||||
test("supports a bracketed IPv6 loopback host", () => {
|
||||
expect(
|
||||
resolveCdpEndpoint("[::1]:60011", {
|
||||
environment: { RYD_CDP_ACTIVE_PORT_FILE: "/profile/DevToolsActivePort" },
|
||||
pathImpl: path.posix,
|
||||
platform: "linux",
|
||||
readFileSync: mappedReader({
|
||||
"/profile/DevToolsActivePort": "60011\n/devtools/browser/ipv6-session\n",
|
||||
}),
|
||||
}),
|
||||
).toBe("ws://[::1]:60011/devtools/browser/ipv6-session");
|
||||
});
|
||||
|
||||
test.each([
|
||||
[
|
||||
"win32",
|
||||
path.win32,
|
||||
{ LOCALAPPDATA: "C:\\Users\\tester\\AppData\\Local" },
|
||||
() => "C:\\Users\\tester",
|
||||
"chrome",
|
||||
"C:\\Users\\tester\\AppData\\Local\\Google\\Chrome\\User Data",
|
||||
],
|
||||
[
|
||||
"darwin",
|
||||
path.posix,
|
||||
{ HOME: "/Users/tester" },
|
||||
() => "/fallback",
|
||||
"brave",
|
||||
"/Users/tester/Library/Application Support/BraveSoftware/Brave-Browser",
|
||||
],
|
||||
[
|
||||
"linux",
|
||||
path.posix,
|
||||
{ HOME: "/home/tester", XDG_CONFIG_HOME: "/custom/config" },
|
||||
() => "/fallback",
|
||||
"edge",
|
||||
"/custom/config/microsoft-edge",
|
||||
],
|
||||
])("discovers standard %s browser user-data roots", (platform, pathImpl, environment, homedir, browser, expected) => {
|
||||
expect(browserUserDataDirectories(browser, { environment, homedir, pathImpl, platform })).toContain(expected);
|
||||
});
|
||||
|
||||
test.each(["chrome", "CHROME", "brave", "edge", "chromium", undefined])(
|
||||
"resolves browser alias %p from its active-port file",
|
||||
(alias) => {
|
||||
const activePortFile = "/custom/profile/DevToolsActivePort";
|
||||
expect(
|
||||
resolveCdpEndpoint(alias, {
|
||||
environment: { RYD_CDP_ACTIVE_PORT_FILE: activePortFile },
|
||||
pathImpl: path.posix,
|
||||
platform: "linux",
|
||||
readFileSync: mappedReader({
|
||||
[activePortFile]: "60123\n/devtools/browser/alias-session\n",
|
||||
}),
|
||||
}),
|
||||
).toBe("ws://127.0.0.1:60123/devtools/browser/alias-session");
|
||||
},
|
||||
);
|
||||
|
||||
test("accepts an absolute WebSocket URL in an active-port file but uses the configured authority", () => {
|
||||
expect(
|
||||
resolveCdpEndpoint("localhost:60011", {
|
||||
environment: { RYD_CDP_ACTIVE_PORT_FILE: "/profile/DevToolsActivePort" },
|
||||
pathImpl: path.posix,
|
||||
platform: "linux",
|
||||
readFileSync: mappedReader({
|
||||
"/profile/DevToolsActivePort": "60011\nws://127.0.0.1:60011/devtools/browser/full-url-session\n",
|
||||
}),
|
||||
}),
|
||||
).toBe("ws://localhost:60011/devtools/browser/full-url-session");
|
||||
});
|
||||
|
||||
test("uses an exact active-port-file override", () => {
|
||||
const readFileSync = mappedReader({
|
||||
"/custom/ChromeActivePort": "61234\n/devtools/browser/custom-session\n",
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveCdpEndpoint("chrome", {
|
||||
environment: { RYD_CDP_ACTIVE_PORT_FILE: "/custom/ChromeActivePort" },
|
||||
pathImpl: path.posix,
|
||||
platform: "linux",
|
||||
readFileSync,
|
||||
}),
|
||||
).toBe("ws://127.0.0.1:61234/devtools/browser/custom-session");
|
||||
expect(readFileSync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("rejects a port mismatch instead of attaching to a different browser", () => {
|
||||
expect(() =>
|
||||
resolveCdpEndpoint("127.0.0.1:60011", {
|
||||
environment: { RYD_CDP_ACTIVE_PORT_FILE: "/profile/DevToolsActivePort" },
|
||||
pathImpl: path.posix,
|
||||
platform: "linux",
|
||||
readFileSync: mappedReader({
|
||||
"/profile/DevToolsActivePort": "60012\n/devtools/browser/wrong-browser\n",
|
||||
}),
|
||||
}),
|
||||
).toThrow(/matching port 60011/);
|
||||
});
|
||||
|
||||
test("rejects ambiguous standard profiles with the same port and different browser paths", () => {
|
||||
const localAppData = "C:\\Users\\tester\\AppData\\Local";
|
||||
const chromeFile = `${localAppData}\\Google\\Chrome\\User Data\\DevToolsActivePort`;
|
||||
const braveFile = `${localAppData}\\BraveSoftware\\Brave-Browser\\User Data\\DevToolsActivePort`;
|
||||
|
||||
expect(() =>
|
||||
resolveCdpEndpoint("127.0.0.1:60011", {
|
||||
environment: { LOCALAPPDATA: localAppData },
|
||||
pathImpl: path.win32,
|
||||
platform: "win32",
|
||||
readFileSync: mappedReader({
|
||||
[chromeFile]: "60011\n/devtools/browser/chrome-session\n",
|
||||
[braveFile]: "60011\n/devtools/browser/brave-session\n",
|
||||
}),
|
||||
}),
|
||||
).toThrow(/More than one DevToolsActivePort file matches/);
|
||||
});
|
||||
|
||||
test("rejects conflicting file and user-data-directory overrides", () => {
|
||||
expect(() =>
|
||||
resolveCdpEndpoint("chrome", {
|
||||
environment: {
|
||||
RYD_CDP_ACTIVE_PORT_FILE: "/profile/DevToolsActivePort",
|
||||
RYD_CDP_USER_DATA_DIR: "/profile",
|
||||
},
|
||||
pathImpl: path.posix,
|
||||
platform: "linux",
|
||||
}),
|
||||
).toThrow(/Set only one/);
|
||||
});
|
||||
|
||||
test.each([
|
||||
["not-a-port\n/devtools/browser/session\n", /invalid port/],
|
||||
["70000\n/devtools/browser/session\n", /invalid port/],
|
||||
["60011\n/devtools/page/not-a-browser\n", /invalid browser WebSocket path/],
|
||||
["60011\n", /invalid browser WebSocket path/],
|
||||
["60011\nws://bad host/devtools/browser/session\n", /invalid browser WebSocket URL/],
|
||||
])("rejects a malformed active-port file", (contents, expectedError) => {
|
||||
expect(() => parseActivePortFile(contents, "DevToolsActivePort")).toThrow(expectedError);
|
||||
});
|
||||
|
||||
test.each([
|
||||
["127.0.0.1:60011", { authority: "127.0.0.1:60011", port: 60011 }],
|
||||
["localhost:9222", { authority: "localhost:9222", port: 9222 }],
|
||||
["[::1]:1234", { authority: "[::1]:1234", port: 1234 }],
|
||||
["localhost", null],
|
||||
["localhost:0", null],
|
||||
["localhost:70000", null],
|
||||
["localhost:9222/json/version", null],
|
||||
])("parses bare host/port endpoint %p", (endpoint, expected) => {
|
||||
expect(parseBareHostAndPort(endpoint)).toEqual(expected);
|
||||
});
|
||||
|
||||
test.each(["not-an-endpoint", "ftp://127.0.0.1:9222", "127.0.0.1", "127.0.0.1:70000/path"])(
|
||||
"rejects unsupported endpoint %p",
|
||||
(endpoint) => {
|
||||
expect(() => resolveCdpEndpoint(endpoint)).toThrow(/RYD_CDP_ENDPOINT/);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -4,10 +4,14 @@
|
||||
|
||||
const path = require("node:path");
|
||||
const {
|
||||
LiveFatalSignalGuard,
|
||||
LiveReadOnlyGate,
|
||||
LiveRunDiagnostics,
|
||||
UNHANDLED_REJECTION_PREFIX,
|
||||
diagnosticApiUrl,
|
||||
environmentalConsoleWarningId,
|
||||
readLivePageState,
|
||||
runIndependentLoggedStages,
|
||||
runLoggedStage,
|
||||
} = require("../e2e/live/live-diagnostics");
|
||||
|
||||
@@ -34,6 +38,29 @@ function visibleRect() {
|
||||
return { bottom: 100, height: 90, left: 10, right: 60, top: 10, width: 50 };
|
||||
}
|
||||
|
||||
function consoleError(text, url = "https://www.youtube.com/watch?v=abcdefghijk") {
|
||||
return {
|
||||
location: () => ({ lineNumber: 7, url }),
|
||||
text: () => text,
|
||||
type: () => "error",
|
||||
};
|
||||
}
|
||||
|
||||
function createLiveHarness(diagnosticsOptions = {}) {
|
||||
const context = new FakeEmitter();
|
||||
context.serviceWorkers = jest.fn(() => []);
|
||||
const page = new FakeEmitter();
|
||||
page.addInitScript = jest.fn().mockResolvedValue(undefined);
|
||||
page.evaluate = jest.fn().mockResolvedValue(undefined);
|
||||
page.isClosed = jest.fn(() => false);
|
||||
page.url = jest.fn(() => "https://www.youtube.com/watch?v=abcdefghijk");
|
||||
return {
|
||||
context,
|
||||
diagnostics: new LiveRunDiagnostics(page, context, { log: jest.fn(), ...diagnosticsOptions }),
|
||||
page,
|
||||
};
|
||||
}
|
||||
|
||||
describe("live interactive diagnostics", () => {
|
||||
test("captures runtime and Shorts ownership state without dumping page HTML", () => {
|
||||
document.documentElement.setAttribute("data-ryd-userscript-version", "3.2.0");
|
||||
@@ -159,6 +186,417 @@ describe("live interactive diagnostics", () => {
|
||||
expect(diagnostics.stageFailed).toHaveBeenCalledWith("failure", expect.any(Number), failure);
|
||||
});
|
||||
|
||||
test.each([
|
||||
["pageerror", (page) => page.emit("pageerror", new Error("page exploded"))],
|
||||
[
|
||||
"unhandledrejection",
|
||||
(page) => page.emit("console", consoleError(`${UNHANDLED_REJECTION_PREFIX}promise exploded`)),
|
||||
],
|
||||
["console.error", (page) => page.emit("console", consoleError("console exploded"))],
|
||||
])("turns an otherwise-passing stage red for a %s", async (type, emitSignal) => {
|
||||
const { diagnostics, page } = createLiveHarness();
|
||||
await diagnostics.start();
|
||||
|
||||
await expect(
|
||||
runLoggedStage(diagnostics, "read-only.watch", async () => {
|
||||
emitSignal(page);
|
||||
return "rendered";
|
||||
}),
|
||||
).rejects.toMatchObject({ name: "LiveFatalSignalError" });
|
||||
|
||||
expect(diagnostics.browserSignals).toEqual([
|
||||
expect.objectContaining({ ignoredBy: null, stage: "read-only.watch", type }),
|
||||
]);
|
||||
diagnostics.stop();
|
||||
});
|
||||
|
||||
test.each([
|
||||
[
|
||||
"googlevideo HTTP 403",
|
||||
"Failed to load resource: the server responded with a status of 403 ()",
|
||||
"https://rr2---sn-q4flrnel.googlevideo.com/videoplayback?opaque=1",
|
||||
"googlevideo-resource-failure",
|
||||
],
|
||||
[
|
||||
"googlevideo DNS failure",
|
||||
"Failed to load resource: net::ERR_NAME_NOT_RESOLVED",
|
||||
"https://rr3---sn-oxup5-f5fk.googlevideo.com/generate_204",
|
||||
"googlevideo-resource-failure",
|
||||
],
|
||||
[
|
||||
"doubleclick follow-on network failure",
|
||||
"Failed to load resource: net::ERR_FAILED",
|
||||
"https://googleads.g.doubleclick.net/pagead/viewthroughconversion/123/",
|
||||
"doubleclick-resource-failure",
|
||||
],
|
||||
[
|
||||
"doubleclick CORS failure",
|
||||
"Access to XMLHttpRequest at 'https://googleads.g.doubleclick.net/pagead/id' from origin 'https://www.youtube.com' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.",
|
||||
"https://www.youtube.com/watch?v=abcdefghijk",
|
||||
"doubleclick-cors",
|
||||
],
|
||||
[
|
||||
"doubleclick CORS failure after a YouTube ad redirect",
|
||||
"Access to fetch at 'https://googleads.g.doubleclick.net/pagead/viewthroughconversion/962985656/?backend=innertube&cname=1' (redirected from 'https://www.youtube.com/pagead/viewthroughconversion/962985656/?backend=innertube&cname=1') from origin 'https://www.youtube.com' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.",
|
||||
"https://www.youtube.com/watch?v=abcdefghijk&list=RDabcdefghijk",
|
||||
"doubleclick-cors",
|
||||
],
|
||||
[
|
||||
"sandboxed about:blank frame script block",
|
||||
"Blocked script execution in 'about:blank' because the document's frame is sandboxed and the 'allow-scripts' permission is not set.",
|
||||
"about:blank",
|
||||
"sandboxed-about-blank-script",
|
||||
],
|
||||
[
|
||||
"YouTube account-cookie rotation rate limit",
|
||||
"Failed to load resource: the server responded with a status of 429 ()",
|
||||
"https://accounts.youtube.com/RotateCookies",
|
||||
"youtube-rotate-cookies-rate-limit",
|
||||
],
|
||||
[
|
||||
"YouTube accounts report-only frame-ancestors violation",
|
||||
"Framing 'https://accounts.youtube.com/' violates the following report-only Content Security Policy directive: \"frame-ancestors 'self'\". The violation has been logged, but no further action has been taken.\n",
|
||||
"",
|
||||
"youtube-accounts-report-only-frame-ancestors",
|
||||
],
|
||||
])("records a narrowly recognized external %s as a non-fatal warning", async (_name, message, url, warningId) => {
|
||||
const { diagnostics, page } = createLiveHarness();
|
||||
await diagnostics.start();
|
||||
|
||||
await expect(
|
||||
runLoggedStage(diagnostics, "read-only.watch", async () => {
|
||||
page.emit("console", consoleError(message, url));
|
||||
return "rendered";
|
||||
}),
|
||||
).resolves.toBe("rendered");
|
||||
|
||||
expect(diagnostics.browserSignals).toEqual([
|
||||
expect.objectContaining({
|
||||
environmentalWarning: warningId,
|
||||
ignoredBy: null,
|
||||
severity: "warning",
|
||||
stage: "read-only.watch",
|
||||
type: "console.error",
|
||||
}),
|
||||
]);
|
||||
diagnostics.stop();
|
||||
});
|
||||
|
||||
test.each([
|
||||
[
|
||||
"unknown status on a media CDN",
|
||||
"Failed to load resource: the server responded with a status of 404 ()",
|
||||
"https://rr2---sn-q4flrnel.googlevideo.com/videoplayback",
|
||||
],
|
||||
[
|
||||
"page JavaScript failure reported at a media URL",
|
||||
"TypeError: Cannot read properties of undefined",
|
||||
"https://rr2---sn-q4flrnel.googlevideo.com/videoplayback",
|
||||
],
|
||||
[
|
||||
"known network wording on YouTube itself",
|
||||
"Failed to load resource: net::ERR_NAME_NOT_RESOLVED",
|
||||
"https://www.youtube.com/youtubei/v1/player",
|
||||
],
|
||||
[
|
||||
"production RYD API failure",
|
||||
"Failed to load resource: net::ERR_FAILED",
|
||||
"https://returnyoutubedislikeapi.com/votes?videoId=abcdefghijk",
|
||||
],
|
||||
[
|
||||
"lookalike media hostname",
|
||||
"Failed to load resource: the server responded with a status of 403 ()",
|
||||
"https://googlevideo.com.attacker.test/videoplayback",
|
||||
],
|
||||
[
|
||||
"insecure media URL",
|
||||
"Failed to load resource: net::ERR_NAME_NOT_RESOLVED",
|
||||
"http://rr2---sn-q4flrnel.googlevideo.com/videoplayback",
|
||||
],
|
||||
[
|
||||
"nonstandard media port",
|
||||
"Failed to load resource: net::ERR_NAME_NOT_RESOLVED",
|
||||
"https://rr2---sn-q4flrnel.googlevideo.com:444/videoplayback",
|
||||
],
|
||||
[
|
||||
"different YouTube account endpoint",
|
||||
"Failed to load resource: the server responded with a status of 429 ()",
|
||||
"https://accounts.youtube.com/RotateCookiesAgain",
|
||||
],
|
||||
[
|
||||
"different status from YouTube cookie rotation",
|
||||
"Failed to load resource: the server responded with a status of 500 ()",
|
||||
"https://accounts.youtube.com/RotateCookies",
|
||||
],
|
||||
[
|
||||
"lookalike YouTube accounts hostname",
|
||||
"Failed to load resource: the server responded with a status of 429 ()",
|
||||
"https://accounts.youtube.com.attacker.test/RotateCookies",
|
||||
],
|
||||
[
|
||||
"enforced YouTube accounts frame-ancestors violation",
|
||||
"Framing 'https://accounts.youtube.com/' violates the following Content Security Policy directive: \"frame-ancestors 'self'\".",
|
||||
"",
|
||||
],
|
||||
[
|
||||
"report-only YouTube accounts frame-ancestors near miss",
|
||||
"Framing 'https://accounts.youtube.com/' violates the following report-only Content Security Policy directive: \"frame-ancestors 'self'\". The violation has been logged, but no further action has been taken.",
|
||||
"",
|
||||
],
|
||||
[
|
||||
"unrelated report-only accounts frame-ancestors violation",
|
||||
"Framing 'https://accounts.google.com/' violates the following report-only Content Security Policy directive: \"frame-ancestors 'self'\". The violation has been logged, but no further action has been taken.\n",
|
||||
"",
|
||||
],
|
||||
[
|
||||
"YouTube accounts report-only frame-ancestors violation with a page location",
|
||||
"Framing 'https://accounts.youtube.com/' violates the following report-only Content Security Policy directive: \"frame-ancestors 'self'\". The violation has been logged, but no further action has been taken.\n",
|
||||
"https://www.youtube.com/watch?v=abcdefghijk",
|
||||
],
|
||||
[
|
||||
"lookalike CORS target",
|
||||
"Access to fetch at 'https://doubleclick.net.attacker.test/pixel' from origin 'https://www.youtube.com' has been blocked by CORS policy: blocked",
|
||||
"https://www.youtube.com/watch?v=abcdefghijk",
|
||||
],
|
||||
[
|
||||
"non-YouTube CORS origin",
|
||||
"Access to fetch at 'https://googleads.g.doubleclick.net/pixel' from origin 'https://attacker.test' has been blocked by CORS policy: blocked",
|
||||
"https://www.youtube.com/watch?v=abcdefghijk",
|
||||
],
|
||||
[
|
||||
"doubleclick CORS redirect from a foreign origin",
|
||||
"Access to fetch at 'https://googleads.g.doubleclick.net/pagead/viewthroughconversion/123/' (redirected from 'https://attacker.test/pagead/viewthroughconversion/123/') from origin 'https://www.youtube.com' has been blocked by CORS policy: blocked",
|
||||
"https://www.youtube.com/watch?v=abcdefghijk",
|
||||
],
|
||||
[
|
||||
"doubleclick CORS redirect from a different YouTube endpoint",
|
||||
"Access to fetch at 'https://googleads.g.doubleclick.net/pagead/viewthroughconversion/123/' (redirected from 'https://www.youtube.com/youtubei/v1/player') from origin 'https://www.youtube.com' has been blocked by CORS policy: blocked",
|
||||
"https://www.youtube.com/watch?v=abcdefghijk",
|
||||
],
|
||||
[
|
||||
"sandboxed-frame message at a page URL",
|
||||
"Blocked script execution in 'about:blank' because the document's frame is sandboxed and the 'allow-scripts' permission is not set.",
|
||||
"https://www.youtube.com/watch?v=abcdefghijk",
|
||||
],
|
||||
[
|
||||
"near-miss sandboxed-frame message",
|
||||
"Blocked script execution in 'about:blank' because the document's frame is sandboxed and the 'allow-scripts' permission is not set. Unexpected",
|
||||
"about:blank",
|
||||
],
|
||||
["unrelated about:blank JavaScript error", "TypeError: Cannot read properties of undefined", "about:blank"],
|
||||
])("keeps %s fatal", async (_name, message, url) => {
|
||||
const { diagnostics, page } = createLiveHarness();
|
||||
await diagnostics.start();
|
||||
|
||||
page.emit("console", consoleError(message, url));
|
||||
expect(() => diagnostics.consumeFatalSignals("unexpected console error")).toThrow(/fatal browser signal/);
|
||||
expect(diagnostics.browserSignals[0]).toEqual(
|
||||
expect.objectContaining({ environmentalWarning: null, severity: "fatal", type: "console.error" }),
|
||||
);
|
||||
diagnostics.stop();
|
||||
});
|
||||
|
||||
test("classifies only page console errors, never page exceptions or unhandled rejections", () => {
|
||||
const details = {
|
||||
location: { url: "https://rr2---sn-q4flrnel.googlevideo.com/videoplayback" },
|
||||
message: "Failed to load resource: net::ERR_NAME_NOT_RESOLVED",
|
||||
source: "page",
|
||||
};
|
||||
expect(environmentalConsoleWarningId({ ...details, type: "console.error" })).toBe("googlevideo-resource-failure");
|
||||
expect(environmentalConsoleWarningId({ ...details, type: "pageerror" })).toBeNull();
|
||||
expect(environmentalConsoleWarningId({ ...details, type: "unhandledrejection" })).toBeNull();
|
||||
expect(
|
||||
environmentalConsoleWarningId({ ...details, source: "extension-service-worker", type: "console.error" }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test("fails closed if a future signal reaches the guard without an explicit severity", () => {
|
||||
const { context, page } = createLiveHarness();
|
||||
const guard = new LiveFatalSignalGuard(page, context, { log: jest.fn() });
|
||||
guard.signals.push({ message: "unclassified", type: "console.error" });
|
||||
|
||||
expect(() => guard.assertNoFatalSignalsBetween(0, 1, "future signal")).toThrow(/console\.error: unclassified/);
|
||||
});
|
||||
|
||||
test("allows only an explicit exact benign-console rule and keeps mismatches fatal", async () => {
|
||||
const { diagnostics, page } = createLiveHarness({
|
||||
ignoredSignalRules: [
|
||||
{
|
||||
id: "known-youtube-message",
|
||||
message: "Known YouTube-only diagnostic",
|
||||
type: "console.error",
|
||||
url: "https://www.youtube.com/watch?v=abcdefghijk",
|
||||
},
|
||||
],
|
||||
});
|
||||
await diagnostics.start();
|
||||
|
||||
page.emit("console", consoleError("Known YouTube-only diagnostic"));
|
||||
expect(() => diagnostics.consumeFatalSignals("known noise")).not.toThrow();
|
||||
expect(diagnostics.browserSignals[0]).toEqual(
|
||||
expect.objectContaining({ ignoredBy: "known-youtube-message", severity: "warning" }),
|
||||
);
|
||||
|
||||
page.emit("console", consoleError("Known YouTube-only diagnostic with extra text"));
|
||||
expect(() => diagnostics.consumeFatalSignals("unexpected noise")).toThrow(/fatal browser signal/);
|
||||
diagnostics.stop();
|
||||
});
|
||||
|
||||
test("rejects broad or callback-based console suppression rules", () => {
|
||||
const { context, page } = createLiveHarness();
|
||||
expect(
|
||||
() =>
|
||||
new LiveFatalSignalGuard(page, context, {
|
||||
ignoredSignalRules: [{ id: "message-only", message: "Failed", type: "console.error" }],
|
||||
}),
|
||||
).toThrow(/requires a non-empty exact url/);
|
||||
expect(
|
||||
() =>
|
||||
new LiveFatalSignalGuard(page, context, {
|
||||
ignoredSignalRules: [{ id: "broad", messagePattern: /error/, type: "console.error" }],
|
||||
}),
|
||||
).toThrow(/unsupported fields: messagePattern/);
|
||||
expect(
|
||||
() =>
|
||||
new LiveFatalSignalGuard(page, context, {
|
||||
ignoredSignalRules: [{ id: "callback", message: "ignored", predicate: () => true, type: "console.error" }],
|
||||
}),
|
||||
).toThrow(/unsupported fields: predicate/);
|
||||
});
|
||||
|
||||
test("fails only for console errors from the selected extension service worker", async () => {
|
||||
const extensionId = "a".repeat(32);
|
||||
const matchingWorker = new FakeEmitter();
|
||||
matchingWorker.url = jest.fn(() => `chrome-extension://${extensionId}/ryd.background.js`);
|
||||
matchingWorker.evaluate = jest.fn().mockResolvedValue(undefined);
|
||||
const unrelatedWorker = new FakeEmitter();
|
||||
unrelatedWorker.url = jest.fn(() => `chrome-extension://${"b".repeat(32)}/ryd.background.js`);
|
||||
unrelatedWorker.evaluate = jest.fn().mockResolvedValue(undefined);
|
||||
const { context, diagnostics } = createLiveHarness({ selectedExtensionId: extensionId });
|
||||
context.serviceWorkers.mockReturnValue([matchingWorker, unrelatedWorker]);
|
||||
await diagnostics.start();
|
||||
|
||||
unrelatedWorker.emit("console", consoleError("unrelated extension failed", unrelatedWorker.url()));
|
||||
expect(() => diagnostics.consumeFatalSignals("unrelated worker")).not.toThrow();
|
||||
|
||||
matchingWorker.emit("console", consoleError("selected extension failed", matchingWorker.url()));
|
||||
expect(() => diagnostics.consumeFatalSignals("selected worker")).toThrow(
|
||||
/extension-service-worker\.console\.error: selected extension failed/,
|
||||
);
|
||||
expect(matchingWorker.evaluate).toHaveBeenCalledWith(expect.any(Function), UNHANDLED_REJECTION_PREFIX);
|
||||
expect(unrelatedWorker.evaluate).not.toHaveBeenCalled();
|
||||
diagnostics.stop();
|
||||
});
|
||||
|
||||
test("keeps external-resource wording fatal when it comes from the selected extension worker", async () => {
|
||||
const extensionId = "a".repeat(32);
|
||||
const matchingWorker = new FakeEmitter();
|
||||
matchingWorker.url = jest.fn(() => `chrome-extension://${extensionId}/ryd.background.js`);
|
||||
matchingWorker.evaluate = jest.fn().mockResolvedValue(undefined);
|
||||
const { context, diagnostics } = createLiveHarness({ selectedExtensionId: extensionId });
|
||||
context.serviceWorkers.mockReturnValue([matchingWorker]);
|
||||
await diagnostics.start();
|
||||
|
||||
matchingWorker.emit(
|
||||
"console",
|
||||
consoleError(
|
||||
"Failed to load resource: net::ERR_NAME_NOT_RESOLVED",
|
||||
"https://rr2---sn-q4flrnel.googlevideo.com/videoplayback",
|
||||
),
|
||||
);
|
||||
|
||||
expect(() => diagnostics.consumeFatalSignals("selected worker")).toThrow(
|
||||
/extension-service-worker\.console\.error/,
|
||||
);
|
||||
expect(diagnostics.browserSignals[0]).toEqual(
|
||||
expect.objectContaining({ environmentalWarning: null, severity: "fatal" }),
|
||||
);
|
||||
diagnostics.stop();
|
||||
});
|
||||
|
||||
test("guards a selected extension service worker that restarts during a scenario", async () => {
|
||||
const extensionId = "a".repeat(32);
|
||||
const replacementWorker = new FakeEmitter();
|
||||
replacementWorker.url = jest.fn(() => `chrome-extension://${extensionId}/ryd.background.js`);
|
||||
replacementWorker.evaluate = jest.fn().mockResolvedValue(undefined);
|
||||
const { context, diagnostics } = createLiveHarness({ selectedExtensionId: extensionId });
|
||||
await diagnostics.start();
|
||||
|
||||
context.emit("serviceworker", replacementWorker);
|
||||
replacementWorker.emit(
|
||||
"console",
|
||||
consoleError(`${UNHANDLED_REJECTION_PREFIX}replacement rejected`, replacementWorker.url()),
|
||||
);
|
||||
|
||||
expect(() => diagnostics.consumeFatalSignals("replacement worker")).toThrow(
|
||||
/extension-service-worker\.unhandledrejection: replacement rejected/,
|
||||
);
|
||||
diagnostics.stop();
|
||||
});
|
||||
|
||||
test("continues independent read-only stages, then rejects the aggregate run", async () => {
|
||||
const diagnostics = {
|
||||
consumeFatalSignals: jest.fn(),
|
||||
stageCompleted: jest.fn(),
|
||||
stageFailed: jest.fn(),
|
||||
stageStarted: jest.fn(),
|
||||
};
|
||||
const laterStage = jest.fn().mockResolvedValue("healthy");
|
||||
|
||||
let aggregate;
|
||||
try {
|
||||
await runIndependentLoggedStages(diagnostics, [
|
||||
{
|
||||
action: async () => {
|
||||
throw new Error("first failed");
|
||||
},
|
||||
name: "first",
|
||||
},
|
||||
{ action: laterStage, name: "second" },
|
||||
]);
|
||||
} catch (error) {
|
||||
aggregate = error;
|
||||
}
|
||||
|
||||
expect(aggregate).toBeInstanceOf(AggregateError);
|
||||
expect(aggregate.message).toBe("1 of 2 independent live read-only stages failed.");
|
||||
expect(aggregate.stageResults).toEqual([
|
||||
expect.objectContaining({ error: expect.objectContaining({ message: "first failed" }), name: "first" }),
|
||||
{ name: "second", result: "healthy" },
|
||||
]);
|
||||
expect(laterStage).toHaveBeenCalledTimes(1);
|
||||
expect(diagnostics.stageCompleted).toHaveBeenCalledWith("second", expect.any(Number));
|
||||
});
|
||||
|
||||
test("blocks production reactions unless this worker completed every read-only scenario", () => {
|
||||
const gate = new LiveReadOnlyGate(["shorts-render", "watch-render", "responsive-visual"], {
|
||||
allowedSkippedScenarioIds: ["watch-render"],
|
||||
});
|
||||
gate.record("shorts-render", "passed");
|
||||
gate.record("watch-render", "skipped");
|
||||
expect(() => gate.assertPassed()).toThrow(/Missing: responsive-visual; failed: none/);
|
||||
|
||||
gate.record("responsive-visual", "passed");
|
||||
expect(() => gate.assertPassed()).not.toThrow();
|
||||
|
||||
gate.record("shorts-render", "failed");
|
||||
gate.record("shorts-render", "passed");
|
||||
expect(() => gate.assertPassed()).toThrow(/Missing: shorts-render; failed: shorts-render/);
|
||||
|
||||
const requiredSkip = new LiveReadOnlyGate(["shorts-render"]);
|
||||
requiredSkip.record("shorts-render", "skipped");
|
||||
expect(() => requiredSkip.assertPassed()).toThrow(/failed: shorts-render/);
|
||||
});
|
||||
|
||||
test("a fresh worker cannot inherit read-only gate success from a previous worker", () => {
|
||||
const firstWorker = new LiveReadOnlyGate(["shorts-render"]);
|
||||
firstWorker.record("shorts-render", "passed");
|
||||
expect(() => firstWorker.assertPassed()).not.toThrow();
|
||||
|
||||
const restartedWorker = new LiveReadOnlyGate(["shorts-render"]);
|
||||
expect(() => restartedWorker.assertPassed()).toThrow(/Missing: shorts-render/);
|
||||
});
|
||||
|
||||
test("redacts identity and proof material while retaining diagnostic video IDs", () => {
|
||||
expect(
|
||||
diagnosticApiUrl(
|
||||
|
||||
@@ -0,0 +1,397 @@
|
||||
const path = require("node:path");
|
||||
const {
|
||||
assertWorkerIdentity,
|
||||
extensionServiceWorkerUrl,
|
||||
normalizeExtensionPath,
|
||||
readUnpackedExtensions,
|
||||
reloadLiveExtension,
|
||||
reloadLiveExtensionInBrowser,
|
||||
selectEnabledUnpackedExtension,
|
||||
waitForReloadedExtensionEnabled,
|
||||
waitForExtensionWorker,
|
||||
} = require("../e2e/live/live-extension-reloader");
|
||||
|
||||
const EXTENSION_ID = "abcdefghijklmnopabcdefghijklmnop";
|
||||
const BUILD_ID = "0123456789abcdef0123456789abcdef";
|
||||
const EXTENSION_PATH = "C:\\repo\\Extensions\\combined\\dist\\chrome";
|
||||
|
||||
function identity(overrides = {}) {
|
||||
return {
|
||||
backgroundScript: "ryd.background.js",
|
||||
compiledBuildId: BUILD_ID,
|
||||
extensionId: EXTENSION_ID,
|
||||
resourceBuildId: BUILD_ID,
|
||||
version: "4.0.5",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function flushPromiseJobs() {
|
||||
for (let index = 0; index < 10; index += 1) await Promise.resolve();
|
||||
}
|
||||
|
||||
describe("live unpacked-extension reload helper", () => {
|
||||
test("matches the exact unpacked path with Windows separator and case normalization", () => {
|
||||
const extension = selectEnabledUnpackedExtension(
|
||||
[
|
||||
{ enabled: true, id: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", path: "C:\\other", version: "4.0.5" },
|
||||
{ enabled: true, id: EXTENSION_ID, path: "c:/REPO/Extensions/combined/dist/chrome/", version: "4.0.4" },
|
||||
],
|
||||
EXTENSION_PATH,
|
||||
{ pathImpl: path.win32, platform: "win32" },
|
||||
);
|
||||
|
||||
expect(extension.id).toBe(EXTENSION_ID);
|
||||
expect(normalizeExtensionPath(extension.path, { pathImpl: path.win32, platform: "win32" })).toBe(
|
||||
normalizeExtensionPath(EXTENSION_PATH, { pathImpl: path.win32, platform: "win32" }),
|
||||
);
|
||||
});
|
||||
|
||||
test.each([
|
||||
[[], /is not installed/],
|
||||
[[{ enabled: false, id: EXTENSION_ID, path: EXTENSION_PATH }], /installed but disabled/],
|
||||
[[{ enabled: true, id: "not-an-id", path: EXTENSION_PATH }], /invalid extension ID/],
|
||||
[
|
||||
[
|
||||
{ enabled: true, id: EXTENSION_ID, path: EXTENSION_PATH },
|
||||
{ enabled: true, id: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", path: EXTENSION_PATH },
|
||||
],
|
||||
/more than one/,
|
||||
],
|
||||
])("refuses an unsafe unpacked-extension inventory", (extensions, expectedError) => {
|
||||
expect(() =>
|
||||
selectEnabledUnpackedExtension(extensions, EXTENSION_PATH, {
|
||||
pathImpl: path.win32,
|
||||
platform: "win32",
|
||||
}),
|
||||
).toThrow(expectedError);
|
||||
});
|
||||
|
||||
test("can use the default-profile inventory only for exact path-to-ID identification", () => {
|
||||
expect(
|
||||
selectEnabledUnpackedExtension(
|
||||
[{ enabled: false, id: EXTENSION_ID, path: EXTENSION_PATH, version: "4.0.5" }],
|
||||
EXTENSION_PATH,
|
||||
{ pathImpl: path.win32, platform: "win32", requireEnabled: false },
|
||||
),
|
||||
).toMatchObject({ enabled: false, id: EXTENSION_ID });
|
||||
});
|
||||
|
||||
test("uses an exact extension worker URL", () => {
|
||||
expect(extensionServiceWorkerUrl(EXTENSION_ID)).toBe(`chrome-extension://${EXTENSION_ID}/ryd.background.js`);
|
||||
expect(() => extensionServiceWorkerUrl("not-an-id")).toThrow(/valid Chrome extension ID/);
|
||||
});
|
||||
|
||||
test("starts a dormant worker through the ServiceWorker domain", async () => {
|
||||
const worker = { url: () => extensionServiceWorkerUrl(EXTENSION_ID) };
|
||||
let workers = [];
|
||||
const context = { serviceWorkers: jest.fn(() => workers) };
|
||||
const serviceWorkerSession = {
|
||||
send: jest.fn(async (method) => {
|
||||
if (method === "ServiceWorker.startWorker") workers = [worker];
|
||||
}),
|
||||
};
|
||||
|
||||
await expect(
|
||||
waitForExtensionWorker(context, serviceWorkerSession, EXTENSION_ID, {
|
||||
delayImpl: async () => {},
|
||||
now: (() => {
|
||||
let value = 0;
|
||||
return () => value++;
|
||||
})(),
|
||||
timeoutMilliseconds: 20,
|
||||
}),
|
||||
).resolves.toBe(worker);
|
||||
expect(serviceWorkerSession.send).toHaveBeenNthCalledWith(1, "ServiceWorker.enable");
|
||||
expect(serviceWorkerSession.send).toHaveBeenNthCalledWith(2, "ServiceWorker.startWorker", {
|
||||
scopeURL: `chrome-extension://${EXTENSION_ID}/`,
|
||||
});
|
||||
});
|
||||
|
||||
test("fails closed when Chrome cannot inventory unpacked extensions", async () => {
|
||||
const browserSession = { send: jest.fn().mockRejectedValue(new Error("Method not available")) };
|
||||
|
||||
await expect(readUnpackedExtensions(browserSession)).rejects.toThrow(
|
||||
/cannot be identified safely.*no extension was changed/i,
|
||||
);
|
||||
});
|
||||
|
||||
test("polls through Chrome's transient post-reload disabled inventory state", async () => {
|
||||
jest.useFakeTimers({ doNotFake: ["performance"] });
|
||||
try {
|
||||
const browserSession = {
|
||||
send: jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
extensions: [{ enabled: false, id: EXTENSION_ID, path: EXTENSION_PATH, version: "4.0.5" }],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
extensions: [{ enabled: true, id: EXTENSION_ID, path: EXTENSION_PATH, version: "4.0.5" }],
|
||||
}),
|
||||
};
|
||||
|
||||
const extensionPromise = waitForReloadedExtensionEnabled(browserSession, EXTENSION_PATH, EXTENSION_ID, {
|
||||
pathOptions: { pathImpl: path.win32, platform: "win32" },
|
||||
timeoutMilliseconds: 100,
|
||||
});
|
||||
await flushPromiseJobs();
|
||||
expect(jest.getTimerCount()).toBe(1);
|
||||
jest.advanceTimersByTime(50);
|
||||
await flushPromiseJobs();
|
||||
|
||||
await expect(extensionPromise).resolves.toMatchObject({ enabled: true, id: EXTENSION_ID });
|
||||
expect(browserSession.send).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
test("times out when Chrome's post-reload inventory remains disabled", async () => {
|
||||
jest.useFakeTimers({ doNotFake: ["performance"] });
|
||||
try {
|
||||
const browserSession = {
|
||||
send: jest.fn().mockResolvedValue({
|
||||
extensions: [{ enabled: false, id: EXTENSION_ID, path: EXTENSION_PATH, version: "4.0.5" }],
|
||||
}),
|
||||
};
|
||||
|
||||
const extensionPromise = waitForReloadedExtensionEnabled(browserSession, EXTENSION_PATH, EXTENSION_ID, {
|
||||
pathOptions: { pathImpl: path.win32, platform: "win32" },
|
||||
timeoutMilliseconds: 100,
|
||||
});
|
||||
const rejection = expect(extensionPromise).rejects.toThrow(/remained disabled for 100ms after reload/);
|
||||
await flushPromiseJobs();
|
||||
expect(jest.getTimerCount()).toBe(1);
|
||||
jest.advanceTimersByTime(50);
|
||||
await flushPromiseJobs();
|
||||
expect(jest.getTimerCount()).toBe(1);
|
||||
jest.advanceTimersByTime(50);
|
||||
await flushPromiseJobs();
|
||||
|
||||
await rejection;
|
||||
expect(browserSession.send).toHaveBeenCalledTimes(3);
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects a different extension ID immediately while polling the exact unpacked path", async () => {
|
||||
const differentExtensionId = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
|
||||
const browserSession = {
|
||||
send: jest.fn().mockResolvedValue({
|
||||
extensions: [{ enabled: false, id: differentExtensionId, path: EXTENSION_PATH, version: "4.0.5" }],
|
||||
}),
|
||||
};
|
||||
|
||||
await expect(
|
||||
waitForReloadedExtensionEnabled(browserSession, EXTENSION_PATH, EXTENSION_ID, {
|
||||
pathOptions: { pathImpl: path.win32, platform: "win32" },
|
||||
timeoutMilliseconds: 100,
|
||||
}),
|
||||
).rejects.toThrow(`changed from ${EXTENSION_ID} to ${differentExtensionId}`);
|
||||
expect(browserSession.send).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("requires both the compiled and resource build IDs after reload", () => {
|
||||
expect(() =>
|
||||
assertWorkerIdentity(identity({ compiledBuildId: "f".repeat(32) }), EXTENSION_ID, {
|
||||
expectedBuildId: BUILD_ID,
|
||||
expectedVersion: "4.0.5",
|
||||
}),
|
||||
).toThrow(/is running build/);
|
||||
expect(() =>
|
||||
assertWorkerIdentity(identity({ resourceBuildId: "f".repeat(32) }), EXTENSION_ID, {
|
||||
expectedBuildId: BUILD_ID,
|
||||
expectedVersion: "4.0.5",
|
||||
}),
|
||||
).toThrow(/exposes resource build/);
|
||||
});
|
||||
|
||||
test("reloads only the exact enabled unpacked extension and proves the replacement worker build", async () => {
|
||||
let workers;
|
||||
let inventoryVersion = "4.0.4";
|
||||
let closeOldWorker;
|
||||
const oldWorkerClosed = new Promise((resolve) => {
|
||||
closeOldWorker = resolve;
|
||||
});
|
||||
const oldWorker = {
|
||||
evaluate: jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce(
|
||||
identity({ compiledBuildId: "a".repeat(32), resourceBuildId: BUILD_ID, version: "4.0.4" }),
|
||||
)
|
||||
.mockImplementationOnce(async () => {
|
||||
workers = [];
|
||||
inventoryVersion = "4.0.5";
|
||||
closeOldWorker();
|
||||
return true;
|
||||
}),
|
||||
url: () => extensionServiceWorkerUrl(EXTENSION_ID),
|
||||
waitForEvent: jest.fn(() => oldWorkerClosed),
|
||||
};
|
||||
const newWorker = {
|
||||
evaluate: jest.fn().mockResolvedValue(identity()),
|
||||
url: () => extensionServiceWorkerUrl(EXTENSION_ID),
|
||||
};
|
||||
workers = [oldWorker];
|
||||
|
||||
const browserSession = {
|
||||
send: jest.fn(async (method) => {
|
||||
expect(method).toBe("Extensions.getExtensions");
|
||||
return {
|
||||
extensions: [{ enabled: true, id: EXTENSION_ID, path: EXTENSION_PATH, version: inventoryVersion }],
|
||||
};
|
||||
}),
|
||||
};
|
||||
const context = { serviceWorkers: jest.fn(() => workers) };
|
||||
const serviceWorkerSession = {
|
||||
send: jest.fn(async (method) => {
|
||||
if (method === "ServiceWorker.startWorker") workers = [newWorker];
|
||||
}),
|
||||
};
|
||||
|
||||
await expect(
|
||||
reloadLiveExtension({
|
||||
browserSession,
|
||||
context,
|
||||
expectedBuildId: BUILD_ID,
|
||||
expectedExtensionPath: EXTENSION_PATH,
|
||||
expectedVersion: "4.0.5",
|
||||
pathOptions: { pathImpl: path.win32, platform: "win32" },
|
||||
serviceWorkerSession,
|
||||
timeoutMilliseconds: 100,
|
||||
waitOptions: { delayImpl: async () => {}, now: Date.now },
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
buildId: BUILD_ID,
|
||||
extensionId: EXTENSION_ID,
|
||||
previousVersion: "4.0.4",
|
||||
version: "4.0.5",
|
||||
});
|
||||
|
||||
expect(browserSession.send).toHaveBeenCalledTimes(2);
|
||||
expect(oldWorker.waitForEvent).toHaveBeenCalledWith("close", { timeout: 100 });
|
||||
expect(oldWorker.evaluate).toHaveBeenCalledTimes(2);
|
||||
expect(newWorker.evaluate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("refuses to start or reload the unpacked extension when Chrome reports it disabled", async () => {
|
||||
const browserSession = {
|
||||
send: jest.fn().mockResolvedValue({
|
||||
extensions: [{ enabled: false, id: EXTENSION_ID, path: EXTENSION_PATH, version: "4.0.5" }],
|
||||
}),
|
||||
};
|
||||
const context = { serviceWorkers: jest.fn() };
|
||||
const serviceWorkerSession = { send: jest.fn() };
|
||||
|
||||
await expect(
|
||||
reloadLiveExtension({
|
||||
browserSession,
|
||||
context,
|
||||
expectedBuildId: BUILD_ID,
|
||||
expectedExtensionPath: EXTENSION_PATH,
|
||||
expectedVersion: "4.0.5",
|
||||
pathOptions: { pathImpl: path.win32, platform: "win32" },
|
||||
serviceWorkerSession,
|
||||
}),
|
||||
).rejects.toThrow(/installed but disabled/);
|
||||
expect(context.serviceWorkers).not.toHaveBeenCalled();
|
||||
expect(serviceWorkerSession.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("does not request a worker reload when the expected unpacked path is absent", async () => {
|
||||
const browserSession = {
|
||||
send: jest.fn().mockResolvedValue({
|
||||
extensions: [{ enabled: true, id: EXTENSION_ID, path: "C:\\somewhere-else", version: "4.0.5" }],
|
||||
}),
|
||||
};
|
||||
const context = { serviceWorkers: jest.fn() };
|
||||
const serviceWorkerSession = { send: jest.fn() };
|
||||
|
||||
await expect(
|
||||
reloadLiveExtension({
|
||||
browserSession,
|
||||
context,
|
||||
expectedBuildId: BUILD_ID,
|
||||
expectedExtensionPath: EXTENSION_PATH,
|
||||
expectedVersion: "4.0.5",
|
||||
pathOptions: { pathImpl: path.win32, platform: "win32" },
|
||||
serviceWorkerSession,
|
||||
}),
|
||||
).rejects.toThrow(/is not installed/);
|
||||
expect(context.serviceWorkers).not.toHaveBeenCalled();
|
||||
expect(serviceWorkerSession.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("uses only the caller-selected YouTube page for the target-profile ServiceWorker session", async () => {
|
||||
const existingInternalPage = { url: () => "chrome://extensions/" };
|
||||
const selectedPage = {
|
||||
context: jest.fn(),
|
||||
isClosed: jest.fn().mockReturnValue(false),
|
||||
url: jest.fn().mockReturnValue("https://www.youtube.com/watch?v=AAAAAAAAAAA"),
|
||||
};
|
||||
const browserSession = { detach: jest.fn().mockResolvedValue(undefined) };
|
||||
const serviceWorkerSession = { detach: jest.fn().mockResolvedValue(undefined) };
|
||||
const browser = { newBrowserCDPSession: jest.fn().mockResolvedValue(browserSession) };
|
||||
const context = {
|
||||
newCDPSession: jest.fn().mockResolvedValue(serviceWorkerSession),
|
||||
newPage: jest.fn(),
|
||||
pages: jest.fn().mockReturnValue([existingInternalPage]),
|
||||
};
|
||||
selectedPage.context.mockReturnValue(context);
|
||||
const reloadResult = { extensionId: EXTENSION_ID };
|
||||
const reloadImpl = jest.fn().mockResolvedValue(reloadResult);
|
||||
|
||||
await expect(
|
||||
reloadLiveExtensionInBrowser({
|
||||
browser,
|
||||
context,
|
||||
expectedBuildId: BUILD_ID,
|
||||
expectedExtensionPath: EXTENSION_PATH,
|
||||
expectedVersion: "4.0.5",
|
||||
reloadImpl,
|
||||
sessionPage: selectedPage,
|
||||
timeoutMilliseconds: 12345,
|
||||
}),
|
||||
).resolves.toBe(reloadResult);
|
||||
|
||||
expect(context.pages).not.toHaveBeenCalled();
|
||||
expect(context.newPage).not.toHaveBeenCalled();
|
||||
expect(context.newCDPSession).toHaveBeenCalledWith(selectedPage);
|
||||
expect(reloadImpl).toHaveBeenCalledWith({
|
||||
browserSession,
|
||||
context,
|
||||
expectedBuildId: BUILD_ID,
|
||||
expectedExtensionPath: EXTENSION_PATH,
|
||||
expectedVersion: "4.0.5",
|
||||
serviceWorkerSession,
|
||||
timeoutMilliseconds: 12345,
|
||||
});
|
||||
expect(serviceWorkerSession.detach).toHaveBeenCalledTimes(1);
|
||||
expect(browserSession.detach).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("rejects a session page from a different profile before opening a browser CDP session", async () => {
|
||||
const otherContext = {};
|
||||
const sessionPage = {
|
||||
context: () => otherContext,
|
||||
isClosed: () => false,
|
||||
url: () => "https://www.youtube.com/",
|
||||
};
|
||||
const browser = { newBrowserCDPSession: jest.fn() };
|
||||
const context = { newCDPSession: jest.fn(), newPage: jest.fn() };
|
||||
|
||||
await expect(
|
||||
reloadLiveExtensionInBrowser({
|
||||
browser,
|
||||
context,
|
||||
expectedBuildId: BUILD_ID,
|
||||
expectedExtensionPath: EXTENSION_PATH,
|
||||
expectedVersion: "4.0.5",
|
||||
sessionPage,
|
||||
}),
|
||||
).rejects.toThrow(/does not belong to the attached Chrome context/);
|
||||
expect(browser.newBrowserCDPSession).not.toHaveBeenCalled();
|
||||
expect(context.newCDPSession).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* @jest-environment node
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const packageManifest = require("../../../package.json");
|
||||
const {
|
||||
LiveValidationIncompleteError,
|
||||
REQUIRED_PRODUCTION_REACTION_SCENARIOS,
|
||||
assertFullLiveReactionCompletion,
|
||||
buildLiveRuntimeArtifact,
|
||||
readFullLiveRuntime,
|
||||
requireProductionReactionApproval,
|
||||
requireUserscriptInstallAcknowledgement,
|
||||
} = require("../e2e/live/live-full-run-contract");
|
||||
|
||||
test.each(["extension", "userscript"])("selects and builds the exact %s live runtime before a full run", (runtime) => {
|
||||
const spawnSyncImpl = jest.fn(() => ({ status: 0 }));
|
||||
const environment = { npm_execpath: "C:\\node\\node_modules\\npm\\bin\\npm-cli.js", RYD_LIVE_RUNTIME: runtime };
|
||||
|
||||
expect(readFullLiveRuntime(environment)).toBe(runtime);
|
||||
expect(
|
||||
buildLiveRuntimeArtifact(runtime, {
|
||||
environment,
|
||||
nodeExecutable: "C:\\node\\node.exe",
|
||||
platform: "win32",
|
||||
repositoryRoot: "repository",
|
||||
spawnSyncImpl,
|
||||
}),
|
||||
).toEqual({ runtime, script: `build:live:${runtime}` });
|
||||
expect(spawnSyncImpl).toHaveBeenCalledWith(
|
||||
"C:\\node\\node.exe",
|
||||
["C:\\node\\node_modules\\npm\\bin\\npm-cli.js", "run", `build:live:${runtime}`],
|
||||
{
|
||||
cwd: "repository",
|
||||
env: environment,
|
||||
stdio: "inherit",
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("uses a Windows command shell only when the npm CLI path is unavailable", () => {
|
||||
const spawnSyncImpl = jest.fn(() => ({ status: 0 }));
|
||||
const environment = { RYD_LIVE_RUNTIME: "extension" };
|
||||
|
||||
buildLiveRuntimeArtifact("extension", {
|
||||
environment,
|
||||
platform: "win32",
|
||||
repositoryRoot: "repository",
|
||||
spawnSyncImpl,
|
||||
});
|
||||
|
||||
expect(spawnSyncImpl).toHaveBeenCalledWith("npm.cmd", ["run", "build:live:extension"], {
|
||||
cwd: "repository",
|
||||
env: environment,
|
||||
shell: true,
|
||||
stdio: "inherit",
|
||||
});
|
||||
});
|
||||
|
||||
test("a failed live build prevents the full runner from continuing", () => {
|
||||
expect(() =>
|
||||
buildLiveRuntimeArtifact("extension", {
|
||||
spawnSyncImpl: () => ({ status: 2 }),
|
||||
}),
|
||||
).toThrow("build:live:extension failed with exit code 2");
|
||||
});
|
||||
|
||||
test.each([undefined, "", "browser"])("rejects unsupported full-live runtime %p", (runtime) => {
|
||||
expect(() => readFullLiveRuntime({ RYD_LIVE_RUNTIME: runtime })).toThrow(/RYD_LIVE_RUNTIME/);
|
||||
});
|
||||
|
||||
test("classifies only both completed production reaction scenarios as a full run", () => {
|
||||
expect(REQUIRED_PRODUCTION_REACTION_SCENARIOS).toEqual(["post-navigation-vote", "reaction-matrix"]);
|
||||
expect(
|
||||
assertFullLiveReactionCompletion({
|
||||
"post-navigation-vote": { videoId: "abcdefghijk" },
|
||||
"reaction-matrix": [{ action: "like" }],
|
||||
}),
|
||||
).toEqual({
|
||||
classification: "full",
|
||||
completedScenarioIds: REQUIRED_PRODUCTION_REACTION_SCENARIOS,
|
||||
productionReactionsCompleted: true,
|
||||
releaseReady: true,
|
||||
});
|
||||
});
|
||||
|
||||
test.each([
|
||||
[null, /no production reaction results/],
|
||||
[{}, /post-navigation-vote, reaction-matrix/],
|
||||
[{ "post-navigation-vote": {} }, /reaction-matrix/],
|
||||
[{ "reaction-matrix": [] }, /post-navigation-vote/],
|
||||
[{ "post-navigation-vote": null, "reaction-matrix": {} }, /post-navigation-vote/],
|
||||
])("rejects incomplete full-live reaction results %#", (results, message) => {
|
||||
expect(() => assertFullLiveReactionCompletion(results)).toThrow(message);
|
||||
});
|
||||
|
||||
test("SKIP is an explicit incomplete failure, not a successful full run", () => {
|
||||
expect(() => requireProductionReactionApproval("SKIP")).toThrow(LiveValidationIncompleteError);
|
||||
expect(() => requireProductionReactionApproval(" skip ")).toThrow(/production reaction scenarios were skipped/);
|
||||
expect(requireProductionReactionApproval("extension:abcdefghijk:123")).toBe("extension:abcdefghijk:123");
|
||||
});
|
||||
|
||||
test("a freshly built userscript requires an explicit installation acknowledgement", () => {
|
||||
expect(requireUserscriptInstallAcknowledgement("INSTALLED")).toBe(true);
|
||||
expect(() => requireUserscriptInstallAcknowledgement("SKIP")).toThrow(LiveValidationIncompleteError);
|
||||
expect(() => requireUserscriptInstallAcknowledgement("")).toThrow(/not acknowledged as installed/);
|
||||
});
|
||||
|
||||
test("package commands distinguish full reaction validation from the read-only Playwright suite", () => {
|
||||
expect(packageManifest.scripts["test:live:youtube"]).toBe(
|
||||
"node Extensions/UserScript/e2e/live/live-interactive-runner.js",
|
||||
);
|
||||
expect(packageManifest.scripts["test:live:youtube:full"]).toBe("npm run test:live:youtube");
|
||||
expect(packageManifest.scripts["test:live:youtube:interactive"]).toBe("npm run test:live:youtube");
|
||||
expect(packageManifest.scripts["test:live:youtube:read-only"]).toBe(
|
||||
"playwright test --config playwright.live-youtube.config.js",
|
||||
);
|
||||
});
|
||||
|
||||
test("the read-only Playwright suite cannot silently register a skipped reaction test", () => {
|
||||
const source = fs.readFileSync(path.resolve(__dirname, "../e2e/live/live-youtube.live.e2e.js"), "utf8");
|
||||
expect(source).toContain('test.describe("live YouTube RYD read-only smoke"');
|
||||
expect(source).not.toMatch(/\btest\.skip\s*\(/);
|
||||
expect(source).not.toContain("reaction-matrix");
|
||||
expect(source).not.toContain("post-navigation-vote");
|
||||
expect(source).toContain("LIVE_VALIDATION_READ_ONLY_COMPLETE");
|
||||
expect(source).toContain("releaseReady: false");
|
||||
});
|
||||
|
||||
test("standalone artifact runners verify current source before consuming existing output", () => {
|
||||
expect(packageManifest.scripts["test:e2e:userscript:run"]).toMatch(/^npm run check:userscript-artifact && /);
|
||||
expect(packageManifest.scripts["test:e2e:artifacts"]).toMatch(
|
||||
/^npm run check:extension-artifact && npm run check:userscript-artifact && /,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,212 @@
|
||||
const {
|
||||
INTERACTIVE_READ_ONLY_SCENARIOS,
|
||||
createInteractiveReadOnlyStages,
|
||||
runInteractiveReadOnlyAfterRuntimeReload,
|
||||
} = require("../e2e/live/live-interactive-readonly");
|
||||
const {
|
||||
REQUIRED_INTERACTIVE_READ_ONLY_SCENARIO_IDS,
|
||||
assertInteractiveReadOnlyScenarioCompletions,
|
||||
assertInteractiveReadOnlyScenarioDefinitions,
|
||||
} = require("../e2e/live/live-interactive-scenario-contract");
|
||||
|
||||
const OPTIONS = Object.freeze({
|
||||
navigation: Object.freeze({ watch: "watch-target" }),
|
||||
runtime: "extension",
|
||||
short: "9LjMX9xeeok",
|
||||
});
|
||||
|
||||
describe("interactive live read-only ordering", () => {
|
||||
test("declares the exact independent required scenario set with the cold Short first", () => {
|
||||
expect(REQUIRED_INTERACTIVE_READ_ONLY_SCENARIO_IDS).toEqual([
|
||||
"shorts-render",
|
||||
"channel-shorts-navigation",
|
||||
"channel-watch-navigation",
|
||||
"reload",
|
||||
"responsive-visual",
|
||||
"spa-navigation",
|
||||
"watch-action-topology",
|
||||
"sidebar-navigation-stress",
|
||||
"watch-render",
|
||||
]);
|
||||
expect(new Set(REQUIRED_INTERACTIVE_READ_ONLY_SCENARIO_IDS)).toHaveProperty(
|
||||
"size",
|
||||
REQUIRED_INTERACTIVE_READ_ONLY_SCENARIO_IDS.length,
|
||||
);
|
||||
expect(REQUIRED_INTERACTIVE_READ_ONLY_SCENARIO_IDS[0]).toBe("shorts-render");
|
||||
});
|
||||
|
||||
test("keeps the implementation aligned with the independent required order", () => {
|
||||
const stages = createInteractiveReadOnlyStages({
|
||||
diagnostics: { checkpoint: jest.fn() },
|
||||
options: OPTIONS,
|
||||
readOnly: {},
|
||||
runtimeAdapter: {},
|
||||
scenarioRunner: { run: jest.fn() },
|
||||
});
|
||||
|
||||
expect(stages.map(({ scenarioId }) => scenarioId)).toEqual(REQUIRED_INTERACTIVE_READ_ONLY_SCENARIO_IDS);
|
||||
expect(() => assertInteractiveReadOnlyScenarioDefinitions(INTERACTIVE_READ_ONLY_SCENARIOS)).not.toThrow();
|
||||
expect(stages[0]).toMatchObject({ name: "read-only.short-render", scenarioId: "shorts-render" });
|
||||
expect(stages.findIndex(({ scenarioId }) => scenarioId === "shorts-render")).toBeLessThan(
|
||||
stages.findIndex(({ scenarioId }) => scenarioId === "channel-shorts-navigation"),
|
||||
);
|
||||
expect(stages.findIndex(({ scenarioId }) => scenarioId === "shorts-render")).toBeLessThan(
|
||||
stages.findIndex(({ scenarioId }) => scenarioId === "channel-watch-navigation"),
|
||||
);
|
||||
});
|
||||
|
||||
test.each(REQUIRED_INTERACTIVE_READ_ONLY_SCENARIO_IDS)(
|
||||
"fails the independent contract when %s is deleted from the implementation",
|
||||
(deletedScenarioId) => {
|
||||
const incompleteDefinitions = INTERACTIVE_READ_ONLY_SCENARIOS.filter(
|
||||
({ scenarioId }) => scenarioId !== deletedScenarioId,
|
||||
);
|
||||
|
||||
expect(() => assertInteractiveReadOnlyScenarioDefinitions(incompleteDefinitions)).toThrow(
|
||||
new RegExp(`Missing: .*${deletedScenarioId}`),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test("rejects duplicate, unexpected, and reordered implementation scenarios", () => {
|
||||
const duplicate = [
|
||||
...INTERACTIVE_READ_ONLY_SCENARIOS,
|
||||
{ key: "duplicate", name: "read-only.duplicate", scenarioId: "shorts-render" },
|
||||
];
|
||||
const unexpected = [
|
||||
...INTERACTIVE_READ_ONLY_SCENARIOS.slice(0, -1),
|
||||
{ key: "unexpected", name: "read-only.unexpected", scenarioId: "unexpected" },
|
||||
];
|
||||
const reordered = [...INTERACTIVE_READ_ONLY_SCENARIOS];
|
||||
[reordered[0], reordered[1]] = [reordered[1], reordered[0]];
|
||||
|
||||
expect(() => assertInteractiveReadOnlyScenarioDefinitions(duplicate)).toThrow(/duplicate scenario IDs/);
|
||||
expect(() => assertInteractiveReadOnlyScenarioDefinitions(unexpected)).toThrow(
|
||||
/Missing: watch-render; unexpected: unexpected/,
|
||||
);
|
||||
expect(() => assertInteractiveReadOnlyScenarioDefinitions(reordered)).toThrow(
|
||||
/required interactive read-only order/,
|
||||
);
|
||||
});
|
||||
|
||||
test("waits for the exact extension reload, then runs the configured Short cold before other scenarios", async () => {
|
||||
const events = [];
|
||||
let finishReload;
|
||||
const reloadPending = new Promise((resolve) => {
|
||||
finishReload = resolve;
|
||||
});
|
||||
const scenarioRunner = {
|
||||
run: jest.fn(async (_adapter, scenarioId, receivedOptions) => {
|
||||
events.push(`scenario:${scenarioId}:${receivedOptions.short}`);
|
||||
return scenarioId;
|
||||
}),
|
||||
};
|
||||
const execution = runInteractiveReadOnlyAfterRuntimeReload({
|
||||
initializeRuntime: async (reloadResult) => {
|
||||
events.push(`initialize:${reloadResult.extensionId}`);
|
||||
return { diagnostics: {}, runtimeAdapter: { runtime: "extension" } };
|
||||
},
|
||||
options: OPTIONS,
|
||||
reloadExtension: async () => {
|
||||
events.push("reload:start");
|
||||
const result = await reloadPending;
|
||||
events.push(`reload:complete:${result.extensionId}`);
|
||||
return result;
|
||||
},
|
||||
runStages: async (_diagnostics, stages) => {
|
||||
for (const stage of stages) await stage.action();
|
||||
},
|
||||
scenarioRunner,
|
||||
});
|
||||
|
||||
await Promise.resolve();
|
||||
expect(events).toEqual(["reload:start"]);
|
||||
|
||||
finishReload({ extensionId: "installed-extension" });
|
||||
await expect(execution).resolves.toMatchObject({
|
||||
readOnly: { short: "shorts-render" },
|
||||
reloadResult: { extensionId: "installed-extension" },
|
||||
});
|
||||
expect(events.slice(0, 4)).toEqual([
|
||||
"reload:start",
|
||||
"reload:complete:installed-extension",
|
||||
"initialize:installed-extension",
|
||||
`scenario:shorts-render:${OPTIONS.short}`,
|
||||
]);
|
||||
expect(scenarioRunner.run).toHaveBeenNthCalledWith(1, { runtime: "extension" }, "shorts-render", OPTIONS);
|
||||
});
|
||||
|
||||
test("refuses to classify an unconfigured channel-to-Watch scenario as completed", () => {
|
||||
expect(() =>
|
||||
createInteractiveReadOnlyStages({
|
||||
diagnostics: { checkpoint: jest.fn() },
|
||||
options: { ...OPTIONS, navigation: { watch: null } },
|
||||
readOnly: {},
|
||||
runtimeAdapter: {},
|
||||
scenarioRunner: { run: jest.fn() },
|
||||
}),
|
||||
).toThrow(/requires a configured channel Watch target/);
|
||||
});
|
||||
|
||||
test("refuses reaction readiness when the stage executor silently omits any scenario", async () => {
|
||||
const scenarioRunner = { run: jest.fn(async (_adapter, scenarioId) => scenarioId) };
|
||||
|
||||
await expect(
|
||||
runInteractiveReadOnlyAfterRuntimeReload({
|
||||
initializeRuntime: async () => ({ diagnostics: {}, runtimeAdapter: {} }),
|
||||
options: OPTIONS,
|
||||
reloadExtension: async () => ({ extensionId: "installed-extension" }),
|
||||
runStages: async (_diagnostics, stages) => {
|
||||
for (const stage of stages.slice(0, -1)) await stage.action();
|
||||
},
|
||||
scenarioRunner,
|
||||
}),
|
||||
).rejects.toThrow(/Missing: watch-render/);
|
||||
expect(scenarioRunner.run).toHaveBeenCalledTimes(REQUIRED_INTERACTIVE_READ_ONLY_SCENARIO_IDS.length - 1);
|
||||
});
|
||||
|
||||
test("refuses reaction readiness when a scenario resolves without a result", async () => {
|
||||
await expect(
|
||||
runInteractiveReadOnlyAfterRuntimeReload({
|
||||
initializeRuntime: async () => ({ diagnostics: {}, runtimeAdapter: {} }),
|
||||
options: OPTIONS,
|
||||
reloadExtension: async () => ({ extensionId: "installed-extension" }),
|
||||
runStages: async (_diagnostics, stages) => {
|
||||
await stages[0].action();
|
||||
},
|
||||
scenarioRunner: { run: jest.fn().mockResolvedValue(undefined) },
|
||||
}),
|
||||
).rejects.toThrow(/shorts-render completed without a result/);
|
||||
});
|
||||
|
||||
test("refuses reaction readiness when stages complete out of order or more than once", async () => {
|
||||
const execute = (runStages) =>
|
||||
runInteractiveReadOnlyAfterRuntimeReload({
|
||||
initializeRuntime: async () => ({ diagnostics: {}, runtimeAdapter: {} }),
|
||||
options: OPTIONS,
|
||||
reloadExtension: async () => ({ extensionId: "installed-extension" }),
|
||||
runStages,
|
||||
scenarioRunner: { run: async (_adapter, scenarioId) => scenarioId },
|
||||
});
|
||||
|
||||
await expect(
|
||||
execute(async (_diagnostics, stages) => {
|
||||
for (const stage of [...stages].reverse()) await stage.action();
|
||||
}),
|
||||
).rejects.toThrow(/required interactive read-only order/);
|
||||
|
||||
await expect(
|
||||
execute(async (_diagnostics, stages) => {
|
||||
for (const stage of stages) await stage.action();
|
||||
await stages[0].action();
|
||||
}),
|
||||
).rejects.toThrow(/duplicate scenario IDs/);
|
||||
});
|
||||
|
||||
test("completion validation independently rejects an empty or incomplete result set", () => {
|
||||
expect(() => assertInteractiveReadOnlyScenarioCompletions([])).toThrow(/Missing:/);
|
||||
expect(() =>
|
||||
assertInteractiveReadOnlyScenarioCompletions(REQUIRED_INTERACTIVE_READ_ONLY_SCENARIO_IDS.slice(0, -1)),
|
||||
).toThrow(/Missing: watch-render/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,452 @@
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { PassThrough } = require("node:stream");
|
||||
const {
|
||||
assertReusableConnectionOptions,
|
||||
closeLiveAttemptPage,
|
||||
createInputLineReader,
|
||||
createInteractiveLiveDriver,
|
||||
createInteractiveVoteRecorderFactory,
|
||||
createReactionTargetDetails,
|
||||
formatReactionApprovalReadiness,
|
||||
main,
|
||||
normalizeLiveSessionCommand,
|
||||
preserveLiveAttemptEvidence,
|
||||
readPersistentSessionMode,
|
||||
resetLiveAttemptEvidenceWorkspace,
|
||||
resolveSafeLiveEvidenceDirectory,
|
||||
} = require("../e2e/live/live-interactive-runner");
|
||||
|
||||
const SELECTED_EXTENSION_ID = "abcdefghijklmnopabcdefghijklmnop";
|
||||
const EXPECTED_BUILD_ID = "0123456789abcdef0123456789abcdef";
|
||||
|
||||
function createOutput() {
|
||||
return { error: jest.fn(), log: jest.fn() };
|
||||
}
|
||||
|
||||
function createLiveOptions(overrides = {}) {
|
||||
return {
|
||||
cdpConnectTimeoutMilliseconds: 120_000,
|
||||
cdpEndpoint: "ws://127.0.0.1:60011/devtools/browser/session",
|
||||
expectedBuildId: EXPECTED_BUILD_ID,
|
||||
expectedChannel: "@anarios-ryd",
|
||||
runtime: "extension",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createContext() {
|
||||
return {
|
||||
off: jest.fn(),
|
||||
on: jest.fn(),
|
||||
pages: jest.fn(() => []),
|
||||
};
|
||||
}
|
||||
|
||||
function createPage() {
|
||||
return {
|
||||
setDefaultNavigationTimeout: jest.fn(),
|
||||
setDefaultTimeout: jest.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
describe("interactive live runtime binding", () => {
|
||||
test("reports the dedicated reaction targets without conflating the read-only Short", () => {
|
||||
const options = {
|
||||
reactionShort: "reactshort1",
|
||||
runtime: "extension",
|
||||
short: "readonly001",
|
||||
watchB: "reaction001",
|
||||
};
|
||||
|
||||
expect(createReactionTargetDetails(options)).toEqual({
|
||||
reactionShort: "reactshort1",
|
||||
reactionWatch: "reaction001",
|
||||
});
|
||||
expect(formatReactionApprovalReadiness(options)).toBe(
|
||||
"READY_FOR_REACTION_APPROVAL runtime=extension watch=reaction001 reactionShort=reactshort1 readOnlyShort=readonly001",
|
||||
);
|
||||
});
|
||||
|
||||
test("passes the selected account and exact extension ID into the live driver", () => {
|
||||
const context = createContext();
|
||||
const diagnostics = { checkpoint: jest.fn() };
|
||||
const page = createPage();
|
||||
const driver = createInteractiveLiveDriver({
|
||||
context,
|
||||
diagnostics,
|
||||
options: { expectedBuildId: EXPECTED_BUILD_ID, expectedChannel: "@Expected.Handle", runtime: "extension" },
|
||||
page,
|
||||
selectedExtensionId: SELECTED_EXTENSION_ID,
|
||||
});
|
||||
|
||||
expect(driver.authenticatedHandle).toBe("@expected.handle");
|
||||
expect(driver.expectedBuildId).toBe(EXPECTED_BUILD_ID);
|
||||
expect(driver.selectedExtensionId).toBe(SELECTED_EXTENSION_ID);
|
||||
expect(() => driver.configureRequestAttributionRuntime("extension")).not.toThrow();
|
||||
});
|
||||
|
||||
test("passes the same exact extension ID and selected page into every vote recorder", () => {
|
||||
const context = createContext();
|
||||
const page = createPage();
|
||||
const createRecorder = createInteractiveVoteRecorderFactory({
|
||||
context,
|
||||
driver: { trafficLedger: null },
|
||||
options: { runtime: "extension" },
|
||||
page,
|
||||
selectedExtensionId: SELECTED_EXTENSION_ID,
|
||||
});
|
||||
|
||||
const recorder = createRecorder("abcdefghijk");
|
||||
|
||||
expect(recorder.page).toBe(page);
|
||||
expect(recorder.runtime).toBe("extension");
|
||||
expect(recorder.selectedExtensionId).toBe(SELECTED_EXTENSION_ID);
|
||||
expect(recorder.videoId).toBe("abcdefghijk");
|
||||
recorder.stop();
|
||||
});
|
||||
|
||||
test("cannot create an extension vote recorder without the selected extension ID", () => {
|
||||
const createRecorder = createInteractiveVoteRecorderFactory({
|
||||
context: createContext(),
|
||||
driver: { trafficLedger: null },
|
||||
options: { runtime: "extension" },
|
||||
page: createPage(),
|
||||
selectedExtensionId: null,
|
||||
});
|
||||
|
||||
expect(() => createRecorder("abcdefghijk")).toThrow(/requires the exact selected extension ID/);
|
||||
});
|
||||
|
||||
test("keeps userscript recorders page-bound without inventing an extension identity", () => {
|
||||
const context = createContext();
|
||||
const page = createPage();
|
||||
const createRecorder = createInteractiveVoteRecorderFactory({
|
||||
context,
|
||||
driver: { trafficLedger: null },
|
||||
options: { runtime: "userscript" },
|
||||
page,
|
||||
selectedExtensionId: null,
|
||||
});
|
||||
|
||||
const recorder = createRecorder("abcdefghijk");
|
||||
|
||||
expect(recorder.page).toBe(page);
|
||||
expect(recorder.runtime).toBe("userscript");
|
||||
expect(recorder.selectedExtensionId).toBeNull();
|
||||
recorder.stop();
|
||||
});
|
||||
});
|
||||
|
||||
describe("interactive live attempt cleanup", () => {
|
||||
test("closes a live-attempt page within the configured bound", async () => {
|
||||
const page = {
|
||||
close: jest.fn().mockResolvedValue(undefined),
|
||||
isClosed: jest.fn(() => false),
|
||||
};
|
||||
|
||||
await expect(closeLiveAttemptPage(page, { timeoutMs: 50 })).resolves.toBe(true);
|
||||
expect(page.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("does not try to close an already-closed live-attempt page", async () => {
|
||||
const page = {
|
||||
close: jest.fn(),
|
||||
isClosed: jest.fn(() => true),
|
||||
};
|
||||
|
||||
await expect(closeLiveAttemptPage(page, { timeoutMs: 50 })).resolves.toBe(false);
|
||||
expect(page.close).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("rejects within the configured bound when page close never settles", async () => {
|
||||
const page = {
|
||||
close: jest.fn(() => new Promise(() => {})),
|
||||
isClosed: jest.fn(() => false),
|
||||
};
|
||||
|
||||
await expect(closeLiveAttemptPage(page, { timeoutMs: 10 })).rejects.toThrow(
|
||||
"Timed out after 10ms while closing the live-attempt test page.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("persistent interactive live session", () => {
|
||||
test("buffers multiple control lines on one stdin reader for the whole connected process", async () => {
|
||||
const input = new PassThrough();
|
||||
const reader = createInputLineReader(input);
|
||||
input.write("first\nRERUN\n");
|
||||
|
||||
await expect(reader.readLine()).resolves.toBe("first");
|
||||
await expect(reader.readLine()).resolves.toBe("RERUN");
|
||||
input.end();
|
||||
await expect(reader.readLine()).resolves.toBeNull();
|
||||
reader.close();
|
||||
});
|
||||
|
||||
test.each([
|
||||
[{ RYD_LIVE_RUNTIME: "extension" }, false],
|
||||
[{ RYD_LIVE_KEEP_CDP_SESSION: "", RYD_LIVE_RUNTIME: "extension" }, false],
|
||||
[{ RYD_LIVE_KEEP_CDP_SESSION: "1", RYD_LIVE_RUNTIME: "extension" }, true],
|
||||
])("reads the opt-in without changing default behavior", (environment, expected) => {
|
||||
expect(readPersistentSessionMode(environment)).toBe(expected);
|
||||
});
|
||||
|
||||
test.each(["true", "yes", "0", "2"])("rejects invalid persistent-session value %s", (configured) => {
|
||||
expect(() => readPersistentSessionMode({ RYD_LIVE_KEEP_CDP_SESSION: configured })).toThrow(/must be 1 or unset/);
|
||||
});
|
||||
|
||||
test.each([
|
||||
["RERUN", "RERUN"],
|
||||
[" rerun ", "RERUN"],
|
||||
["exit", "EXIT"],
|
||||
[null, "EXIT"],
|
||||
["", null],
|
||||
["again", null],
|
||||
])("normalizes persistent-session command %p", (value, expected) => {
|
||||
expect(normalizeLiveSessionCommand(value)).toBe(expected);
|
||||
});
|
||||
|
||||
test("keeps one browser connection across a failed attempt and a complete rerun", async () => {
|
||||
const firstOptions = createLiveOptions();
|
||||
const secondOptions = createLiveOptions({ expectedBuildId: "fedcba9876543210fedcba9876543210" });
|
||||
const browser = { close: jest.fn().mockResolvedValue(undefined) };
|
||||
const selectedSession = { context: {}, contextIndex: 0, pageIndex: 1, sessionPage: {} };
|
||||
const buildArtifact = jest
|
||||
.fn()
|
||||
.mockReturnValueOnce({ runtime: "extension", script: "build:live:extension", attempt: 1 })
|
||||
.mockReturnValueOnce({ runtime: "extension", script: "build:live:extension", attempt: 2 });
|
||||
const connectBrowser = jest.fn().mockResolvedValue(browser);
|
||||
const executeAttempt = jest
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error("first live attempt failed"))
|
||||
.mockResolvedValueOnce({ classification: "full", attempt: 2 });
|
||||
const output = createOutput();
|
||||
const preserveEvidence = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce("attempt-01-failed")
|
||||
.mockResolvedValueOnce("attempt-02-passed");
|
||||
const resetEvidenceWorkspace = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ removedEntries: ["stale-first"] })
|
||||
.mockResolvedValueOnce({ removedEntries: ["stale-second"] });
|
||||
const readLine = jest.fn().mockResolvedValueOnce("RERUN").mockResolvedValueOnce("EXIT");
|
||||
const readOptions = jest.fn().mockReturnValueOnce(firstOptions).mockReturnValueOnce(secondOptions);
|
||||
const selectContext = jest.fn().mockResolvedValue(selectedSession);
|
||||
|
||||
await expect(
|
||||
main({
|
||||
buildArtifact,
|
||||
connectBrowser,
|
||||
environment: { RYD_LIVE_KEEP_CDP_SESSION: "1", RYD_LIVE_RUNTIME: "extension" },
|
||||
executeAttempt,
|
||||
output,
|
||||
preserveEvidence,
|
||||
readLine,
|
||||
readOptions,
|
||||
resetEvidenceWorkspace,
|
||||
selectContext,
|
||||
}),
|
||||
).resolves.toEqual({ classification: "full", attempt: 2 });
|
||||
|
||||
expect(buildArtifact).toHaveBeenCalledTimes(2);
|
||||
expect(readOptions).toHaveBeenCalledTimes(2);
|
||||
expect(connectBrowser).toHaveBeenCalledTimes(1);
|
||||
expect(selectContext).toHaveBeenCalledTimes(1);
|
||||
expect(executeAttempt).toHaveBeenCalledTimes(2);
|
||||
expect(resetEvidenceWorkspace).toHaveBeenNthCalledWith(1, { attemptNumber: 1 });
|
||||
expect(resetEvidenceWorkspace).toHaveBeenNthCalledWith(2, { attemptNumber: 2 });
|
||||
expect(resetEvidenceWorkspace.mock.invocationCallOrder[0]).toBeLessThan(executeAttempt.mock.invocationCallOrder[0]);
|
||||
expect(preserveEvidence.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
resetEvidenceWorkspace.mock.invocationCallOrder[1],
|
||||
);
|
||||
expect(resetEvidenceWorkspace.mock.invocationCallOrder[1]).toBeLessThan(executeAttempt.mock.invocationCallOrder[1]);
|
||||
expect(executeAttempt.mock.calls[0][0]).toMatchObject({ browser, options: firstOptions, selectedSession });
|
||||
expect(executeAttempt.mock.calls[1][0]).toMatchObject({ browser, options: secondOptions, selectedSession });
|
||||
expect(preserveEvidence).toHaveBeenNthCalledWith(1, { attemptNumber: 1, outcome: "failed" });
|
||||
expect(preserveEvidence).toHaveBeenNthCalledWith(2, { attemptNumber: 2, outcome: "passed" });
|
||||
expect(output.log).toHaveBeenCalledWith("READY_FOR_LIVE_RERUN_OR_EXIT attempt=1 outcome=failed");
|
||||
expect(output.log).toHaveBeenCalledWith("READY_FOR_LIVE_RERUN_OR_EXIT attempt=2 outcome=passed");
|
||||
expect(browser.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("retains normal one-attempt exit and failure semantics when persistence is unset", async () => {
|
||||
const options = createLiveOptions();
|
||||
const browser = { close: jest.fn().mockResolvedValue(undefined) };
|
||||
const failure = new Error("strict live failure");
|
||||
const executeAttempt = jest.fn().mockRejectedValue(failure);
|
||||
const preserveEvidence = jest.fn();
|
||||
const readLine = jest.fn();
|
||||
|
||||
await expect(
|
||||
main({
|
||||
buildArtifact: jest.fn(() => ({ runtime: "extension" })),
|
||||
connectBrowser: jest.fn().mockResolvedValue(browser),
|
||||
environment: { RYD_LIVE_RUNTIME: "extension" },
|
||||
executeAttempt,
|
||||
output: createOutput(),
|
||||
preserveEvidence,
|
||||
readLine,
|
||||
readOptions: jest.fn(() => options),
|
||||
resetEvidenceWorkspace: jest.fn().mockResolvedValue({ removedEntries: [] }),
|
||||
selectContext: jest.fn(async () => ({ context: {}, contextIndex: 0, pageIndex: 0, sessionPage: {} })),
|
||||
}),
|
||||
).rejects.toBe(failure);
|
||||
|
||||
expect(executeAttempt).toHaveBeenCalledTimes(1);
|
||||
expect(preserveEvidence).not.toHaveBeenCalled();
|
||||
expect(readLine).not.toHaveBeenCalled();
|
||||
expect(browser.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("returns the latest failed attempt as a failure when a persistent session exits", async () => {
|
||||
const options = createLiveOptions();
|
||||
const browser = { close: jest.fn().mockResolvedValue(undefined) };
|
||||
const failure = new Error("persistent live failure");
|
||||
|
||||
await expect(
|
||||
main({
|
||||
buildArtifact: jest.fn(() => ({ runtime: "extension" })),
|
||||
connectBrowser: jest.fn().mockResolvedValue(browser),
|
||||
environment: { RYD_LIVE_KEEP_CDP_SESSION: "1", RYD_LIVE_RUNTIME: "extension" },
|
||||
executeAttempt: jest.fn().mockRejectedValue(failure),
|
||||
output: createOutput(),
|
||||
preserveEvidence: jest.fn().mockResolvedValue("attempt-01-failed"),
|
||||
readLine: jest.fn().mockResolvedValue("EXIT"),
|
||||
readOptions: jest.fn(() => options),
|
||||
resetEvidenceWorkspace: jest.fn().mockResolvedValue({ removedEntries: [] }),
|
||||
selectContext: jest.fn(async () => ({ context: {}, contextIndex: 0, pageIndex: 0, sessionPage: {} })),
|
||||
}),
|
||||
).rejects.toBe(failure);
|
||||
|
||||
expect(browser.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("keeps the connection open while rejecting unknown rerun commands", async () => {
|
||||
const options = createLiveOptions();
|
||||
const browser = { close: jest.fn().mockResolvedValue(undefined) };
|
||||
const output = createOutput();
|
||||
const executeAttempt = jest.fn().mockResolvedValue({ classification: "full" });
|
||||
const readLine = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce("not-a-command")
|
||||
.mockResolvedValueOnce("RERUN")
|
||||
.mockResolvedValueOnce("EXIT");
|
||||
|
||||
await main({
|
||||
buildArtifact: jest.fn(() => ({ runtime: "extension" })),
|
||||
connectBrowser: jest.fn().mockResolvedValue(browser),
|
||||
environment: { RYD_LIVE_KEEP_CDP_SESSION: "1", RYD_LIVE_RUNTIME: "extension" },
|
||||
executeAttempt,
|
||||
output,
|
||||
preserveEvidence: jest.fn().mockResolvedValue("evidence"),
|
||||
readLine,
|
||||
readOptions: jest.fn(() => options),
|
||||
resetEvidenceWorkspace: jest.fn().mockResolvedValue({ removedEntries: [] }),
|
||||
selectContext: jest.fn(async () => ({ context: {}, contextIndex: 0, pageIndex: 0, sessionPage: {} })),
|
||||
});
|
||||
|
||||
expect(output.error).toHaveBeenCalledWith("LIVE_RERUN_COMMAND_REJECTED expected=RERUN_OR_EXIT");
|
||||
expect(executeAttempt).toHaveBeenCalledTimes(2);
|
||||
expect(browser.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("fails closed if a rerun tries to switch endpoint, account, or runtime", () => {
|
||||
const initial = createLiveOptions();
|
||||
expect(() => assertReusableConnectionOptions(initial, { ...initial, cdpEndpoint: "ws://other" })).toThrow(
|
||||
/cdpEndpoint changed/,
|
||||
);
|
||||
expect(() => assertReusableConnectionOptions(initial, { ...initial, expectedChannel: "@other" })).toThrow(
|
||||
/expectedChannel changed/,
|
||||
);
|
||||
expect(() => assertReusableConnectionOptions(initial, { ...initial, runtime: "userscript" })).toThrow(
|
||||
/runtime changed/,
|
||||
);
|
||||
expect(assertReusableConnectionOptions(initial, { ...initial, expectedBuildId: "f".repeat(32) })).toMatchObject({
|
||||
expectedBuildId: "f".repeat(32),
|
||||
});
|
||||
});
|
||||
|
||||
test("copies each persistent attempt's evidence without recursively copying prior attempt archives", () => {
|
||||
const evidenceDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "ryd-live-attempt-"));
|
||||
try {
|
||||
fs.mkdirSync(path.join(evidenceDirectory, "responsive"), { recursive: true });
|
||||
fs.writeFileSync(path.join(evidenceDirectory, "responsive", "watch.png"), "image");
|
||||
fs.mkdirSync(path.join(evidenceDirectory, "attempts", "old"), { recursive: true });
|
||||
fs.writeFileSync(path.join(evidenceDirectory, "attempts", "old", "old.json"), "{}");
|
||||
const fixedDate = new Date("2026-09-04T12:00:00.000Z");
|
||||
|
||||
const archived = preserveLiveAttemptEvidence(
|
||||
{ attemptNumber: 3, outcome: "failed" },
|
||||
{ evidenceDirectory, now: () => fixedDate },
|
||||
);
|
||||
|
||||
expect(archived).toBe(path.join(evidenceDirectory, "attempts", "03-2026-09-04T12-00-00-000Z-failed"));
|
||||
expect(fs.readFileSync(path.join(archived, "responsive", "watch.png"), "utf8")).toBe("image");
|
||||
expect(fs.existsSync(path.join(archived, "attempts", "old", "old.json"))).toBe(false);
|
||||
expect(JSON.parse(fs.readFileSync(path.join(archived, "attempt.json"), "utf8"))).toEqual({
|
||||
attemptNumber: 3,
|
||||
capturedAt: fixedDate.toISOString(),
|
||||
outcome: "failed",
|
||||
});
|
||||
} finally {
|
||||
fs.rmSync(evidenceDirectory, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("removes only the active attempt workspace while preserving every prior attempt archive", () => {
|
||||
const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ryd-live-reset-"));
|
||||
const evidenceDirectory = path.join(temporaryRoot, "live-youtube");
|
||||
try {
|
||||
fs.mkdirSync(path.join(evidenceDirectory, "responsive"), { recursive: true });
|
||||
fs.writeFileSync(path.join(evidenceDirectory, "responsive", "stale-watch.png"), "stale image");
|
||||
fs.writeFileSync(path.join(evidenceDirectory, "manual-persistent.png"), "stale manual image");
|
||||
fs.mkdirSync(path.join(evidenceDirectory, "Attempts", "01-passed"), { recursive: true });
|
||||
fs.writeFileSync(path.join(evidenceDirectory, "Attempts", "01-passed", "attempt.json"), "{}");
|
||||
|
||||
const reset = resetLiveAttemptEvidenceWorkspace(
|
||||
{ attemptNumber: 2 },
|
||||
{ allowedParentDirectory: temporaryRoot, evidenceDirectory },
|
||||
);
|
||||
|
||||
expect(reset).toEqual({
|
||||
attemptNumber: 2,
|
||||
evidenceDirectory: fs.realpathSync.native(evidenceDirectory),
|
||||
removedEntries: ["manual-persistent.png", "responsive"],
|
||||
});
|
||||
expect(fs.readdirSync(evidenceDirectory).map((entry) => entry.toLowerCase())).toEqual(["attempts"]);
|
||||
expect(fs.readFileSync(path.join(evidenceDirectory, "Attempts", "01-passed", "attempt.json"), "utf8")).toBe("{}");
|
||||
} finally {
|
||||
fs.rmSync(temporaryRoot, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("fails closed for filesystem roots, the repository root, and broader evidence parents", () => {
|
||||
const repositoryDirectory = path.resolve(__dirname, "../../..");
|
||||
const filesystemRoot = path.parse(repositoryDirectory).root;
|
||||
|
||||
expect(() => resolveSafeLiveEvidenceDirectory(filesystemRoot)).toThrow(/unsafe live evidence directory/);
|
||||
expect(() => resolveSafeLiveEvidenceDirectory(repositoryDirectory)).toThrow(/unsafe live evidence directory/);
|
||||
expect(() => resolveSafeLiveEvidenceDirectory(path.join(repositoryDirectory, "test-results"))).toThrow(
|
||||
/unsafe live evidence directory/,
|
||||
);
|
||||
});
|
||||
|
||||
test("refuses an unrelated directory merely named live-youtube without deleting its contents", () => {
|
||||
const unrelatedParent = fs.mkdtempSync(path.join(os.tmpdir(), "unrelated-live-evidence-"));
|
||||
const unrelatedEvidence = path.join(unrelatedParent, "live-youtube");
|
||||
const protectedFile = path.join(unrelatedEvidence, "keep.txt");
|
||||
try {
|
||||
fs.mkdirSync(unrelatedEvidence, { recursive: true });
|
||||
fs.writeFileSync(protectedFile, "keep");
|
||||
|
||||
expect(() =>
|
||||
resetLiveAttemptEvidenceWorkspace({ attemptNumber: 1 }, { evidenceDirectory: unrelatedEvidence }),
|
||||
).toThrow(/unsafe live evidence directory/);
|
||||
expect(fs.readFileSync(protectedFile, "utf8")).toBe("keep");
|
||||
} finally {
|
||||
fs.rmSync(unrelatedParent, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
const DEFAULT_LIVE_SHORTS_NEXT_HOPS = 10;
|
||||
const MIN_LIVE_SHORTS_NEXT_HOPS = 10;
|
||||
const MAX_LIVE_SHORTS_NEXT_HOPS = 25;
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_LIVE_SHORTS_NEXT_HOPS,
|
||||
MAX_LIVE_SHORTS_NEXT_HOPS,
|
||||
MIN_LIVE_SHORTS_NEXT_HOPS,
|
||||
};
|
||||
@@ -3,11 +3,21 @@ const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const extensionPackage = require("../../../package.json");
|
||||
const userscriptVersion = require("../userscript-version.json");
|
||||
const { assertCurrentLiveArtifact } = require("../e2e/live/live-artifact-readiness");
|
||||
const { resolveCdpEndpoint } = require("./live-cdp-endpoint");
|
||||
const {
|
||||
DEFAULT_LIVE_SHORTS_NEXT_HOPS,
|
||||
MAX_LIVE_SHORTS_NEXT_HOPS,
|
||||
MIN_LIVE_SHORTS_NEXT_HOPS,
|
||||
} = require("./live-navigation-constants");
|
||||
|
||||
const LIVE_VOTE_APPROVAL_WINDOW_SECONDS = 120;
|
||||
const LIVE_VOTE_APPROVALS_DIRECTORY = path.resolve(__dirname, "../../../test-results/live-youtube-vote-approvals");
|
||||
const DEFAULT_LIVE_NAV_CHANNEL_URL = "https://www.youtube.com/@SmashTrash";
|
||||
const DEFAULT_LIVE_NAV_SHORT = "iKQhN7omLM4";
|
||||
const DEFAULT_LIVE_CDP_CONNECT_TIMEOUT_MILLISECONDS = 120_000;
|
||||
const MIN_LIVE_CDP_CONNECT_TIMEOUT_MILLISECONDS = 15_000;
|
||||
const MAX_LIVE_CDP_CONNECT_TIMEOUT_MILLISECONDS = 300_000;
|
||||
const DEFAULT_LIVE_NAV_CHANNEL_URL = "https://www.youtube.com/@MrBeast";
|
||||
const DEFAULT_LIVE_NAV_SHORT = "5mU6SRS2Bxo";
|
||||
const DEFAULT_LIVE_SIDEBAR_HOPS = 3;
|
||||
const MAX_LIVE_SIDEBAR_HOPS = 10;
|
||||
const VIDEO_ID_PATTERN = /^[A-Za-z0-9_-]{11}$/;
|
||||
@@ -32,25 +42,16 @@ function requireVideoId(environment, name) {
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalVideoId(environment, name) {
|
||||
const value = environment[name]?.trim();
|
||||
if (!value) return null;
|
||||
if (!VIDEO_ID_PATTERN.test(value)) {
|
||||
throw new Error(`${name} must be an 11-character YouTube video ID when provided.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalBoundedInteger(environment, name, defaultValue, maximum) {
|
||||
function optionalBoundedInteger(environment, name, defaultValue, maximum, minimum = 1) {
|
||||
const rawValue = environment[name]?.trim();
|
||||
if (!rawValue) return defaultValue;
|
||||
if (!/^\d+$/.test(rawValue)) {
|
||||
throw new Error(`${name} must be a whole number from 1 to ${maximum}.`);
|
||||
throw new Error(`${name} must be a whole number from ${minimum} to ${maximum}.`);
|
||||
}
|
||||
|
||||
const value = Number(rawValue);
|
||||
if (!Number.isSafeInteger(value) || value < 1 || value > maximum) {
|
||||
throw new Error(`${name} must be a whole number from 1 to ${maximum}.`);
|
||||
if (!Number.isSafeInteger(value) || value < minimum || value > maximum) {
|
||||
throw new Error(`${name} must be a whole number from ${minimum} to ${maximum}.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
@@ -149,7 +150,14 @@ function parseChannelUrl(value) {
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function readExpectedBuildId(runtime, { markerPaths = LIVE_BUILD_MARKER_PATHS, readFileSync = fs.readFileSync } = {}) {
|
||||
function readExpectedBuildId(
|
||||
runtime,
|
||||
{
|
||||
assertArtifactCurrent = assertCurrentLiveArtifact,
|
||||
markerPaths = LIVE_BUILD_MARKER_PATHS,
|
||||
readFileSync = fs.readFileSync,
|
||||
} = {},
|
||||
) {
|
||||
const markerPath = markerPaths[runtime];
|
||||
let marker;
|
||||
try {
|
||||
@@ -163,13 +171,14 @@ function readExpectedBuildId(runtime, { markerPaths = LIVE_BUILD_MARKER_PATHS, r
|
||||
if (!LIVE_BUILD_ID_PATTERN.test(marker?.buildId)) {
|
||||
throw new Error(`The ${runtime} live-build marker at ${markerPath} is malformed.`);
|
||||
}
|
||||
assertArtifactCurrent(runtime, marker.buildId);
|
||||
return marker.buildId;
|
||||
}
|
||||
|
||||
function readLiveOptions(
|
||||
environment = process.env,
|
||||
nowMilliseconds = Date.now(),
|
||||
{ readBuildId = readExpectedBuildId } = {},
|
||||
{ readBuildId = readExpectedBuildId, resolveEndpoint = resolveCdpEndpoint } = {},
|
||||
) {
|
||||
if (environment.RYD_LIVE_YOUTUBE !== "1") return null;
|
||||
|
||||
@@ -187,6 +196,9 @@ function readLiveOptions(
|
||||
const watchA = requireVideoId(environment, "RYD_LIVE_WATCH_A");
|
||||
const watchB = requireVideoId(environment, "RYD_LIVE_WATCH_B");
|
||||
const short = requireVideoId(environment, "RYD_LIVE_SHORT");
|
||||
const reactionShort = environment.RYD_LIVE_REACTION_SHORT?.trim()
|
||||
? requireVideoId(environment, "RYD_LIVE_REACTION_SHORT")
|
||||
: short;
|
||||
if (watchA === watchB) throw new Error("RYD_LIVE_WATCH_A and RYD_LIVE_WATCH_B must be different videos.");
|
||||
|
||||
const expectedVersion =
|
||||
@@ -194,7 +206,14 @@ function readLiveOptions(
|
||||
(runtime === "userscript" ? userscriptVersion : extensionPackage.version);
|
||||
|
||||
return {
|
||||
cdpEndpoint: environment.RYD_CDP_ENDPOINT?.trim() || "chrome",
|
||||
cdpEndpoint: resolveEndpoint(environment.RYD_CDP_ENDPOINT?.trim() || "chrome", { environment }),
|
||||
cdpConnectTimeoutMilliseconds: optionalBoundedInteger(
|
||||
environment,
|
||||
"RYD_CDP_CONNECT_TIMEOUT_MS",
|
||||
DEFAULT_LIVE_CDP_CONNECT_TIMEOUT_MILLISECONDS,
|
||||
MAX_LIVE_CDP_CONNECT_TIMEOUT_MILLISECONDS,
|
||||
MIN_LIVE_CDP_CONNECT_TIMEOUT_MILLISECONDS,
|
||||
),
|
||||
expectedBuildId: readBuildId(runtime),
|
||||
expectedChannel: requireChannelHandle(environment),
|
||||
expectedVersion,
|
||||
@@ -204,10 +223,18 @@ function readLiveOptions(
|
||||
{ RYD_LIVE_NAV_SHORT: environment.RYD_LIVE_NAV_SHORT?.trim() || DEFAULT_LIVE_NAV_SHORT },
|
||||
"RYD_LIVE_NAV_SHORT",
|
||||
),
|
||||
watch: optionalVideoId(environment, "RYD_LIVE_NAV_WATCH"),
|
||||
shortsNextHops: optionalBoundedInteger(
|
||||
environment,
|
||||
"RYD_LIVE_SHORTS_NEXT_HOPS",
|
||||
DEFAULT_LIVE_SHORTS_NEXT_HOPS,
|
||||
MAX_LIVE_SHORTS_NEXT_HOPS,
|
||||
MIN_LIVE_SHORTS_NEXT_HOPS,
|
||||
),
|
||||
watch: requireVideoId(environment, "RYD_LIVE_NAV_WATCH"),
|
||||
},
|
||||
playlistUrl: parsePlaylistUrl(requireValue(environment, "RYD_LIVE_PLAYLIST_URL"), watchA),
|
||||
productionApiApproved: true,
|
||||
reactionShort,
|
||||
runtime,
|
||||
sidebar: {
|
||||
hopCount: optionalBoundedInteger(
|
||||
@@ -227,8 +254,12 @@ module.exports = {
|
||||
DEFAULT_LIVE_NAV_CHANNEL_URL,
|
||||
DEFAULT_LIVE_NAV_SHORT,
|
||||
DEFAULT_LIVE_SIDEBAR_HOPS,
|
||||
DEFAULT_LIVE_SHORTS_NEXT_HOPS,
|
||||
DEFAULT_LIVE_CDP_CONNECT_TIMEOUT_MILLISECONDS,
|
||||
LIVE_VOTE_APPROVALS_DIRECTORY,
|
||||
LIVE_VOTE_APPROVAL_WINDOW_SECONDS,
|
||||
MAX_LIVE_SHORTS_NEXT_HOPS,
|
||||
MIN_LIVE_SHORTS_NEXT_HOPS,
|
||||
consumeLiveVoteApproval,
|
||||
hasFreshVoteApproval,
|
||||
liveVoteApproval,
|
||||
|
||||
@@ -5,6 +5,8 @@ const {
|
||||
DEFAULT_LIVE_NAV_CHANNEL_URL,
|
||||
DEFAULT_LIVE_NAV_SHORT,
|
||||
DEFAULT_LIVE_SIDEBAR_HOPS,
|
||||
DEFAULT_LIVE_SHORTS_NEXT_HOPS,
|
||||
DEFAULT_LIVE_CDP_CONNECT_TIMEOUT_MILLISECONDS,
|
||||
LIVE_VOTE_APPROVALS_DIRECTORY,
|
||||
LIVE_VOTE_APPROVAL_WINDOW_SECONDS,
|
||||
consumeLiveVoteApproval,
|
||||
@@ -24,6 +26,7 @@ const VALID_ENVIRONMENT = {
|
||||
RYD_LIVE_WATCH_A: "abcdefghijk",
|
||||
RYD_LIVE_WATCH_B: "zyxwvutsrqp",
|
||||
RYD_LIVE_SHORT: "shortsabcde",
|
||||
RYD_LIVE_NAV_WATCH: "navwatch001",
|
||||
RYD_LIVE_PLAYLIST_URL: "https://www.youtube.com/watch?v=abcdefghijk&list=PL-test",
|
||||
RYD_LIVE_EXPECTED_CHANNEL: "@ryd-test",
|
||||
};
|
||||
@@ -31,20 +34,24 @@ const VALID_ENVIRONMENT = {
|
||||
function readLiveOptions(environment, nowMilliseconds = NOW) {
|
||||
return readLiveOptionsFromEnvironment(environment, nowMilliseconds, {
|
||||
readBuildId: () => EXPECTED_BUILD_ID,
|
||||
resolveEndpoint: (value) => value,
|
||||
});
|
||||
}
|
||||
|
||||
describe("live YouTube options", () => {
|
||||
test("reads the exact generated build ID from the selected runtime marker", () => {
|
||||
const readFileSync = jest.fn(() => JSON.stringify({ buildId: EXPECTED_BUILD_ID }));
|
||||
const assertArtifactCurrent = jest.fn();
|
||||
|
||||
expect(
|
||||
readExpectedBuildId("userscript", {
|
||||
assertArtifactCurrent,
|
||||
markerPaths: { userscript: "owned-live-build.json" },
|
||||
readFileSync,
|
||||
}),
|
||||
).toBe(EXPECTED_BUILD_ID);
|
||||
expect(readFileSync).toHaveBeenCalledWith("owned-live-build.json", "utf8");
|
||||
expect(assertArtifactCurrent).toHaveBeenCalledWith("userscript", EXPECTED_BUILD_ID);
|
||||
});
|
||||
|
||||
test.each([
|
||||
@@ -60,12 +67,29 @@ describe("live YouTube options", () => {
|
||||
])("rejects a %s generated live-build marker", (_label, readFileSync, expectedMessage) => {
|
||||
expect(() =>
|
||||
readExpectedBuildId("extension", {
|
||||
assertArtifactCurrent: jest.fn(),
|
||||
markerPaths: { extension: "owned-live-build.json" },
|
||||
readFileSync,
|
||||
}),
|
||||
).toThrow(expectedMessage);
|
||||
});
|
||||
|
||||
test("rejects a live marker when the corresponding artifact receipt is stale", () => {
|
||||
const staleArtifact = new Error("Userscript build inputs changed after the artifact was generated.");
|
||||
const assertArtifactCurrent = jest.fn(() => {
|
||||
throw staleArtifact;
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
readExpectedBuildId("userscript", {
|
||||
assertArtifactCurrent,
|
||||
markerPaths: { userscript: "owned-live-build.json" },
|
||||
readFileSync: () => JSON.stringify({ buildId: EXPECTED_BUILD_ID }),
|
||||
}),
|
||||
).toThrow(staleArtifact);
|
||||
expect(assertArtifactCurrent).toHaveBeenCalledWith("userscript", EXPECTED_BUILD_ID);
|
||||
});
|
||||
|
||||
test("stores consumed vote approvals outside Playwright's cleaned output directory", () => {
|
||||
const playwrightOutputDirectory = path.resolve(__dirname, "../../../test-results/live-youtube");
|
||||
expect(LIVE_VOTE_APPROVALS_DIRECTORY).not.toBe(playwrightOutputDirectory);
|
||||
@@ -76,6 +100,11 @@ describe("live YouTube options", () => {
|
||||
expect(readLiveOptions({})).toBeNull();
|
||||
});
|
||||
|
||||
test("defaults to the currently verified high-volume channel navigation fixture", () => {
|
||||
expect(DEFAULT_LIVE_NAV_CHANNEL_URL).toBe("https://www.youtube.com/@MrBeast");
|
||||
expect(DEFAULT_LIVE_NAV_SHORT).toBe("5mU6SRS2Bxo");
|
||||
});
|
||||
|
||||
test("requires production API acknowledgement", () => {
|
||||
expect(() => readLiveOptions({ ...VALID_ENVIRONMENT, RYD_LIVE_PRODUCTION_API: undefined })).toThrow(
|
||||
"RYD_LIVE_PRODUCTION_API=1",
|
||||
@@ -116,12 +145,15 @@ describe("live YouTube options", () => {
|
||||
test.each(["userscript", "extension"])("accepts the %s runtime", (runtime) => {
|
||||
expect(readLiveOptions({ ...VALID_ENVIRONMENT, RYD_LIVE_RUNTIME: runtime })).toMatchObject({
|
||||
cdpEndpoint: "chrome",
|
||||
cdpConnectTimeoutMilliseconds: DEFAULT_LIVE_CDP_CONNECT_TIMEOUT_MILLISECONDS,
|
||||
expectedBuildId: EXPECTED_BUILD_ID,
|
||||
navigation: {
|
||||
channelUrl: DEFAULT_LIVE_NAV_CHANNEL_URL,
|
||||
short: DEFAULT_LIVE_NAV_SHORT,
|
||||
watch: null,
|
||||
shortsNextHops: DEFAULT_LIVE_SHORTS_NEXT_HOPS,
|
||||
watch: "navwatch001",
|
||||
},
|
||||
reactionShort: "shortsabcde",
|
||||
runtime,
|
||||
sidebar: { hopCount: DEFAULT_LIVE_SIDEBAR_HOPS },
|
||||
short: "shortsabcde",
|
||||
@@ -135,19 +167,68 @@ describe("live YouTube options", () => {
|
||||
const result = readLiveOptionsFromEnvironment(
|
||||
{ ...VALID_ENVIRONMENT, RYD_LIVE_EXPECTED_BUILD_ID: "f".repeat(32) },
|
||||
NOW,
|
||||
{ readBuildId },
|
||||
{ readBuildId, resolveEndpoint: (value) => value },
|
||||
);
|
||||
|
||||
expect(readBuildId).toHaveBeenCalledWith("userscript");
|
||||
expect(result.expectedBuildId).toBe(EXPECTED_BUILD_ID);
|
||||
});
|
||||
|
||||
test("resolves the configured browser endpoint with the same environment used for live options", () => {
|
||||
const resolveEndpoint = jest.fn(() => "ws://127.0.0.1:60011/devtools/browser/session");
|
||||
const environment = { ...VALID_ENVIRONMENT, RYD_CDP_ENDPOINT: " 127.0.0.1:60011 " };
|
||||
|
||||
const result = readLiveOptionsFromEnvironment(environment, NOW, {
|
||||
readBuildId: () => EXPECTED_BUILD_ID,
|
||||
resolveEndpoint,
|
||||
});
|
||||
|
||||
expect(resolveEndpoint).toHaveBeenCalledWith("127.0.0.1:60011", { environment });
|
||||
expect(result.cdpEndpoint).toBe("ws://127.0.0.1:60011/devtools/browser/session");
|
||||
});
|
||||
|
||||
test("accepts a bounded remote-debugging approval timeout", () => {
|
||||
expect(readLiveOptions({ ...VALID_ENVIRONMENT, RYD_CDP_CONNECT_TIMEOUT_MS: "180000" })).toMatchObject({
|
||||
cdpConnectTimeoutMilliseconds: 180_000,
|
||||
});
|
||||
});
|
||||
|
||||
test.each(["0", "14999", "300001", "1.5", "two minutes", "-1"])(
|
||||
"rejects invalid remote-debugging approval timeout %p",
|
||||
(timeout) => {
|
||||
expect(() => readLiveOptions({ ...VALID_ENVIRONMENT, RYD_CDP_CONNECT_TIMEOUT_MS: timeout })).toThrow(
|
||||
/RYD_CDP_CONNECT_TIMEOUT_MS.*whole number from 15000 to 300000/,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test("accepts a bounded sidebar stress hop count", () => {
|
||||
expect(readLiveOptions({ ...VALID_ENVIRONMENT, RYD_LIVE_SIDEBAR_HOPS: "5" })).toMatchObject({
|
||||
sidebar: { hopCount: 5 },
|
||||
});
|
||||
});
|
||||
|
||||
test("defaults the live Shorts stress to ten successful Next samples", () => {
|
||||
expect(readLiveOptions(VALID_ENVIRONMENT)).toMatchObject({
|
||||
navigation: { shortsNextHops: 10 },
|
||||
});
|
||||
});
|
||||
|
||||
test("accepts a larger bounded successful live Shorts Next count", () => {
|
||||
expect(readLiveOptions({ ...VALID_ENVIRONMENT, RYD_LIVE_SHORTS_NEXT_HOPS: "15" })).toMatchObject({
|
||||
navigation: { shortsNextHops: 15 },
|
||||
});
|
||||
});
|
||||
|
||||
test.each(["0", "1", "9", "26", "10.5", "three", "-1"])(
|
||||
"rejects live Shorts Next count below ten or outside the bound: %p",
|
||||
(hopCount) => {
|
||||
expect(() => readLiveOptions({ ...VALID_ENVIRONMENT, RYD_LIVE_SHORTS_NEXT_HOPS: hopCount })).toThrow(
|
||||
/RYD_LIVE_SHORTS_NEXT_HOPS.*whole number from 10 to 25/,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test.each(["0", "11", "1.5", "three", "-1"])("rejects invalid sidebar stress hop count %p", (hopCount) => {
|
||||
expect(() => readLiveOptions({ ...VALID_ENVIRONMENT, RYD_LIVE_SIDEBAR_HOPS: hopCount })).toThrow(
|
||||
/RYD_LIVE_SIDEBAR_HOPS.*whole number from 1 to 10/,
|
||||
@@ -171,6 +252,19 @@ describe("live YouTube options", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("accepts a dedicated reaction Short without changing the read-only Short", () => {
|
||||
expect(readLiveOptions({ ...VALID_ENVIRONMENT, RYD_LIVE_REACTION_SHORT: "reactshort1" })).toMatchObject({
|
||||
reactionShort: "reactshort1",
|
||||
short: "shortsabcde",
|
||||
});
|
||||
});
|
||||
|
||||
test.each(["invalid", "abcdefghij!", "abcdefghijkl"])("rejects malformed reaction Short ID %p", (videoId) => {
|
||||
expect(() => readLiveOptions({ ...VALID_ENVIRONMENT, RYD_LIVE_REACTION_SHORT: videoId })).toThrow(
|
||||
"RYD_LIVE_REACTION_SHORT must be an 11-character YouTube video ID",
|
||||
);
|
||||
});
|
||||
|
||||
test.each([
|
||||
"http://www.youtube.com/@SmashTrash",
|
||||
"https://example.com/@SmashTrash",
|
||||
@@ -184,7 +278,13 @@ describe("live YouTube options", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test.each(["RYD_LIVE_NAV_SHORT", "RYD_LIVE_NAV_WATCH"])("validates optional navigation ID %s", (name) => {
|
||||
test("requires an exact channel Watch target", () => {
|
||||
expect(() => readLiveOptions({ ...VALID_ENVIRONMENT, RYD_LIVE_NAV_WATCH: undefined })).toThrow(
|
||||
"RYD_LIVE_NAV_WATCH is required",
|
||||
);
|
||||
});
|
||||
|
||||
test.each(["RYD_LIVE_NAV_SHORT", "RYD_LIVE_NAV_WATCH"])("validates navigation ID %s", (name) => {
|
||||
expect(() => readLiveOptions({ ...VALID_ENVIRONMENT, [name]: "invalid" })).toThrow("11-character YouTube video ID");
|
||||
});
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ class FakeBrowserContext {
|
||||
this.routes = [];
|
||||
this.routeCalls = 0;
|
||||
this.unrouteCalls = 0;
|
||||
this.unrouteImplementation = null;
|
||||
}
|
||||
|
||||
on(event, handler) {
|
||||
@@ -25,6 +26,7 @@ class FakeBrowserContext {
|
||||
async unroute(matcher, handler) {
|
||||
this.unrouteCalls += 1;
|
||||
this.routes = this.routes.filter((route) => route.matcher !== matcher || route.handler !== handler);
|
||||
if (this.unrouteImplementation) await this.unrouteImplementation();
|
||||
}
|
||||
|
||||
async dispatch(request) {
|
||||
@@ -45,12 +47,12 @@ class FakeBrowserContext {
|
||||
}
|
||||
}
|
||||
|
||||
function createDriver(context) {
|
||||
function createDriver(context, options = {}) {
|
||||
const page = {
|
||||
setDefaultNavigationTimeout: jest.fn(),
|
||||
setDefaultTimeout: jest.fn(),
|
||||
};
|
||||
return new LiveYoutubeDriver(page, context);
|
||||
return new LiveYoutubeDriver(page, context, options);
|
||||
}
|
||||
|
||||
function request(method, url) {
|
||||
@@ -116,4 +118,59 @@ describe("live read-only production-interaction guard", () => {
|
||||
expect(context.unrouteCalls).toBe(1);
|
||||
expect(context.routes).toEqual([]);
|
||||
});
|
||||
|
||||
test("bounds a stalled route removal, clears the guard, and allows a fresh guard", async () => {
|
||||
const context = new FakeBrowserContext();
|
||||
const reportProgress = jest.fn();
|
||||
const driver = createDriver(context, {
|
||||
readOnlyInteractionGuardCleanupTimeout: 10,
|
||||
reportProgress,
|
||||
});
|
||||
context.unrouteImplementation = () => new Promise(() => {});
|
||||
|
||||
await expect(driver.withNoProductionInteractions(async () => "first action complete")).rejects.toThrow(
|
||||
"Timed out after 10ms while removing the live read-only production-interaction route.",
|
||||
);
|
||||
|
||||
expect(driver.readOnlyInteractionGuard).toBeNull();
|
||||
expect(context.listeners.get("request")?.size ?? 0).toBe(0);
|
||||
expect(context.routes).toEqual([]);
|
||||
expect(reportProgress).toHaveBeenCalledWith("read-only-interaction-guard.unroute-started", { timeoutMs: 10 });
|
||||
expect(reportProgress).toHaveBeenCalledWith("read-only-interaction-guard.unroute-failed", {
|
||||
message: "Timed out after 10ms while removing the live read-only production-interaction route.",
|
||||
timeoutMs: 10,
|
||||
});
|
||||
|
||||
context.unrouteImplementation = null;
|
||||
await expect(driver.withNoProductionInteractions(async () => "second action complete")).resolves.toBe(
|
||||
"second action complete",
|
||||
);
|
||||
expect(context.routeCalls).toBe(2);
|
||||
expect(context.unrouteCalls).toBe(2);
|
||||
});
|
||||
|
||||
test("preserves both the action failure and a stalled route-removal failure", async () => {
|
||||
const context = new FakeBrowserContext();
|
||||
const driver = createDriver(context, { readOnlyInteractionGuardCleanupTimeout: 10 });
|
||||
const actionError = new Error("read-only action failed");
|
||||
context.unrouteImplementation = () => new Promise(() => {});
|
||||
|
||||
const failure = await driver
|
||||
.withNoProductionInteractions(async () => {
|
||||
throw actionError;
|
||||
})
|
||||
.catch((error) => error);
|
||||
|
||||
expect(failure).toBeInstanceOf(AggregateError);
|
||||
expect(failure.message).toBe("The read-only live scenario and its production-interaction guard both failed.");
|
||||
expect(failure.errors).toHaveLength(2);
|
||||
expect(failure.errors[0]).toBe(actionError);
|
||||
expect(failure.errors[1]).toEqual(
|
||||
expect.objectContaining({
|
||||
message: "Timed out after 10ms while removing the live read-only production-interaction route.",
|
||||
}),
|
||||
);
|
||||
expect(driver.readOnlyInteractionGuard).toBeNull();
|
||||
expect(context.listeners.get("request")?.size ?? 0).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
const { AttributedRuntimeTrafficLedger, attributeRuntimeRequest } = require("../e2e/live/live-youtube-driver");
|
||||
|
||||
const SELECTED_EXTENSION_ID = "abcdefghijklmnopabcdefghijklmnop";
|
||||
const EXPECTED_BUILD_ID = "0123456789abcdef0123456789abcdef";
|
||||
|
||||
function createEventContext(pages = []) {
|
||||
const listeners = new Map();
|
||||
return {
|
||||
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(() => pages),
|
||||
},
|
||||
emit(event, value) {
|
||||
listeners.get(event)?.forEach((listener) => listener(value));
|
||||
},
|
||||
listeners,
|
||||
};
|
||||
}
|
||||
|
||||
function pageRequest(page, { method, pathname, videoId }) {
|
||||
return {
|
||||
frame: () => ({ page: () => page }),
|
||||
method: () => method,
|
||||
postDataJSON: () => ({ userId: "A".repeat(36), value: -1, videoId }),
|
||||
url: () => `https://returnyoutubedislikeapi.com${pathname}${pathname === "/votes" ? `?videoId=${videoId}` : ""}`,
|
||||
};
|
||||
}
|
||||
|
||||
async function advanceFakeTime(milliseconds) {
|
||||
for (let elapsed = 0; elapsed < milliseconds; elapsed += 25) {
|
||||
jest.advanceTimersByTime(Math.min(25, milliseconds - elapsed));
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
describe("run-wide attributed runtime traffic ledger", () => {
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
test.each([
|
||||
["late vote-data GET", { method: "GET", pathname: "/votes" }],
|
||||
["late interaction POST", { method: "POST", pathname: "/interact/vote" }],
|
||||
])("rejects a %s emitted after the stage listener detached", async (_label, requestOptions) => {
|
||||
jest.useFakeTimers({ doNotFake: ["performance"] });
|
||||
const page = {};
|
||||
const harness = createEventContext([page]);
|
||||
const ledger = new AttributedRuntimeTrafficLedger(harness.context, page, (request) =>
|
||||
attributeRuntimeRequest(request, page, "userscript"),
|
||||
);
|
||||
const detachedStageListener = jest.fn();
|
||||
harness.context.on("request", detachedStageListener);
|
||||
harness.context.off("request", detachedStageListener);
|
||||
const request = pageRequest(page, { ...requestOptions, videoId: "abcdefghijk" });
|
||||
|
||||
const audit = ledger.assertNoUnclaimed({ label: "detached stage", quietMs: 100, timeoutMs: 500 });
|
||||
const rejection = expect(audit).rejects.toThrow(/escaped every active audit/);
|
||||
setTimeout(() => harness.emit("request", request), 50);
|
||||
await advanceFakeTime(250);
|
||||
await rejection;
|
||||
|
||||
expect(detachedStageListener).not.toHaveBeenCalled();
|
||||
expect(ledger.records).toEqual([
|
||||
expect.objectContaining({
|
||||
method: requestOptions.method,
|
||||
pathname: requestOptions.pathname,
|
||||
videoId: "abcdefghijk",
|
||||
}),
|
||||
]);
|
||||
ledger.stop();
|
||||
});
|
||||
|
||||
test("accepts delayed traffic only when an active stage claims that exact request", async () => {
|
||||
jest.useFakeTimers({ doNotFake: ["performance"] });
|
||||
const page = {};
|
||||
const harness = createEventContext([page]);
|
||||
const ledger = new AttributedRuntimeTrafficLedger(harness.context, page, (request) =>
|
||||
attributeRuntimeRequest(request, page, "userscript"),
|
||||
);
|
||||
const request = pageRequest(page, { method: "GET", pathname: "/votes", videoId: "abcdefghijk" });
|
||||
harness.context.on("request", (candidate) => ledger.claim(candidate, "exact-votes-stage"));
|
||||
|
||||
const audit = ledger.assertNoUnclaimed({ label: "claimed stage", quietMs: 100, timeoutMs: 500 });
|
||||
setTimeout(() => harness.emit("request", request), 50);
|
||||
await advanceFakeTime(250);
|
||||
|
||||
await expect(audit).resolves.toEqual({ attributedRequests: 1, label: "claimed stage" });
|
||||
ledger.stop();
|
||||
});
|
||||
|
||||
test("retains same-video other-tab ambiguity from request time after that tab navigates away", async () => {
|
||||
const page = { url: () => "https://www.youtube.com/watch?v=abcdefghijk" };
|
||||
let otherUrl = "https://www.youtube.com/shorts/abcdefghijk";
|
||||
const otherPage = { url: () => otherUrl };
|
||||
const harness = createEventContext([page, otherPage]);
|
||||
const ledger = new AttributedRuntimeTrafficLedger(harness.context, page, (request) =>
|
||||
attributeRuntimeRequest(request, page, "extension", SELECTED_EXTENSION_ID, EXPECTED_BUILD_ID),
|
||||
);
|
||||
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",
|
||||
};
|
||||
|
||||
harness.emit("request", request);
|
||||
expect(ledger.claim(request, "exact-votes-stage")).toBe(true);
|
||||
otherUrl = "https://www.youtube.com/watch?v=lmnopqrstuv";
|
||||
|
||||
await expect(ledger.assertNoUnclaimed({ quietMs: 0 })).rejects.toThrow(/same video open at send time/);
|
||||
ledger.stop();
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,8 @@
|
||||
const { assertLogicalVoteHandshake } = require("../e2e/live/live-youtube-driver");
|
||||
|
||||
const VIDEO_ID = "abcdefghijk";
|
||||
const USER_ID = "shared-user-id";
|
||||
const USER_ID = "A".repeat(36);
|
||||
const SOLUTION = Buffer.alloc(4).toString("base64");
|
||||
|
||||
function vote({ status = 200, userId = USER_ID, value = 1, videoId = VIDEO_ID } = {}) {
|
||||
return {
|
||||
@@ -14,7 +15,7 @@ function vote({ status = 200, userId = USER_ID, value = 1, videoId = VIDEO_ID }
|
||||
|
||||
function confirmation({ confirmed = true, status = 200, userId = USER_ID, videoId = VIDEO_ID } = {}) {
|
||||
return {
|
||||
body: { solution: "proof", userId, videoId },
|
||||
body: { solution: SOLUTION, userId, videoId },
|
||||
pathname: "/interact/confirmVote",
|
||||
responseBody: confirmed,
|
||||
responseError: null,
|
||||
@@ -42,13 +43,24 @@ describe("live logical vote handshake validation", () => {
|
||||
});
|
||||
|
||||
test.each([
|
||||
["user", [vote(), vote({ userId: "another-user" }), confirmation()], /different user IDs/],
|
||||
["user", [vote(), vote({ userId: "B".repeat(36) }), confirmation()], /different user IDs/],
|
||||
["video", [vote(), vote({ videoId: "lmnopqrstuv" }), confirmation()], /different video/],
|
||||
["value", [vote(), vote({ value: 0 }), confirmation()], /changed the requested vote value/],
|
||||
])("rejects a retry with a mismatched %s", (_field, records, message) => {
|
||||
expect(() => assertLogicalVoteHandshake(records, VIDEO_ID, 1)).toThrow(message);
|
||||
});
|
||||
|
||||
test("rejects a complete handshake that targets the stale origin video", () => {
|
||||
const staleVideoId = "lmnopqrstuv";
|
||||
expect(() =>
|
||||
assertLogicalVoteHandshake(
|
||||
[vote({ videoId: staleVideoId }), confirmation({ videoId: staleVideoId })],
|
||||
VIDEO_ID,
|
||||
1,
|
||||
),
|
||||
).toThrow(/targeted a different video/);
|
||||
});
|
||||
|
||||
test.each([
|
||||
["extra confirmation", [vote(), confirmation(), confirmation()]],
|
||||
["other interaction", [vote(), { ...vote(), pathname: "/interact/other" }, confirmation()]],
|
||||
@@ -65,6 +77,27 @@ describe("live logical vote handshake validation", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test.each([
|
||||
["short user ID", [vote({ userId: "short" }), confirmation({ userId: "short" })], /36-character/],
|
||||
[
|
||||
"extra vote body key",
|
||||
[{ ...vote(), body: { ...vote().body, extra: true } }, confirmation()],
|
||||
/exactly userId, value, and videoId/,
|
||||
],
|
||||
[
|
||||
"extra confirmation body key",
|
||||
[vote(), { ...confirmation(), body: { ...confirmation().body, extra: true } }],
|
||||
/exactly solution, userId, and videoId/,
|
||||
],
|
||||
[
|
||||
"non-four-byte proof",
|
||||
[vote(), { ...confirmation(), body: { ...confirmation().body, solution: "AA==" } }],
|
||||
/four-byte proof/,
|
||||
],
|
||||
])("rejects a handshake with a %s", (_label, records, message) => {
|
||||
expect(() => assertLogicalVoteHandshake(records, VIDEO_ID, 1)).toThrow(message);
|
||||
});
|
||||
|
||||
test("rejects a failed vote or confirmation response", () => {
|
||||
expect(() => assertLogicalVoteHandshake([vote({ status: 500 }), confirmation()], VIDEO_ID, 1)).toThrow(
|
||||
/Vote request failed with HTTP 500/,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,132 @@
|
||||
const {
|
||||
assertWatchRatioData,
|
||||
colorContrastRatio,
|
||||
formattedWatchTooltipCandidates,
|
||||
normalizeTooltipText,
|
||||
parseCssColor,
|
||||
} = require("../e2e/live/watch-ratio-audit");
|
||||
|
||||
const VIDEO_ID = "abcdefghijk";
|
||||
const COUNTS = Object.freeze({ dislikes: 250, likes: 750 });
|
||||
|
||||
function measurement(overrides = {}) {
|
||||
return {
|
||||
appearance: {
|
||||
inlineFillWidth: "75%",
|
||||
negativeTrackColor: "rgb(126, 126, 126)",
|
||||
pageBackgroundColor: "rgb(15, 15, 15)",
|
||||
tooltipText: "750 / 250",
|
||||
wrapperVideoId: VIDEO_ID,
|
||||
...overrides.appearance,
|
||||
},
|
||||
geometry: {
|
||||
bar: { width: 150 },
|
||||
container: { width: 200 },
|
||||
...overrides.geometry,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function assertMeasurement(value, options = {}) {
|
||||
return assertWatchRatioData(value, {
|
||||
expectedCounts: COUNTS,
|
||||
expectedTooltipCandidates: ["750 / 250"],
|
||||
runtime: "extension",
|
||||
videoId: VIDEO_ID,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
describe("live Watch ratio data audit", () => {
|
||||
test.each([
|
||||
["dark", "rgb(126, 126, 126)", "rgb(15, 15, 15)"],
|
||||
["light", "rgb(115, 115, 115)", "rgb(255, 255, 255)"],
|
||||
])("accepts an owned exact-ratio bar with a visible negative track in the %s theme", (_theme, track, page) => {
|
||||
expect(
|
||||
assertMeasurement(measurement({ appearance: { negativeTrackColor: track, pageBackgroundColor: page } })),
|
||||
).toMatchObject({
|
||||
expectedRatio: 0.75,
|
||||
inlineRatio: 0.75,
|
||||
renderedRatio: 0.75,
|
||||
tooltipText: "750 / 250",
|
||||
wrapperVideoId: VIDEO_ID,
|
||||
});
|
||||
});
|
||||
|
||||
test.each([null, "zyxwvutsrqp"])("rejects a Watch ratio wrapper owned by %s", (wrapperVideoId) => {
|
||||
expect(() => assertMeasurement(measurement({ appearance: { wrapperVideoId } }))).toThrow(
|
||||
/Watch ratio bar is owned by/,
|
||||
);
|
||||
});
|
||||
|
||||
test.each([
|
||||
["inline width", { appearance: { inlineFillWidth: "25%" } }],
|
||||
["rendered geometry", { geometry: { bar: { width: 50 } } }],
|
||||
])("rejects a wrong %s even when the displayed count is unchanged", (_label, override) => {
|
||||
expect(() => assertMeasurement(measurement(override))).toThrow(
|
||||
/expected 75\.000% from exact API counts|expected 75\.00%/,
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects a stale tooltip even when ownership, count text, and ratio are valid", () => {
|
||||
expect(() => assertMeasurement(measurement({ appearance: { tooltipText: "900 / 100" } }))).toThrow(
|
||||
/tooltip.*does not exactly match the API counts/,
|
||||
);
|
||||
});
|
||||
|
||||
test("accepts the exact configured percentage variants", () => {
|
||||
expect(
|
||||
assertMeasurement(measurement({ appearance: { tooltipText: "750 / 250 - 75%" } }), {
|
||||
expectedTooltipCandidates: ["750 / 250", "750 / 250 - 75%", "750 / 250 - 25%"],
|
||||
}).tooltipText,
|
||||
).toBe("750 / 250 - 75%");
|
||||
});
|
||||
|
||||
test("derives every configured tooltip form from the exact API counts and browser locale", () => {
|
||||
expect(formattedWatchTooltipCandidates(COUNTS, "en-US")).toEqual([
|
||||
{ form: "counts", text: "750 / 250" },
|
||||
{ form: "counts-with-like-percentage", text: "750 / 250 - 75%" },
|
||||
{ form: "counts-with-dislike-percentage", text: "750 / 250 - 25%" },
|
||||
{ form: "both-percentages", text: "75% / 25%" },
|
||||
{ form: "like-percentage", text: "75%" },
|
||||
{ form: "dislike-percentage", text: "25%" },
|
||||
]);
|
||||
});
|
||||
|
||||
test.each([
|
||||
["transparent", "rgba(115, 115, 115, 0)"],
|
||||
["ancestor opacity", "rgb(115, 115, 115)", "rgb(15, 15, 15)", 0.01],
|
||||
["near-background dark", "rgb(18, 18, 18)"],
|
||||
["near-background light", "rgb(248, 248, 248)", "rgb(255, 255, 255)"],
|
||||
])("rejects an effectively invisible negative track: %s", (_label, track, page = "rgb(15, 15, 15)", opacity = 1) => {
|
||||
expect(() =>
|
||||
assertMeasurement(
|
||||
measurement({
|
||||
appearance: { negativeTrackColor: track, negativeTrackOpacity: opacity, pageBackgroundColor: page },
|
||||
}),
|
||||
),
|
||||
).toThrow(/negative track is effectively invisible/);
|
||||
});
|
||||
|
||||
test("rejects a negative-track background image instead of guessing its contrast", () => {
|
||||
expect(() =>
|
||||
assertMeasurement(measurement({ appearance: { negativeTrackBackgroundImage: "linear-gradient(#000, #fff)" } })),
|
||||
).toThrow(/background image whose effective contrast cannot be audited/);
|
||||
});
|
||||
|
||||
test("normalizes non-breaking spaces and directional formatting in tooltip text", () => {
|
||||
expect(normalizeTooltipText("\u2066 750\u00a0/\u00a0250 \u2069")).toBe("750 / 250");
|
||||
});
|
||||
|
||||
test.each([
|
||||
["#737373", { a: 1, b: 115, g: 115, r: 115 }],
|
||||
["rgba(115, 115, 115, 50%)", { a: 0.5, b: 115, g: 115, r: 115 }],
|
||||
["color(srgb 0.5 0.25 0 / 0.75)", { a: 0.75, b: 0, g: 63.75, r: 127.5 }],
|
||||
])("parses computed CSS color %s", (value, expected) => {
|
||||
expect(parseCssColor(value)).toEqual(expected);
|
||||
});
|
||||
|
||||
test("computes the standard black-to-white contrast ratio", () => {
|
||||
expect(colorContrastRatio(parseCssColor("#000"), parseCssColor("#fff"))).toBeCloseTo(21, 5);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user