Files
return-youtube-dislike/Extensions/UserScript/live/live-diagnostics.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

611 lines
25 KiB
JavaScript

/**
* @jest-environment jsdom
*/
const path = require("node:path");
const {
LiveFatalSignalGuard,
LiveReadOnlyGate,
LiveRunDiagnostics,
UNHANDLED_REJECTION_PREFIX,
diagnosticApiUrl,
environmentalConsoleWarningId,
readLivePageState,
runIndependentLoggedStages,
runLoggedStage,
} = require("../e2e/live/live-diagnostics");
class FakeEmitter {
constructor() {
this.listeners = new Map();
}
emit(event, value) {
for (const listener of this.listeners.get(event) ?? []) listener(value);
}
off(event, listener) {
this.listeners.get(event)?.delete(listener);
}
on(event, listener) {
if (!this.listeners.has(event)) this.listeners.set(event, new Set());
this.listeners.get(event).add(listener);
}
}
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");
document.body.innerHTML = `
<ytd-reel-video-renderer video-id="abcdefghijk" is-active>
<a href="/shorts/abcdefghijk">Short</a>
<reel-action-bar-view-model>
<like-button-view-model></like-button-view-model>
<button-view-model data-ryd-synthetic-shorts-dislike data-ryd-video-id="abcdefghijk">
<button aria-pressed="false"></button><span>123</span>
</button-view-model>
</reel-action-bar-view-model>
</ytd-reel-video-renderer>
`;
for (const element of document.querySelectorAll("*")) element.getBoundingClientRect = visibleRect;
const state = readLivePageState();
expect(state.runtimeMarkers).toEqual({
extension: null,
extensionBuild: null,
userscript: "3.2.0",
userscriptBuild: null,
});
expect(state.renderers).toEqual([
expect.objectContaining({ actionBars: 1, syntheticControls: 1, videoId: "abcdefghijk", visible: true }),
]);
expect(state.renderers[0].links).toEqual([{ href: "/shorts/abcdefghijk", visible: true }]);
expect(state.actionBars).toEqual([
expect.objectContaining({ nativeLikes: 1, syntheticControls: 1, videoId: "abcdefghijk", visible: true }),
]);
expect(state.syntheticControls).toEqual([
expect.objectContaining({ ariaPressed: "false", text: "123", videoId: "abcdefghijk", visible: true }),
]);
expect(JSON.stringify(state)).not.toContain("outerHTML");
});
test("records browser failures and redacted recent API traffic in the persisted snapshot", async () => {
const context = new FakeEmitter();
const page = new FakeEmitter();
page.addInitScript = jest.fn().mockResolvedValue(undefined);
page.evaluate = jest.fn(async (callback) =>
callback.name === "readLivePageState" ? { runtimeMarkers: { userscript: "3.2.0" } } : undefined,
);
page.isClosed = jest.fn(() => false);
page.url = jest.fn(() => "https://www.youtube.com/shorts/abcdefghijk");
const fileSystem = { mkdirSync: jest.fn(), writeFileSync: jest.fn() };
const log = jest.fn();
const diagnostics = new LiveRunDiagnostics(page, context, {
clock: () => new Date("2026-08-18T12:34:56.000Z"),
fileSystem,
log,
outputDirectory: "diagnostics",
runtime: "userscript",
});
await diagnostics.start();
diagnostics.stageStarted("read-only.channel-to-shorts-and-next");
diagnostics.checkpoint("ryd-votes-response.waiting", { videoId: "abcdefghijk" });
const request = {
method: () => "GET",
resourceType: () => "fetch",
url: () => "https://returnyoutubedislikeapi.com/votes?videoId=abcdefghijk&userId=private-id",
};
context.emit("request", request);
context.emit("response", { request: () => request, status: () => 200 });
page.emit("pageerror", new Error("page exploded"));
page.emit("console", {
location: () => ({ lineNumber: 7, url: "https://www.youtube.com/shorts/abcdefghijk" }),
text: () => `${UNHANDLED_REJECTION_PREFIX}promise exploded`,
type: () => "error",
});
page.emit("console", {
location: () => ({}),
text: () => "console exploded",
type: () => "error",
});
const snapshotPath = await diagnostics.persistFailureSnapshot(new Error("scenario timed out"));
diagnostics.stop();
expect(snapshotPath).toBe(path.join("diagnostics", "failure-2026-08-18T12-34-56-000Z.json"));
expect(fileSystem.mkdirSync).toHaveBeenCalledWith("diagnostics", { recursive: true });
const snapshot = JSON.parse(fileSystem.writeFileSync.mock.calls[0][1]);
expect(snapshot.currentStage).toBe("read-only.channel-to-shorts-and-next");
expect(snapshot.currentCheckpoint).toBe("ryd-votes-response.waiting");
expect(snapshot.browserSignals.map(({ type }) => type)).toEqual([
"pageerror",
"unhandledrejection",
"console.error",
]);
expect(snapshot.recentApiRequests).toEqual([
expect.objectContaining({
method: "GET",
pathname: "/votes",
query: { userId: "<redacted>", videoId: "abcdefghijk" },
status: 200,
}),
]);
expect(snapshot.pageState).toEqual({ runtimeMarkers: { userscript: "3.2.0" } });
expect(snapshot.url).toBe("https://www.youtube.com/shorts/abcdefghijk");
expect(context.listeners.get("request").size).toBe(0);
expect(page.listeners.get("pageerror").size).toBe(0);
});
test("logs stage completion and failure boundaries", async () => {
const diagnostics = {
stageCompleted: jest.fn(),
stageFailed: jest.fn(),
stageStarted: jest.fn(),
};
await expect(runLoggedStage(diagnostics, "success", async () => 42)).resolves.toBe(42);
const failure = new Error("failed");
await expect(
runLoggedStage(diagnostics, "failure", async () => {
throw failure;
}),
).rejects.toBe(failure);
expect(diagnostics.stageStarted.mock.calls.map(([name]) => name)).toEqual(["success", "failure"]);
expect(diagnostics.stageCompleted).toHaveBeenCalledWith("success", expect.any(Number));
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(
"https://returnyoutubedislikeapi.com/puzzle/registration?userId=private&videoId=abcdefghijk&solution=secret",
),
).toEqual({
pathname: "/puzzle/registration",
query: { solution: "<redacted>", userId: "<redacted>", videoId: "abcdefghijk" },
});
});
});