Files
return-youtube-dislike/Extensions/UserScript/live/live-interactive-runner.spec.js
T
Anarios e500088e65 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.
2026-09-06 12:51:12 +02:00

453 lines
18 KiB
JavaScript

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 });
}
});
});